mirror of
https://github.com/jmcorgan/fips.git
synced 2026-07-22 15:52:20 +00:00
Compare commits
52 Commits
v0.1.0
...
v0.2.1-rel
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d72e619c51 | ||
|
|
de8db82614 | ||
|
|
db4b32110c | ||
|
|
b36966be3a | ||
|
|
7e002a3883 | ||
|
|
d16acf8cea | ||
|
|
42834b8008 | ||
|
|
0b81f15369 | ||
|
|
19cb776216 | ||
|
|
68bdcb2c75 | ||
|
|
48b1617497 | ||
|
|
13c0b70dc3 | ||
|
|
a859da7748 | ||
|
|
15628e5b41 | ||
|
|
f6f2bea792 | ||
|
|
0ff9139b64 | ||
|
|
7d33f1f2c9 | ||
|
|
97fc29eb82 | ||
|
|
8c4455cc1c | ||
|
|
7f33e5f867 | ||
|
|
bce5619b74 | ||
|
|
9757877c0a | ||
|
|
94884876b8 | ||
|
|
a16370e78d | ||
|
|
537eaf0db7 | ||
|
|
6aff490251 | ||
|
|
7a643a9ac3 | ||
|
|
c164de8808 | ||
|
|
fed6cc6987 | ||
|
|
5053cf673d | ||
|
|
8d51dbd268 | ||
|
|
9e63b42bd9 | ||
|
|
c8b7459fbc | ||
|
|
8c349f524e | ||
|
|
0e098cadad | ||
|
|
ebabb6e93c | ||
|
|
a5130b357e | ||
|
|
6e03adde72 | ||
|
|
1020f90828 | ||
|
|
0f24333c0d | ||
|
|
ef73e54316 | ||
|
|
8697897047 | ||
|
|
9e11a8e7ba | ||
|
|
e8ef15acb7 | ||
|
|
5f7fe989f3 | ||
|
|
324535e76d | ||
|
|
959134657f | ||
|
|
6c90cf6c02 | ||
|
|
c0f30d8fe8 | ||
|
|
d873d0e00e | ||
|
|
35ff4a5d0a | ||
|
|
1bfb58845a |
2
.git-blame-ignore-revs
Normal file
2
.git-blame-ignore-revs
Normal file
@@ -0,0 +1,2 @@
|
||||
# rustfmt bulk reformat (maint)
|
||||
13c0b70dc3111cf94fef217b0f8b5fdbe469d3eb
|
||||
36
.github/workflows/aur-publish.yml
vendored
Normal file
36
.github/workflows/aur-publish.yml
vendored
Normal file
@@ -0,0 +1,36 @@
|
||||
name: AUR Publish
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
jobs:
|
||||
aur-publish-fips:
|
||||
name: Publish fips to AUR
|
||||
runs-on: ubuntu-latest
|
||||
continue-on-error: true
|
||||
if: "!contains(github.ref_name, '-')"
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Update pkgver in PKGBUILD
|
||||
run: |
|
||||
VERSION="${GITHUB_REF_NAME#v}"
|
||||
sed -i "s/^pkgver=.*/pkgver=${VERSION}/" packaging/aur/PKGBUILD
|
||||
|
||||
- name: Publish to AUR
|
||||
uses: KSXGitHub/github-actions-deploy-aur@v4.1.1
|
||||
with:
|
||||
pkgname: fips
|
||||
pkgbuild: packaging/aur/PKGBUILD
|
||||
updpkgsums: true
|
||||
assets: |
|
||||
packaging/aur/fips.sysusers
|
||||
packaging/aur/fips.tmpfiles
|
||||
packaging/aur/fips.install
|
||||
commit_username: ${{ github.repository_owner }}
|
||||
commit_email: ${{ secrets.AUR_EMAIL }}
|
||||
ssh_private_key: ${{ secrets.AUR_SSH_PRIVATE_KEY }}
|
||||
commit_message: "Update to ${{ github.ref_name }}"
|
||||
96
.github/workflows/ci.yml
vendored
96
.github/workflows/ci.yml
vendored
@@ -18,6 +18,7 @@ permissions:
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
RUST_BACKTRACE: 1
|
||||
SOURCE_DATE_EPOCH: 0 # overridden per-step after checkout
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Job 1 – Build matrix
|
||||
@@ -26,6 +27,16 @@ env:
|
||||
# separate ci-compat.yml workflow so their failures don't mark this run red.
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
jobs:
|
||||
fmt:
|
||||
name: Format check
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
components: rustfmt
|
||||
- run: cargo fmt --check
|
||||
|
||||
build:
|
||||
name: Build (${{ matrix.os }})
|
||||
runs-on: ${{ matrix.os }}
|
||||
@@ -40,6 +51,9 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set SOURCE_DATE_EPOCH from git
|
||||
run: echo "SOURCE_DATE_EPOCH=$(git log -1 --format=%ct)" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
@@ -57,6 +71,9 @@ jobs:
|
||||
- name: Build
|
||||
run: cargo build --release
|
||||
|
||||
- name: SHA-256 hashes
|
||||
run: sha256sum target/release/fips target/release/fipsctl target/release/fipstop
|
||||
|
||||
# Upload the Linux binary so integration jobs can use it without rebuilding
|
||||
- name: Upload Linux binary
|
||||
if: matrix.os == 'ubuntu-latest'
|
||||
@@ -82,6 +99,9 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set SOURCE_DATE_EPOCH from git
|
||||
run: echo "SOURCE_DATE_EPOCH=$(git log -1 --format=%ct)" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
@@ -125,8 +145,8 @@ jobs:
|
||||
# Runs only when both build and test succeed. Each topology / scenario is a
|
||||
# separate matrix entry so they run in parallel.
|
||||
#
|
||||
# Static topologies → build Docker images, start containers, ping-test
|
||||
# Chaos scenarios → build sim image, run stochastic simulation
|
||||
# All harnesses share a single Docker image (fips-test:latest) built once
|
||||
# in the setup step from testing/docker/.
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
integration:
|
||||
name: Integration (${{ matrix.suite }})
|
||||
@@ -153,24 +173,25 @@ jobs:
|
||||
- suite: chaos-smoke-10
|
||||
type: chaos
|
||||
scenario: smoke-10
|
||||
- suite: chaos-10
|
||||
- suite: churn-mixed-10
|
||||
type: chaos
|
||||
scenario: chaos-10
|
||||
scenario: churn-mixed
|
||||
chaos_flags: "--nodes 10 --duration 120"
|
||||
- suite: ethernet-mesh
|
||||
type: chaos
|
||||
scenario: ethernet-mesh
|
||||
- suite: ethernet-only
|
||||
type: chaos
|
||||
scenario: ethernet-only
|
||||
- suite: tcp-mesh
|
||||
type: chaos
|
||||
scenario: tcp-mesh
|
||||
- suite: bottleneck-parent
|
||||
type: chaos
|
||||
scenario: bottleneck-parent
|
||||
- suite: cost-avoidance
|
||||
type: chaos
|
||||
scenario: cost-avoidance
|
||||
- suite: cost-mixed-7node
|
||||
type: chaos
|
||||
scenario: cost-mixed-7node
|
||||
- suite: cost-reeval
|
||||
type: chaos
|
||||
scenario: cost-reeval
|
||||
@@ -183,6 +204,9 @@ jobs:
|
||||
- suite: mixed-technology
|
||||
type: chaos
|
||||
scenario: mixed-technology
|
||||
- suite: congestion-stress
|
||||
type: chaos
|
||||
scenario: congestion-stress
|
||||
# ── Sidecar deployment ──────────────────────────────────────────
|
||||
- suite: sidecar
|
||||
type: sidecar
|
||||
@@ -197,24 +221,22 @@ jobs:
|
||||
name: fips-linux
|
||||
path: _bin
|
||||
|
||||
# ── Static topology ────────────────────────────────────────────────────
|
||||
- name: Install binary (static)
|
||||
if: matrix.type == 'static'
|
||||
# Install binaries to unified docker context and build shared image
|
||||
- name: Install binaries and build Docker image
|
||||
run: |
|
||||
chmod +x _bin/fips _bin/fipsctl
|
||||
cp _bin/fips testing/static/fips
|
||||
cp _bin/fipsctl testing/static/fipsctl
|
||||
[ -f _bin/fipstop ] && chmod +x _bin/fipstop || true
|
||||
cp _bin/fips testing/docker/fips
|
||||
cp _bin/fipsctl testing/docker/fipsctl
|
||||
[ -f _bin/fipstop ] && cp _bin/fipstop testing/docker/fipstop || true
|
||||
docker build -t fips-test:latest testing/docker
|
||||
docker build -t fips-test-app:latest -f testing/docker/Dockerfile.app testing/docker
|
||||
|
||||
# ── Static topology ────────────────────────────────────────────────────
|
||||
- name: Generate configs (static)
|
||||
if: matrix.type == 'static'
|
||||
run: bash testing/static/scripts/generate-configs.sh ${{ matrix.topology }}
|
||||
|
||||
- name: Build Docker images (static)
|
||||
if: matrix.type == 'static'
|
||||
run: |
|
||||
docker compose -f testing/static/docker-compose.yml \
|
||||
--profile ${{ matrix.topology }} build
|
||||
|
||||
- name: Start containers (static)
|
||||
if: matrix.type == 'static'
|
||||
run: |
|
||||
@@ -238,25 +260,12 @@ jobs:
|
||||
--profile ${{ matrix.topology }} down --volumes --remove-orphans
|
||||
|
||||
# ── Rekey integration test ──────────────────────────────────────────────
|
||||
- name: Install binary (rekey)
|
||||
if: matrix.type == 'rekey'
|
||||
run: |
|
||||
chmod +x _bin/fips _bin/fipsctl
|
||||
cp _bin/fips testing/static/fips
|
||||
cp _bin/fipsctl testing/static/fipsctl
|
||||
|
||||
- name: Generate and inject configs (rekey)
|
||||
if: matrix.type == 'rekey'
|
||||
run: |
|
||||
bash testing/static/scripts/generate-configs.sh rekey
|
||||
bash testing/static/scripts/rekey-test.sh inject-config
|
||||
|
||||
- name: Build Docker images (rekey)
|
||||
if: matrix.type == 'rekey'
|
||||
run: |
|
||||
docker compose -f testing/static/docker-compose.yml \
|
||||
--profile rekey build
|
||||
|
||||
- name: Start containers (rekey)
|
||||
if: matrix.type == 'rekey'
|
||||
run: |
|
||||
@@ -284,20 +293,9 @@ jobs:
|
||||
if: matrix.type == 'chaos'
|
||||
run: pip3 install --quiet pyyaml jinja2
|
||||
|
||||
- name: Install binary (chaos)
|
||||
if: matrix.type == 'chaos'
|
||||
run: |
|
||||
chmod +x _bin/fips _bin/fipsctl
|
||||
cp _bin/fips testing/chaos/fips
|
||||
cp _bin/fipsctl testing/chaos/fipsctl
|
||||
|
||||
- name: Build chaos Docker image
|
||||
if: matrix.type == 'chaos'
|
||||
run: docker build -t fips-chaos:latest testing/chaos
|
||||
|
||||
- name: Run chaos scenario
|
||||
if: matrix.type == 'chaos'
|
||||
run: bash testing/chaos/scripts/chaos.sh ${{ matrix.scenario }}
|
||||
run: bash testing/chaos/scripts/chaos.sh ${{ matrix.scenario }} ${{ matrix.chaos_flags }}
|
||||
|
||||
- name: Upload sim results on failure (chaos)
|
||||
if: matrix.type == 'chaos' && failure()
|
||||
@@ -308,17 +306,9 @@ jobs:
|
||||
retention-days: 7
|
||||
|
||||
# ── Sidecar deployment ──────────────────────────────────────────────
|
||||
- name: Install binary (sidecar)
|
||||
if: matrix.type == 'sidecar'
|
||||
run: |
|
||||
chmod +x _bin/fips _bin/fipsctl _bin/fipstop
|
||||
cp _bin/fips testing/sidecar/fips
|
||||
cp _bin/fipsctl testing/sidecar/fipsctl
|
||||
cp _bin/fipstop testing/sidecar/fipstop
|
||||
|
||||
- name: Run sidecar integration test
|
||||
if: matrix.type == 'sidecar'
|
||||
run: bash testing/sidecar/scripts/test-sidecar.sh
|
||||
run: bash testing/sidecar/scripts/test-sidecar.sh --skip-build
|
||||
|
||||
- name: Collect logs on failure (sidecar)
|
||||
if: matrix.type == 'sidecar' && failure()
|
||||
@@ -327,4 +317,4 @@ jobs:
|
||||
echo "--- sidecar-${node} logs ---"
|
||||
docker logs "sidecar-${node}-fips-1" 2>&1 || true
|
||||
echo ""
|
||||
done
|
||||
done
|
||||
|
||||
196
.github/workflows/package-linux.yml
vendored
Normal file
196
.github/workflows/package-linux.yml
vendored
Normal file
@@ -0,0 +1,196 @@
|
||||
name: Linux Package
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
|
||||
jobs:
|
||||
determine-versioning:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
linux_package_version: ${{ steps.linux_version.outputs.linux_package_version }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Derive Linux package version
|
||||
id: linux_version
|
||||
shell: bash
|
||||
run: |
|
||||
: ${GITHUB_OUTPUT:=/tmp/github_output}
|
||||
|
||||
BASE_VERSION=$(grep '^version' Cargo.toml | head -1 | sed 's/.*"\(.*\)"/\1/')
|
||||
if [[ "$GITHUB_REF" == refs/tags/* ]]; then
|
||||
VERSION="${GITHUB_REF_NAME#v}"
|
||||
else
|
||||
BRANCH=$(echo "$GITHUB_REF_NAME" | sed 's|[^A-Za-z0-9]|.|g; s/\.\.+/./g; s/^\.//; s/\.$//')
|
||||
HEIGHT=$(git rev-list --count HEAD)
|
||||
HASH=$(git rev-parse --short HEAD)
|
||||
if [[ -z "$BRANCH" ]]; then
|
||||
BRANCH="ref"
|
||||
fi
|
||||
VERSION="${BASE_VERSION}+${BRANCH}.${HEIGHT}.${HASH}"
|
||||
fi
|
||||
|
||||
echo "linux_package_version=${VERSION}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
build:
|
||||
name: Build Linux artifacts (${{ matrix.artifact_arch }})
|
||||
runs-on: ${{ matrix.os }}
|
||||
needs: determine-versioning
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- os: ubuntu-latest
|
||||
artifact_arch: x86_64
|
||||
deb_arch: amd64
|
||||
- os: ubuntu-24.04-arm
|
||||
artifact_arch: aarch64
|
||||
deb_arch: arm64
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set SOURCE_DATE_EPOCH from git
|
||||
run: echo "SOURCE_DATE_EPOCH=$(git log -1 --format=%ct)" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Install system dependencies
|
||||
run: sudo apt-get update && sudo apt-get install -y --no-install-recommends libdbus-1-dev llvm
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Cache Cargo registry + build
|
||||
if: ${{ env.ACT != 'true' }}
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry
|
||||
~/.cargo/git
|
||||
target
|
||||
key: linux-release-${{ runner.os }}-${{ matrix.artifact_arch }}-${{ hashFiles('**/Cargo.lock') }}
|
||||
restore-keys: |
|
||||
linux-release-${{ runner.os }}-${{ matrix.artifact_arch }}-
|
||||
|
||||
- name: Install cargo-deb
|
||||
run: cargo install cargo-deb --version 3.6.3 --locked
|
||||
|
||||
- name: Build release binaries
|
||||
run: cargo build --release
|
||||
|
||||
- name: Build systemd tarball
|
||||
env:
|
||||
STRIP: llvm-strip
|
||||
run: |
|
||||
packaging/systemd/build-tarball.sh \
|
||||
--version "${{ needs.determine-versioning.outputs.linux_package_version }}" \
|
||||
--arch "${{ matrix.artifact_arch }}" \
|
||||
--no-build
|
||||
|
||||
- name: Build Debian package
|
||||
run: |
|
||||
packaging/debian/build-deb.sh \
|
||||
--version "${{ needs.determine-versioning.outputs.linux_package_version }}" \
|
||||
--no-build
|
||||
|
||||
- name: Resolve Linux asset paths
|
||||
id: linux-assets
|
||||
shell: bash
|
||||
run: |
|
||||
: ${GITHUB_OUTPUT:=/tmp/github_output}
|
||||
|
||||
TARBALL="deploy/fips-${{ needs.determine-versioning.outputs.linux_package_version }}-linux-${{ matrix.artifact_arch }}.tar.gz"
|
||||
if [[ ! -f "$TARBALL" ]]; then
|
||||
echo "Missing tarball: $TARBALL" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
DEB_FILE=$(find deploy -maxdepth 1 -type f -name "fips_*_${{ matrix.deb_arch }}.deb" | sort | head -n 1)
|
||||
if [[ -z "$DEB_FILE" ]]; then
|
||||
echo "Missing Debian package for ${{ matrix.deb_arch }}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "tarball=$TARBALL" >> "$GITHUB_OUTPUT"
|
||||
echo "deb=$DEB_FILE" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: SHA-256 hashes
|
||||
run: |
|
||||
echo "==> Linux release assets:"
|
||||
sha256sum \
|
||||
"${{ steps.linux-assets.outputs.tarball }}" \
|
||||
"${{ steps.linux-assets.outputs.deb }}"
|
||||
|
||||
- name: Upload artifact (GitHub only)
|
||||
if: ${{ env.ACT != 'true' }}
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: fips_${{ needs.determine-versioning.outputs.linux_package_version }}_${{ matrix.artifact_arch }}_linux
|
||||
path: |
|
||||
${{ steps.linux-assets.outputs.tarball }}
|
||||
${{ steps.linux-assets.outputs.deb }}
|
||||
retention-days: 30
|
||||
|
||||
- name: Build Summary
|
||||
run: |
|
||||
echo "Build Summary for linux/${{ matrix.artifact_arch }}:"
|
||||
echo " Tarball: ${{ steps.linux-assets.outputs.tarball }}"
|
||||
echo " Debian: ${{ steps.linux-assets.outputs.deb }}"
|
||||
|
||||
release:
|
||||
name: Publish Linux assets to GitHub Release
|
||||
runs-on: ubuntu-latest
|
||||
needs: build
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
steps:
|
||||
- name: Download Linux artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: dist
|
||||
merge-multiple: true
|
||||
|
||||
- name: Generate Linux release checksums
|
||||
run: |
|
||||
cd dist
|
||||
find . -maxdepth 1 -type f \( -name '*.deb' -o -name '*.tar.gz' \) -printf '%P\n' \
|
||||
| LC_ALL=C sort \
|
||||
| xargs sha256sum \
|
||||
> checksums-linux.txt
|
||||
|
||||
- name: Wait for tag release
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
for attempt in $(seq 1 20); do
|
||||
if gh release view "${GITHUB_REF_NAME}" --repo "${GITHUB_REPOSITORY}" >/dev/null 2>&1; then
|
||||
exit 0
|
||||
fi
|
||||
echo "Release ${GITHUB_REF_NAME} not available yet; waiting..."
|
||||
sleep 15
|
||||
done
|
||||
|
||||
echo "Timed out waiting for release ${GITHUB_REF_NAME}" >&2
|
||||
exit 1
|
||||
|
||||
- name: Upload Linux assets
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
gh release upload "${GITHUB_REF_NAME}" \
|
||||
dist/*.deb \
|
||||
dist/*.tar.gz \
|
||||
dist/checksums-linux.txt \
|
||||
--clobber \
|
||||
--repo "${GITHUB_REPOSITORY}"
|
||||
255
.github/workflows/package-openwrt.yml
vendored
255
.github/workflows/package-openwrt.yml
vendored
@@ -1,5 +1,4 @@
|
||||
name: OpenWrt Package
|
||||
|
||||
on:
|
||||
push:
|
||||
workflow_dispatch:
|
||||
@@ -11,11 +10,57 @@ on:
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
PACKAGE_NAME: "fips"
|
||||
|
||||
jobs:
|
||||
determine-versioning:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
package_version: ${{ steps.version.outputs.package_version }}
|
||||
release_channel: ${{ steps.channel.outputs.release_channel }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Derive package version
|
||||
id: version
|
||||
shell: bash
|
||||
run: |
|
||||
: ${GITHUB_OUTPUT:=/tmp/github_output}
|
||||
if [[ "$GITHUB_REF" == refs/tags/* ]]; then
|
||||
echo "package_version=${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"
|
||||
fi
|
||||
|
||||
- name: Determine release channel
|
||||
id: channel
|
||||
shell: bash
|
||||
run: |
|
||||
: ${GITHUB_OUTPUT:=/tmp/github_output}
|
||||
if [[ "$GITHUB_REF" == refs/tags/* ]]; then
|
||||
TAG_NAME=${GITHUB_REF#refs/tags/}
|
||||
if [[ $TAG_NAME =~ ^v[0-9]+\.[0-9]+\.[0-9]+-alpha ]]; then
|
||||
echo "release_channel=alpha" >> "$GITHUB_OUTPUT"
|
||||
elif [[ $TAG_NAME =~ ^v[0-9]+\.[0-9]+\.[0-9]+-beta ]]; then
|
||||
echo "release_channel=beta" >> "$GITHUB_OUTPUT"
|
||||
elif [[ $TAG_NAME =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||
echo "release_channel=stable" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "release_channel=dev" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
else
|
||||
echo "release_channel=dev" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
build:
|
||||
name: Build .ipk (${{ matrix.openwrt_arch }})
|
||||
runs-on: ubuntu-latest
|
||||
needs: determine-versioning
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
@@ -46,19 +91,13 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Derive package version
|
||||
id: version
|
||||
- name: Set SOURCE_DATE_EPOCH from git
|
||||
run: echo "SOURCE_DATE_EPOCH=$(git log -1 --format=%ct)" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Initialize
|
||||
run: |
|
||||
if [[ "$GITHUB_REF" == refs/tags/* ]]; then
|
||||
VERSION="${GITHUB_REF_NAME#v}"
|
||||
else
|
||||
BRANCH=$(echo "$GITHUB_REF_NAME" | sed 's|/|-|g')
|
||||
HEIGHT=$(git rev-list --count HEAD)
|
||||
HASH=$(git rev-parse --short HEAD)
|
||||
VERSION="${BRANCH}.${HEIGHT}.${HASH}"
|
||||
fi
|
||||
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
||||
echo "filename=fips_${VERSION}_${{ matrix.openwrt_arch }}.ipk" >> "$GITHUB_OUTPUT"
|
||||
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'
|
||||
@@ -73,6 +112,7 @@ jobs:
|
||||
components: rust-src
|
||||
|
||||
- name: Cache Cargo registry + build
|
||||
if: ${{ env.ACT != 'true' }}
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
@@ -84,29 +124,200 @@ jobs:
|
||||
openwrt-${{ matrix.rust_target }}-
|
||||
|
||||
- name: Install cargo-zigbuild
|
||||
run: cargo install cargo-zigbuild --locked
|
||||
run: cargo install cargo-zigbuild --version 0.19.8 --locked
|
||||
|
||||
- name: Install zig (required by cargo-zigbuild)
|
||||
uses: goto-bus-stop/setup-zig@v2
|
||||
run: |
|
||||
ZIG_VERSION="0.13.0"
|
||||
ARCH=$(uname -m)
|
||||
case "$ARCH" in
|
||||
x86_64|amd64) ZIG_ARCH="x86_64" ;;
|
||||
aarch64|arm64) ZIG_ARCH="aarch64" ;;
|
||||
*) echo "Unsupported architecture: $ARCH"; exit 1 ;;
|
||||
esac
|
||||
curl -fsSL "https://ziglang.org/download/${ZIG_VERSION}/zig-linux-${ZIG_ARCH}-${ZIG_VERSION}.tar.xz" | sudo tar xJ -C /opt
|
||||
sudo ln -sf /opt/zig-linux-${ZIG_ARCH}-${ZIG_VERSION}/zig /usr/local/bin/zig
|
||||
zig version
|
||||
|
||||
- name: Install llvm-strip
|
||||
run: sudo apt-get install -y --no-install-recommends llvm
|
||||
run: sudo apt-get update && sudo apt-get install -y --no-install-recommends llvm
|
||||
|
||||
- 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: Build .ipk
|
||||
env:
|
||||
PKG_VERSION: ${{ steps.version.outputs.version }}
|
||||
PKG_VERSION: ${{ needs.determine-versioning.outputs.package_version }}
|
||||
LLVM_STRIP: llvm-strip
|
||||
run: ./packaging/openwrt/build-ipk.sh --arch ${{ matrix.build_arch }}
|
||||
run: ./packaging/openwrt-ipk/build-ipk.sh --arch ${{ matrix.build_arch }}
|
||||
|
||||
- name: Upload artifact
|
||||
- name: SHA-256 hashes
|
||||
run: |
|
||||
echo "==> Binaries:"
|
||||
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@v4
|
||||
with:
|
||||
name: ${{ steps.version.outputs.filename }}
|
||||
path: dist/${{ steps.version.outputs.filename }}
|
||||
name: ${{ env.PACKAGE_FILENAME }}
|
||||
path: dist/${{ env.PACKAGE_FILENAME }}
|
||||
retention-days: 30
|
||||
|
||||
- name: Upload to Blossom
|
||||
id: blossom_upload
|
||||
shell: bash
|
||||
env:
|
||||
BLOSSOM_SERVER: "https://blossom.primal.net"
|
||||
NSEC: ${{ steps.keys.outputs.nsec }}
|
||||
run: |
|
||||
: ${GITHUB_OUTPUT:=/tmp/github_output}
|
||||
|
||||
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 "Failed to extract hash from upload response"
|
||||
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
|
||||
shell: bash
|
||||
env:
|
||||
RELAYS: "wss://relay.damus.io wss://nos.lol wss://nostr.mom wss://relay.primal.net"
|
||||
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 }}" \
|
||||
--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 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: Verify NIP-94 event on relays
|
||||
if: ${{ steps.publish.outputs.eventId != '' }}
|
||||
env:
|
||||
EVENT_ID: ${{ steps.publish.outputs.eventId }}
|
||||
RELAYS: "wss://relay.damus.io wss://nos.lol wss://nostr.mom wss://relay.primal.net"
|
||||
run: |
|
||||
echo "Verifying event $EVENT_ID on relays..."
|
||||
FOUND=0
|
||||
for relay in $RELAYS; do
|
||||
echo "Checking $relay..."
|
||||
RESULT=$(nak req -i "$EVENT_ID" "$relay" 2>/dev/null || echo "")
|
||||
if [ -n "$RESULT" ]; then
|
||||
echo "Found on $relay"
|
||||
FOUND=1
|
||||
else
|
||||
echo "Not found on $relay"
|
||||
fi
|
||||
done
|
||||
|
||||
if [ $FOUND -eq 0 ]; then
|
||||
echo "Warning: Event not found on any relay yet (may still be propagating)"
|
||||
else
|
||||
echo "Event verified on at least one relay"
|
||||
fi
|
||||
|
||||
- name: Build Summary
|
||||
run: |
|
||||
echo "Build Summary for ${{ matrix.openwrt_arch }}:"
|
||||
echo " Package: ${{ env.PACKAGE_FILENAME }}"
|
||||
echo " Release EventId: ${{ steps.publish.outputs.eventId }}"
|
||||
echo " Blossom URL: ${{ steps.blossom_upload.outputs.url }}"
|
||||
|
||||
release:
|
||||
name: Publish GitHub Release
|
||||
name: Publish GitHub Release (github only)
|
||||
runs-on: ubuntu-latest
|
||||
needs: build
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
|
||||
10
.gitignore
vendored
10
.gitignore
vendored
@@ -20,4 +20,12 @@ vps.env
|
||||
reference/
|
||||
|
||||
dist/
|
||||
*.ipk
|
||||
*.ipk
|
||||
|
||||
sim-results/
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*.egg-info/
|
||||
*.egg
|
||||
148
CHANGELOG.md
148
CHANGELOG.md
@@ -5,7 +5,153 @@ All notable changes to this project will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [Unreleased]
|
||||
## [0.2.1] - 2026-05-11
|
||||
|
||||
### Added
|
||||
|
||||
- Linux release artifact workflow: builds x86_64 and aarch64 tarballs
|
||||
and `.deb` packages on `v*` tag push, with SHA-256 checksums
|
||||
- AUR publish workflow for tagged stable releases
|
||||
|
||||
### Changed
|
||||
|
||||
- Validate bloom filter fill ratio on FilterAnnounce ingress.
|
||||
Inbound FilterAnnounce messages whose derived false-positive
|
||||
rate exceeds `node.bloom.max_inbound_fpr` (new config field,
|
||||
default 0.05) are rejected silently on the wire, logged at WARN,
|
||||
and counted in a new `bloom.fill_exceeded` counter. A
|
||||
rate-limited WARN also fires if our own outgoing filter's FPR
|
||||
exceeds the cap. `BloomFilter::estimated_count` now takes
|
||||
`max_fpr` and returns `Option<f64>`, returning `None` for
|
||||
saturated filters; this propagates through `compute_mesh_size`
|
||||
into `estimated_mesh_size` (already `Option<u64>`)
|
||||
|
||||
### Fixed
|
||||
|
||||
- Control socket path detection in fipsctl and fipstop now checks for
|
||||
the `/run/fips/` directory instead of the socket file inside it, so
|
||||
users not yet in the `fips` group get a clear "Permission denied"
|
||||
error instead of a misleading "No such file" fallback to
|
||||
`$XDG_RUNTIME_DIR` ([#30](https://github.com/jmcorgan/fips/issues/30),
|
||||
reported by [@Sebastix](https://github.com/Sebastix))
|
||||
- OpenWrt ipk build excluded BLE feature that requires D-Bus, which is
|
||||
unavailable on OpenWrt targets
|
||||
- IPv6 routing policy rule added at TUN setup to protect `fd00::/8`
|
||||
from interception by Tailscale's table 52 default route
|
||||
- Bloom filter routing no longer swallows traffic when no bloom
|
||||
candidate is strictly closer than the current node. `find_next_hop`
|
||||
now falls through to greedy tree routing in that case instead of
|
||||
returning `NoRoute`, which previously caused dropped packets in
|
||||
topologies where the tree parent was closer but not a bloom
|
||||
candidate
|
||||
- Auto-connect peers now reconnect after a graceful `Disconnect`
|
||||
notification from the remote side. `handle_disconnect` previously
|
||||
removed the peer without scheduling a reconnect, orphaning the
|
||||
entry on a clean upstream shutdown; the other removal paths
|
||||
(link-dead, decrypt failure, peer restart) already scheduled
|
||||
reconnect ([#60](https://github.com/jmcorgan/fips/issues/60),
|
||||
reported by [@SwapMarket](https://github.com/SwapMarket))
|
||||
- `fipsctl connect` now rejects FIPS mesh (`fd00::/8`) addresses for
|
||||
`udp`, `tcp`, and `ethernet` transports with a clear error message
|
||||
instead of echoing success while the daemon silently failed the
|
||||
bind with `EAFNOSUPPORT`
|
||||
([#61](https://github.com/jmcorgan/fips/issues/61),
|
||||
reported by [@SwapMarket](https://github.com/SwapMarket))
|
||||
- Tighten TreeAnnounce ancestry validation to match the spanning
|
||||
tree specification. The receive path now verifies that the
|
||||
ancestry is structurally consistent with the signed parent
|
||||
declaration before mutating tree state.
|
||||
- Make the tree ancestry acceptance unit test deterministic.
|
||||
`test_tree_announce_validate_semantics_accepts_valid_non_root`
|
||||
generated a random signing identity while pinning the fixed root
|
||||
to `node_addr[0] = 0x01`; about 2 in 256 random identities were
|
||||
numerically smaller than the claimed root, triggering
|
||||
`AncestryRootNotMinimum`. The test now regenerates the identity
|
||||
until its `node_addr` is strictly larger than both the fixed
|
||||
parent and root.
|
||||
|
||||
## [0.2.0] - 2026-03-22
|
||||
|
||||
### Added
|
||||
|
||||
#### Operator Tooling
|
||||
|
||||
- `fipsctl connect` and `disconnect` commands for runtime peer
|
||||
management via control socket, with hostname resolution from
|
||||
`/etc/fips/hosts`
|
||||
|
||||
#### IPv6 Adapter
|
||||
|
||||
- Pre-seed identity cache from configured peer npubs at startup, so TUN packets can be dispatched immediately without waiting for handshake completion ([@v0l](https://github.com/v0l))
|
||||
|
||||
#### Mesh Peer Transports
|
||||
|
||||
- New Tor transport with SOCKS5 and directory-mode onion service for anonymous inbound and outbound peering
|
||||
- DNS hostname support in peer addresses for UDP and TCP transports
|
||||
- Non-blocking transport connect for connection-oriented transports (TCP, Tor)
|
||||
|
||||
#### Packaging and Deployment
|
||||
|
||||
- Reproducible build infrastructure: Rust toolchain pinning via
|
||||
`rust-toolchain.toml`, `SOURCE_DATE_EPOCH` in CI and packaging
|
||||
scripts, deterministic archive timestamps
|
||||
- Top-level packaging Makefile for unified build across formats
|
||||
- Kubernetes sidecar deployment example with Nostr relay demo
|
||||
- Nostr release publishing in OpenWrt package workflow
|
||||
- SHA-256 hash output in CI build and OpenWrt workflows
|
||||
|
||||
#### Testing and CI
|
||||
|
||||
- Maelstrom chaos scenario with dynamic topology mutation and
|
||||
ephemeral node identities via connect/disconnect commands
|
||||
- Consolidated Docker test harness infrastructure
|
||||
|
||||
### Changed
|
||||
|
||||
- Discovery protocol: replace flooding with bloom-filter-guided tree
|
||||
routing. Includes originator retry (T=0/T=5s/T=10s), exponential
|
||||
backoff after timeouts and bloom misses, and transit-side per-target
|
||||
rate limiting. Removed 257-byte visited bloom filter from LookupRequest wire format. *This is a breaking change; nodes running versions prior to this release will not be compatible.*
|
||||
|
||||
### Fixed
|
||||
|
||||
- DNS responder returned NXDOMAIN for A queries on valid `.fips` names,
|
||||
causing resolvers to give up without trying AAAA. Now returns NOERROR
|
||||
with empty answers for non-AAAA queries on resolvable names.
|
||||
(#9, reported by [@alopatindev](https://github.com/alopatindev))
|
||||
- Stale end-to-end session left in session table after peer removal blocked session re-establishment on reconnect — `remove_active_peer` now cleans up `self.sessions` and `self.pending_tun_packets`. (#5, [@v0l](https://github.com/v0l))
|
||||
- `schedule_reconnect` reset exponential backoff to zero on each link-dead
|
||||
cycle instead of preserving accumulated retry count.
|
||||
(#5, [@v0l](https://github.com/v0l))
|
||||
- FMP/FSP rekey dual-initiation race on high-latency links (Tor): both
|
||||
sides' timers fired simultaneously, both msg1s crossed in flight, each
|
||||
side's responder path destroyed the initiator state. Fixed with
|
||||
deterministic tie-breaker (smaller NodeAddr wins as initiator).
|
||||
- Parent selection SRTT gate bypass: `evaluate_parent` used default cost
|
||||
1.0 for peers filtered out by `has_srtt()`, defeating the MMP eligibility
|
||||
gate. Now skips unmeasured candidates when any peer has cost data.
|
||||
- FSP rekey cutover race: initiator cut over before responder received msg3,
|
||||
causing AEAD failures. Fixed by deferring initiator cutover by 2 seconds.
|
||||
- MMP metric discontinuity after rekey: receiver state carried stale
|
||||
counters across rekey, inflating reorder counts and jitter. Fixed via
|
||||
`reset_for_rekey()`.
|
||||
- Auto-connect peers exhausted `max_retries` on initial connection failures
|
||||
and were permanently abandoned. Now retry indefinitely with exponential
|
||||
backoff capped at 300 seconds.
|
||||
- Control socket permissions: non-root users couldn't connect. Daemon now
|
||||
chowns socket and directory to `root:fips` group at bind time.
|
||||
- Post-rekey jitter spikes: old-session frames arriving via the drain window
|
||||
produced 2,000–7,000ms jitter spikes that corrupted the EWMA estimator.
|
||||
Added a 15-second grace period after rekey cutover that suppresses jitter
|
||||
updates until drain-window frames have flushed. (#10)
|
||||
- ICMPv6 Packet Too Big source was set to the local FIPS address, which
|
||||
Linux ignores (loopback PTB check). Now uses the original packet's
|
||||
destination so the kernel honors the PMTU update.
|
||||
(#16, [@v0l](https://github.com/v0l))
|
||||
- Reverse delivery ratio used lifetime cumulative counters instead of
|
||||
per-interval deltas, making ETX unresponsive to recent loss. (#14)
|
||||
- MMP delta guards used `prev_rr > 0` to detect first report, conflating
|
||||
it with a legitimate zero counter. Replaced with `has_prev_rr`. (#14)
|
||||
|
||||
## [0.1.0] - 2026-03-12
|
||||
|
||||
|
||||
15
Cargo.lock
generated
15
Cargo.lock
generated
@@ -783,7 +783,7 @@ checksum = "9844ddc3a6e533d62bba727eb6c28b5d360921d5175e9ff0f1e621a5c590a4d5"
|
||||
|
||||
[[package]]
|
||||
name = "fips"
|
||||
version = "0.1.0"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"bech32",
|
||||
"chacha20poly1305",
|
||||
@@ -807,6 +807,7 @@ dependencies = [
|
||||
"tempfile",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tokio-socks",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"tun",
|
||||
@@ -2422,6 +2423,18 @@ dependencies = [
|
||||
"syn 2.0.114",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-socks"
|
||||
version = "0.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0d4770b8024672c1101b3f6733eab95b18007dbe0847a8afe341fcf79e06043f"
|
||||
dependencies = [
|
||||
"either",
|
||||
"futures-util",
|
||||
"thiserror 1.0.69",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-util"
|
||||
version = "0.7.18"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "fips"
|
||||
version = "0.1.0"
|
||||
version = "0.2.1"
|
||||
edition = "2024"
|
||||
description = "A distributed, decentralized network routing protocol for mesh nodes connecting over arbitrary transports"
|
||||
license = "MIT"
|
||||
@@ -36,6 +36,7 @@ tokio = { version = "1", features = ["rt", "macros", "signal", "sync", "net", "t
|
||||
futures = "0.3"
|
||||
simple-dns = "0.11.2"
|
||||
socket2 = { version = "0.6.2", features = ["all"] }
|
||||
tokio-socks = "0.5"
|
||||
|
||||
[package.metadata.deb]
|
||||
maintainer = "Johnathan Corgan <jcorgan@corganlabs.com>"
|
||||
|
||||
45
README.md
45
README.md
@@ -1,16 +1,15 @@
|
||||
# FIPS: Free Internetworking Peering System
|
||||
|
||||

|
||||
[](LICENSE)
|
||||
[](https://www.rust-lang.org/)
|
||||
[-yellow.svg)](#status--roadmap)
|
||||
[](#status--roadmap)
|
||||
|
||||
A distributed, decentralized network routing protocol for mesh nodes
|
||||
connecting over arbitrary transports.
|
||||
|
||||
> **Status: Alpha (0.1.0)**
|
||||
>
|
||||
> FIPS is under active development. The protocol and APIs are not stable.
|
||||
> Expect breaking changes. See [Status & Roadmap](#status--roadmap) below.
|
||||
> FIPS is under active development. The protocol and APIs are not yet stable.
|
||||
> See [Status & Roadmap](#status--roadmap) below.
|
||||
|
||||
## Overview
|
||||
|
||||
@@ -37,22 +36,22 @@ endpoints.
|
||||
|
||||
## Features
|
||||
|
||||
- **Self-organizing mesh routing** — spanning tree coordinates and bloom
|
||||
filter candidate selection, no global routing tables
|
||||
- **Multi-transport** — UDP, TCP, and Ethernet today; designed for
|
||||
Bluetooth, serial, radio, and Tor
|
||||
- **Noise encryption** — hop-by-hop link encryption plus independent
|
||||
end-to-end session encryption, with periodic rekey for forward secrecy
|
||||
- **Self-organizing mesh routing** — spanning tree coordinates with bloom
|
||||
filter guided discovery, no global routing tables
|
||||
- **Multi-transport** — UDP, TCP, Ethernet, and Tor today; designed for
|
||||
Bluetooth, serial, and radio
|
||||
- **Noise encryption** — hop-by-hop link encryption (IK) plus independent
|
||||
end-to-end session encryption (XK), with periodic rekey for forward secrecy
|
||||
- **Nostr-native identity** — secp256k1 keypairs as node addresses, no
|
||||
registration or central authority
|
||||
- **IPv6 adaptation** — TUN interface maps npubs to fd00::/8 addresses for
|
||||
unmodified IP applications; static hostname mapping (`/etc/fips/hosts`)
|
||||
- **Metrics Measurement Protocol** — per-link RTT, loss, jitter, and goodput
|
||||
measurement
|
||||
measurement with mesh size estimation
|
||||
- **ECN congestion signaling** — hop-by-hop CE flag relay with RFC 3168 IPv6
|
||||
marking, transport kernel drop detection
|
||||
- **Operator visibility** — `fipsctl` CLI and `fipstop` TUI dashboard for
|
||||
runtime inspection of peers, links, sessions, tree state, and metrics
|
||||
runtime inspection and runtime peer management
|
||||
- **Zero configuration** — sensible defaults; a node can start with no config
|
||||
file, though peer addresses are needed to join a network
|
||||
|
||||
@@ -231,7 +230,7 @@ a layered protocol specification. Start with
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
```text
|
||||
src/ Rust source (library + fips/fipsctl/fipstop binaries)
|
||||
packaging/ Debian, systemd tarball, and shared packaging files
|
||||
docs/design/ Protocol design specifications
|
||||
@@ -240,29 +239,31 @@ testing/ Docker-based integration test harnesses
|
||||
|
||||
## Status & Roadmap
|
||||
|
||||
FIPS is at **v0.1.0 (alpha)**. The core protocol works end-to-end over
|
||||
UDP, TCP, and Ethernet but has not been tested beyond small meshes.
|
||||
FIPS is at **v0.2.1**. The core protocol works end-to-end over UDP, TCP,
|
||||
Ethernet, and Tor with a small live mesh of deployed nodes.
|
||||
|
||||
### What works today
|
||||
|
||||
- Spanning tree construction with greedy coordinate routing
|
||||
- Bloom filter discovery for finding nodes without global state
|
||||
- Bloom filter guided discovery (no flooding, single-path with retry)
|
||||
- Noise IK (link layer) and Noise XK (session layer) encryption
|
||||
- Periodic Noise rekey with forward secrecy (FMP + FSP)
|
||||
- Periodic Noise rekey with hitless cutover for forward secrecy (FMP + FSP)
|
||||
- Persistent node identity with key file management
|
||||
- IPv6 TUN adapter with DNS resolution of `.fips` names
|
||||
- Static hostname mapping (`/etc/fips/hosts`) with auto-reload
|
||||
- Per-link metrics (RTT, loss, jitter, goodput) and mesh size estimation
|
||||
- ECN congestion signaling (hop-by-hop CE relay, IPv6 CE marking, kernel drop detection)
|
||||
- UDP, TCP, and Ethernet transports
|
||||
- Runtime inspection via `fipsctl` and `fipstop`
|
||||
- UDP, TCP, Ethernet, and Tor transports (SOCKS5 outbound + directory-mode onion service inbound)
|
||||
- Runtime inspection and peer management via `fipsctl` and `fipstop`
|
||||
- Reproducible builds with toolchain pinning and SOURCE_DATE_EPOCH
|
||||
- Debian and systemd tarball packaging
|
||||
- Docker-based integration and chaos testing
|
||||
|
||||
### Near-term priorities
|
||||
|
||||
- Peer discovery via Nostr relays (bootstrap without static peer lists)
|
||||
- Additional transports (Bluetooth, Tor)
|
||||
- Improved routing resilience under churn
|
||||
- Native API for FIPS-aware applications (npub:port addressing)
|
||||
- Additional transports (Bluetooth)
|
||||
- Security audit of cryptographic protocols
|
||||
|
||||
### Longer-term
|
||||
|
||||
141
RELEASE-NOTES.md
Normal file
141
RELEASE-NOTES.md
Normal file
@@ -0,0 +1,141 @@
|
||||
# FIPS v0.2.1
|
||||
|
||||
**Released**: 2026-05-11
|
||||
|
||||
v0.2.1 is a maintenance release on the v0.2.x line. No new features
|
||||
and no wire-format changes; operators running v0.2.0 can upgrade in
|
||||
place. The release rolls up bug fixes and operational hardening for
|
||||
issues surfaced in v0.2.0 deployments, plus a bloom-filter fill-ratio
|
||||
validation that protects mesh-size estimates from saturated-filter
|
||||
inputs.
|
||||
|
||||
## At a glance
|
||||
|
||||
- 22 commits since v0.2.0, 5 committers plus 2 issue reporters.
|
||||
- All changes are backwards-compatible with v0.2.0 on the wire.
|
||||
- Bloom filter fill-ratio validation hardens the FilterAnnounce
|
||||
ingress path.
|
||||
- TreeAnnounce ancestry validation tightened to match the
|
||||
spanning-tree specification.
|
||||
- Signed-tarball + `.deb` artifact workflow added for tagged
|
||||
releases; AUR auto-publish on stable tags.
|
||||
|
||||
## Behavior changes worth flagging
|
||||
|
||||
- **Bloom filter fill-ratio validation** runs on every inbound
|
||||
`FilterAnnounce`. Filters whose derived false-positive rate exceeds
|
||||
`node.bloom.max_inbound_fpr` (new config field, default `0.05`) are
|
||||
rejected silently on the wire, logged at WARN, and counted in a
|
||||
new `bloom.fill_exceeded` counter. A rate-limited WARN also fires
|
||||
when the local outgoing filter exceeds the cap.
|
||||
`BloomFilter::estimated_count` now takes `max_fpr` and returns
|
||||
`Option<f64>`, returning `None` for saturated filters; this
|
||||
propagates through `compute_mesh_size` into `estimated_mesh_size`.
|
||||
- **TreeAnnounce ancestry validation** is now run before tree-state
|
||||
mutation, enforcing ancestry-self-match, root-single-entry,
|
||||
parent-second-entry, and root-is-minimum-NodeAddr. Non-conforming
|
||||
announces are rejected with a WARN. Mixed v0.2.0 / v0.2.1 meshes
|
||||
may produce WARN log lines on the v0.2.1 side until all peers
|
||||
upgrade; behavior is correct, log noise only.
|
||||
|
||||
## Notable bug fixes
|
||||
|
||||
- **Control socket path detection** in `fipsctl` and `fipstop` now
|
||||
checks for the `/run/fips/` directory instead of the socket file
|
||||
inside it. Users not yet in the `fips` group get a clear
|
||||
"Permission denied" error instead of a misleading "No such file"
|
||||
fallback to `$XDG_RUNTIME_DIR`
|
||||
([#30](https://github.com/jmcorgan/fips/issues/30), reported by
|
||||
[@Sebastix](https://github.com/Sebastix)).
|
||||
- **`fd00::/8` routing protected from Tailscale interception.** The
|
||||
daemon installs an IPv6 routing-policy rule
|
||||
(`ip -6 rule to fd00::/8 lookup main priority 5265`) at TUN setup,
|
||||
so Tailscale's table 52 default route can no longer divert mesh
|
||||
traffic.
|
||||
- **Bloom filter routing greedy-tree fallback.** `find_next_hop` no
|
||||
longer returns `NoRoute` when the bloom candidate set is non-empty
|
||||
but no candidate is strictly closer than the current node; it
|
||||
falls through to greedy tree routing instead. Previously, this
|
||||
caused dropped packets in topologies where the tree parent was
|
||||
closer but not a bloom candidate.
|
||||
- **Auto-connect peers reconnect after a graceful Disconnect.**
|
||||
Previously, a clean upstream shutdown left the auto-connect peer
|
||||
orphaned; only the link-dead, decrypt-fail, and peer-restart paths
|
||||
scheduled a reconnect
|
||||
([#60](https://github.com/jmcorgan/fips/issues/60), reported by
|
||||
[@SwapMarket](https://github.com/SwapMarket)).
|
||||
- **`fipsctl connect` rejects FIPS mesh addresses** (`fd00::/8`) for
|
||||
`udp`, `tcp`, and `ethernet` transports with a clear error message
|
||||
instead of echoing success while the daemon silently failed the
|
||||
bind with `EAFNOSUPPORT`
|
||||
([#61](https://github.com/jmcorgan/fips/issues/61), reported by
|
||||
[@SwapMarket](https://github.com/SwapMarket)).
|
||||
- **OpenWrt ipk** cross-compiles cleanly again after excluding the
|
||||
BLE feature that requires D-Bus, which is unavailable on OpenWrt
|
||||
targets.
|
||||
|
||||
## Packaging
|
||||
|
||||
- **Linux release artifact workflow** builds x86_64 and aarch64
|
||||
tarballs and `.deb` packages on `v*` tag push, with SHA-256
|
||||
checksums, and publishes them to the GitHub release page.
|
||||
- **AUR publish workflow** auto-publishes the `fips` PKGBUILD on
|
||||
stable `v*` tags.
|
||||
|
||||
## Upgrade notes
|
||||
|
||||
Operator-actionable items when moving from v0.2.0 to v0.2.1:
|
||||
|
||||
- **Bloom filter fill-ratio cap (default 0.05).** Inbound
|
||||
`FilterAnnounce` messages whose derived FPR exceeds the cap are
|
||||
rejected silently on the wire. Operators with unusually saturated
|
||||
filters in the field may want to confirm that the default applies
|
||||
cleanly to their deployment; check the new `bloom.fill_exceeded`
|
||||
counter if mesh-size estimates drift after upgrade.
|
||||
- **TreeAnnounce ancestry tightening.** Mixed v0.2.0 / v0.2.1 meshes
|
||||
may produce WARN log lines on the v0.2.1 side until all peers
|
||||
upgrade. Behavior is correct, log noise only.
|
||||
|
||||
## Getting v0.2.1
|
||||
|
||||
- **Linux x86_64 / aarch64**: `.deb` and tarball at the
|
||||
[v0.2.1 release page](https://github.com/jmcorgan/fips/releases/tag/v0.2.1).
|
||||
- **Arch Linux**: `fips` from the AUR.
|
||||
- **OpenWrt**: `.ipk` at the v0.2.1 release page.
|
||||
- **From source**: `cargo build --release` from a checkout of the
|
||||
v0.2.1 tag.
|
||||
|
||||
The full per-commit changelog lives in
|
||||
[`CHANGELOG.md`](../../CHANGELOG.md). Issues and discussion at
|
||||
[github.com/jmcorgan/fips](https://github.com/jmcorgan/fips).
|
||||
|
||||
## Contributors
|
||||
|
||||
Thanks to everyone who contributed code or bug reports to this
|
||||
release.
|
||||
|
||||
**Code and packaging**:
|
||||
|
||||
- [@jcorgan](https://github.com/jmcorgan): release shepherd, bloom
|
||||
fill-ratio validation, auto-connect reconnect fix, `fipsctl`
|
||||
mesh-address rejection, control-socket path detection,
|
||||
Tailscale-vs-`fd00::/8` routing policy, bloom routing greedy
|
||||
fallback, rustfmt baseline.
|
||||
- [@Origami74](https://github.com/Origami74): OpenWrt ipk
|
||||
BLE-feature build fix.
|
||||
- [@jodobear](https://github.com/jodobear): Linux release-artifact
|
||||
workflow and target-aware build scripts.
|
||||
- [@dskvr](https://github.com/dskvr): AUR publish workflow.
|
||||
- [@SatsAndSports](https://github.com/SatsAndSports): TreeAnnounce
|
||||
semantic validation.
|
||||
|
||||
**Issue reports that drove fixes in this release**:
|
||||
|
||||
- [@Sebastix](https://github.com/Sebastix): `fipsctl` / `fipstop`
|
||||
control-socket path detection
|
||||
([#30](https://github.com/jmcorgan/fips/issues/30)).
|
||||
- [@SwapMarket](https://github.com/SwapMarket): auto-connect
|
||||
reconnect after graceful disconnect
|
||||
([#60](https://github.com/jmcorgan/fips/issues/60)) and
|
||||
`fipsctl` mesh-address rejection
|
||||
([#61](https://github.com/jmcorgan/fips/issues/61)).
|
||||
@@ -1,9 +1,9 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 580 478" font-family="monospace" font-size="13">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 580 430" font-family="monospace" font-size="13">
|
||||
<!-- Background -->
|
||||
<rect width="580" height="478" fill="#1a1a2e" rx="4"/>
|
||||
<rect width="580" height="430" fill="#1a1a2e" rx="4"/>
|
||||
|
||||
<!-- Title -->
|
||||
<text x="310" y="26" fill="#e0e0e0" text-anchor="middle" font-size="14" font-weight="bold">LookupRequest (0x30) — 303 + 16n bytes</text>
|
||||
<text x="310" y="26" fill="#e0e0e0" text-anchor="middle" font-size="14" font-weight="bold">LookupRequest (0x30) — 46 + 16n bytes</text>
|
||||
|
||||
<!-- Row 0 (0–3): msg_type + request_id starts -->
|
||||
<text x="50" y="62" fill="#666" font-size="10" text-anchor="end">0–3</text>
|
||||
@@ -58,15 +58,6 @@
|
||||
<rect x="55" y="348" width="520" height="40" fill="#1f1f3a" stroke="#4ad9d9" stroke-width="1" stroke-dasharray="4,3" rx="3"/>
|
||||
<text x="315" y="372" fill="#8af8f8" text-anchor="middle" font-size="11">origin_coords × n (16 bytes each — NodeAddr only)</text>
|
||||
|
||||
<!-- Row 7: visited bloom -->
|
||||
<text x="50" y="414" fill="#666" font-size="10" text-anchor="end">+1+256</text>
|
||||
|
||||
<rect x="55" y="394" width="130" height="40" fill="#3d3d2d" stroke="#a0a04a" stroke-width="1.5" rx="3"/>
|
||||
<text x="120" y="418" fill="#d8d88a" text-anchor="middle" font-size="10">hash_cnt</text>
|
||||
|
||||
<rect x="185" y="394" width="390" height="40" fill="#1f1f3a" stroke="#a0a04a" stroke-width="1" stroke-dasharray="4,3" rx="3"/>
|
||||
<text x="380" y="418" fill="#d8d88a" text-anchor="middle" font-size="11">visited_bits (256 bytes)</text>
|
||||
|
||||
<!-- Total -->
|
||||
<text x="310" y="462" fill="#777" font-size="10" text-anchor="middle">total: 303 + (n × 16) bytes, where n = origin depth + 1</text>
|
||||
<text x="310" y="414" fill="#777" font-size="10" text-anchor="middle">total: 46 + (n × 16) bytes, where n = origin depth + 1</text>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 4.3 KiB After Width: | Height: | Size: 3.8 KiB |
@@ -56,9 +56,11 @@ peers: # Static peer list
|
||||
| `node.control.enabled` | bool | `true` | Enable the Unix domain control socket |
|
||||
| `node.control.socket_path` | string | *(auto)* | Socket file path. Default: `$XDG_RUNTIME_DIR/fips/control.sock`, then `/run/fips/control.sock` (if root), then `/tmp/fips-control.sock` |
|
||||
|
||||
The control socket provides read-only access to node state via the
|
||||
`fipsctl` command-line tool. See the project
|
||||
[README](../../README.md#inspect) for the command list.
|
||||
The control socket provides access to node state and runtime management
|
||||
via the `fipsctl` command-line tool. In addition to read-only status
|
||||
queries, `fipsctl connect` and `fipsctl disconnect` enable runtime peer
|
||||
management. See the project [README](../../README.md#inspect) for the
|
||||
command list.
|
||||
|
||||
All tunable protocol parameters live under `node.*`, organized as sysctl-style
|
||||
dotted paths. The top-level sections (`tun`, `dns`, `transports`, `peers`)
|
||||
@@ -144,13 +146,18 @@ Controls caching of tree coordinates and identity mappings.
|
||||
|
||||
### Discovery Protocol (`node.discovery.*`)
|
||||
|
||||
Controls flood-based node discovery (LookupRequest/LookupResponse).
|
||||
Controls bloom-guided node discovery (LookupRequest/LookupResponse).
|
||||
|
||||
| Parameter | Type | Default | Description |
|
||||
|-----------|------|---------|-------------|
|
||||
| `node.discovery.ttl` | u8 | `64` | Hop limit for LookupRequest flood |
|
||||
| `node.discovery.ttl` | u8 | `64` | Hop limit for LookupRequest forwarding |
|
||||
| `node.discovery.timeout_secs` | u64 | `10` | Lookup completion timeout |
|
||||
| `node.discovery.recent_expiry_secs` | u64 | `10` | Dedup cache expiry for recent request IDs |
|
||||
| `node.discovery.retry_interval_secs` | u64 | `5` | Retry interval within the timeout window; after this interval without a response, resend the lookup |
|
||||
| `node.discovery.max_attempts` | u8 | `2` | Max attempts per lookup (1 = no retry, 2 = one retry) |
|
||||
| `node.discovery.backoff_base_secs` | u64 | `30` | Base for exponential backoff after lookup failure; doubles per consecutive failure |
|
||||
| `node.discovery.backoff_max_secs` | u64 | `300` | Cap on exponential backoff (5 minutes) |
|
||||
| `node.discovery.forward_min_interval_secs` | u64 | `2` | Transit-side rate limiting: minimum interval between forwarded lookups for the same target |
|
||||
|
||||
### Spanning Tree (`node.tree.*`)
|
||||
|
||||
@@ -359,7 +366,6 @@ overhead.
|
||||
| `transports.tcp.recv_buf_size` | usize | `2097152` | Socket receive buffer size in bytes (2 MB) |
|
||||
| `transports.tcp.send_buf_size` | usize | `2097152` | Socket send buffer size in bytes (2 MB) |
|
||||
| `transports.tcp.max_inbound_connections` | usize | `256` | Maximum simultaneous inbound connections |
|
||||
| `transports.tcp.socks5_proxy` | string | *(none)* | SOCKS5 proxy for outbound connections (implementation deferred) |
|
||||
|
||||
**Named instances.** Like other transports, multiple TCP instances can
|
||||
be configured with named sub-keys:
|
||||
@@ -369,9 +375,114 @@ transports:
|
||||
tcp:
|
||||
public:
|
||||
bind_addr: "0.0.0.0:443"
|
||||
tor:
|
||||
socks5_proxy: "127.0.0.1:9050"
|
||||
connect_timeout_ms: 30000
|
||||
internal:
|
||||
bind_addr: "10.0.0.1:8443"
|
||||
max_inbound_connections: 64
|
||||
```
|
||||
|
||||
### Tor (`transports.tor.*`)
|
||||
|
||||
Tor transport routes FIPS traffic through the Tor network for anonymity.
|
||||
Requires an external Tor daemon providing a SOCKS5 proxy. Three modes:
|
||||
`socks5` for outbound-only, `control_port` for outbound + monitoring,
|
||||
`directory` for outbound + inbound via Tor-managed onion service.
|
||||
|
||||
| Parameter | Type | Default | Description |
|
||||
|-----------|------|---------|-------------|
|
||||
| `transports.tor.mode` | string | `"socks5"` | Tor access mode: `socks5` (outbound only), `control_port` (outbound + monitoring), or `directory` (outbound + inbound onion service) |
|
||||
| `transports.tor.socks5_addr` | string | `"127.0.0.1:9050"` | SOCKS5 proxy address (host:port) |
|
||||
| `transports.tor.connect_timeout_ms` | u64 | `120000` | Connect timeout in milliseconds. Tor circuits take 10–60s. |
|
||||
| `transports.tor.mtu` | u16 | `1400` | Default MTU |
|
||||
| `transports.tor.control_addr` | string | `"/run/tor/control"` | Tor control port address: Unix socket path or host:port. Used in `control_port` mode; optional in `directory` mode for monitoring. |
|
||||
| `transports.tor.control_auth` | string | `"cookie"` | Control port authentication: `"cookie"`, `"cookie:/path/to/cookie"`, or `"password:<secret>"`. |
|
||||
| `transports.tor.cookie_path` | string | `"/var/run/tor/control.authcookie"` | Path to Tor control cookie file. Used when `control_auth` is `"cookie"`. |
|
||||
| `transports.tor.max_inbound_connections` | usize | `64` | Maximum inbound connections via onion service. |
|
||||
| `transports.tor.directory_service.hostname_file` | string | `"/var/lib/tor/fips_onion_service/hostname"` | Path to Tor-managed hostname file containing the `.onion` address. |
|
||||
| `transports.tor.directory_service.bind_addr` | string | `"127.0.0.1:8443"` | Local bind address for the listener that Tor forwards inbound connections to. Must match `HiddenServicePort` target in `torrc`. |
|
||||
|
||||
**Named instances.** Like other transports, multiple Tor instances can
|
||||
be configured with named sub-keys for different SOCKS5 proxy endpoints.
|
||||
|
||||
**Directory mode** (recommended for production). Tor manages the onion
|
||||
service via `HiddenServiceDir` in `torrc`. FIPS reads the `.onion`
|
||||
address from the hostname file and binds a local TCP listener. This
|
||||
enables Tor's `Sandbox 1` (seccomp-bpf). If `control_addr` is also
|
||||
set, the transport connects to the control port for daemon monitoring
|
||||
(non-fatal on failure).
|
||||
|
||||
**Control port mode.** Connects to the Tor daemon's control port for
|
||||
monitoring only (bootstrap status, circuit health, traffic stats).
|
||||
No inbound connections. Both `control_addr` and `control_auth` are
|
||||
required.
|
||||
|
||||
### UDP + Tor Bridge Example
|
||||
|
||||
A node bridging clearnet (UDP) and anonymous (Tor) portions of the mesh:
|
||||
|
||||
```yaml
|
||||
node:
|
||||
identity:
|
||||
persistent: true
|
||||
|
||||
tun:
|
||||
enabled: true
|
||||
|
||||
transports:
|
||||
udp:
|
||||
bind_addr: "0.0.0.0:2121"
|
||||
mtu: 1472
|
||||
tor:
|
||||
socks5_addr: "127.0.0.1:9050"
|
||||
|
||||
peers:
|
||||
- npub: "npub1abc..."
|
||||
alias: "clearnet-peer"
|
||||
addresses:
|
||||
- transport: udp
|
||||
addr: "203.0.113.5:2121"
|
||||
- npub: "npub1def..."
|
||||
alias: "anonymous-peer"
|
||||
addresses:
|
||||
- transport: tor
|
||||
addr: "abc123...xyz.onion:2121"
|
||||
```
|
||||
|
||||
### Tor Directory Mode Example
|
||||
|
||||
A node accepting inbound connections via Tor-managed onion service
|
||||
(recommended for production — enables Sandbox 1):
|
||||
|
||||
```yaml
|
||||
node:
|
||||
identity:
|
||||
persistent: true
|
||||
|
||||
tun:
|
||||
enabled: true
|
||||
|
||||
transports:
|
||||
tor:
|
||||
mode: "directory"
|
||||
socks5_addr: "127.0.0.1:9050"
|
||||
control_addr: "/run/tor/control" # optional, for monitoring
|
||||
control_auth: "cookie"
|
||||
directory_service:
|
||||
hostname_file: "/var/lib/tor/fips/hostname"
|
||||
bind_addr: "127.0.0.1:8444"
|
||||
|
||||
peers:
|
||||
- npub: "npub1abc..."
|
||||
alias: "tor-peer"
|
||||
addresses:
|
||||
- transport: tor
|
||||
addr: "abcdef...xyz.onion:8443"
|
||||
```
|
||||
|
||||
Requires a corresponding `torrc`:
|
||||
|
||||
```text
|
||||
HiddenServiceDir /var/lib/tor/fips
|
||||
HiddenServicePort 8443 127.0.0.1:8444
|
||||
```
|
||||
|
||||
## Peers (`peers[]`)
|
||||
@@ -382,8 +493,8 @@ Static peer list. Each entry defines a peer to connect to.
|
||||
|-----------|------|---------|-------------|
|
||||
| `peers[].npub` | string | *(required)* | Peer's Nostr public key (npub-encoded) |
|
||||
| `peers[].alias` | string | *(none)* | Human-readable name for logging |
|
||||
| `peers[].addresses[].transport` | string | *(required)* | Transport type: `udp`, `tcp`, or `ethernet` |
|
||||
| `peers[].addresses[].addr` | string | *(required)* | Transport address. UDP/TCP: `"ip:port"`. Ethernet: `"interface/mac"` (e.g., `"eth0/aa:bb:cc:dd:ee:ff"`) |
|
||||
| `peers[].addresses[].transport` | string | *(required)* | Transport type: `udp`, `tcp`, `ethernet`, or `tor` |
|
||||
| `peers[].addresses[].addr` | string | *(required)* | Transport address. UDP/TCP: `"host:port"` (IP or DNS hostname). Ethernet: `"interface/mac"` (e.g., `"eth0/aa:bb:cc:dd:ee:ff"`). Tor: `".onion:port"` or `"host:port"` |
|
||||
| `peers[].addresses[].priority` | u8 | `100` | Address priority (lower = preferred) |
|
||||
| `peers[].connect_policy` | string | `"auto_connect"` | Connection policy: `auto_connect`, `on_demand`, or `manual` |
|
||||
| `peers[].auto_reconnect` | bool | `true` | Automatically reconnect after MMP link-dead removal (exponential backoff, unlimited retries) |
|
||||
@@ -512,6 +623,11 @@ node:
|
||||
ttl: 64
|
||||
timeout_secs: 10
|
||||
recent_expiry_secs: 10
|
||||
retry_interval_secs: 5
|
||||
max_attempts: 2
|
||||
backoff_base_secs: 30
|
||||
backoff_max_secs: 300
|
||||
forward_min_interval_secs: 2
|
||||
tree:
|
||||
announce_min_interval_ms: 500
|
||||
parent_hysteresis: 0.2 # cost improvement fraction for parent switch
|
||||
@@ -590,7 +706,20 @@ transports:
|
||||
# recv_buf_size: 2097152 # 2 MB
|
||||
# send_buf_size: 2097152 # 2 MB
|
||||
# max_inbound_connections: 256 # resource protection limit
|
||||
# socks5_proxy: null # SOCKS5 for outbound (deferred)
|
||||
# tor: # uncomment to enable Tor transport
|
||||
# mode: "socks5" # "socks5", "control_port", or "directory"
|
||||
# socks5_addr: "127.0.0.1:9050" # SOCKS5 proxy address
|
||||
# connect_timeout_ms: 120000 # connect timeout (120s for Tor circuits)
|
||||
# mtu: 1400 # default MTU
|
||||
# # monitoring (control_port mode, or optional in directory mode):
|
||||
# # control_addr: "/run/tor/control" # Unix socket or host:port
|
||||
# # control_auth: "cookie" # "cookie" or "password:<secret>"
|
||||
# # cookie_path: "/var/run/tor/control.authcookie"
|
||||
# # directory mode (inbound via Tor-managed onion service):
|
||||
# # directory_service:
|
||||
# # hostname_file: "/var/lib/tor/fips/hostname"
|
||||
# # bind_addr: "127.0.0.1:8444"
|
||||
# # max_inbound_connections: 64
|
||||
|
||||
peers: # static peer list
|
||||
# - npub: "npub1..."
|
||||
|
||||
@@ -216,7 +216,7 @@ address prepends `fd` to the first 15 bytes of the node_addr, providing a
|
||||
ULA overlay address for unmodified IP applications via the TUN interface.
|
||||
|
||||
Below the FIPS identity layer, each transport uses its own native addressing
|
||||
— IP:port tuples, MAC addresses, .onion identifiers. These **link
|
||||
— IP:port or hostname:port addresses, MAC addresses, .onion identifiers. These **link
|
||||
addresses** are opaque to everything above FMP and discarded once link
|
||||
authentication completes.
|
||||
|
||||
@@ -524,9 +524,9 @@ forwarding, a publicly addressed peer, or relay through other mesh nodes.
|
||||
UDP hole punching and relay-assisted NAT traversal are potential future
|
||||
mechanisms but are not part of the current design.
|
||||
|
||||
> **Implementation status**: UDP/IP is implemented. Ethernet and Bluetooth
|
||||
> transports are under active design and development. All others are future
|
||||
> directions.
|
||||
> **Implementation status**: UDP/IP, TCP/IP, Ethernet, and Tor
|
||||
> (SOCKS5 outbound + directory-mode inbound via onion service)
|
||||
> transports are implemented. All others are future directions.
|
||||
|
||||
See [fips-transport-layer.md](fips-transport-layer.md) for the full transport
|
||||
layer specification.
|
||||
|
||||
@@ -15,7 +15,7 @@ updates, coordinate discovery, and forwarded session datagrams — all encrypted
|
||||
per-hop.
|
||||
|
||||
FMP is the boundary between opaque transport addresses and identified peers.
|
||||
Below FMP, everything is transport-specific addresses (IP:port, MAC, .onion).
|
||||
Below FMP, everything is transport-specific addresses (host:port, MAC, .onion).
|
||||
Above FMP, everything is peers identified by public keys and routable by
|
||||
node_addr. The transport layer never sees FIPS-level structure; FSP never sees
|
||||
transport addresses or routing details.
|
||||
@@ -280,7 +280,7 @@ authenticated peer regardless of what transport address the packet arrived
|
||||
from. FMP updates the peer's current address to the packet's source address,
|
||||
and subsequent outbound packets use the updated address.
|
||||
|
||||
This allows peers to change transport addresses (e.g., IP:port for UDP)
|
||||
This allows peers to change transport addresses (e.g., host:port for UDP)
|
||||
without session interruption. The mechanism is:
|
||||
|
||||
1. Packet arrives from a different address than expected
|
||||
|
||||
@@ -330,7 +330,9 @@ fresh SessionSetup re-warms transit caches (still within their 300s TTL).
|
||||
## Discovery Protocol
|
||||
|
||||
Discovery resolves a destination's tree coordinates so that multi-hop routing
|
||||
can proceed.
|
||||
can proceed. Requests are forwarded using **bloom-guided tree routing** —
|
||||
only to tree peers (parent + children) whose bloom filter contains the
|
||||
target — producing single-path forwarding through the spanning tree.
|
||||
|
||||
### When Discovery Is Needed
|
||||
|
||||
@@ -347,17 +349,70 @@ The source creates a LookupRequest containing:
|
||||
- **origin**: The requester's node_addr
|
||||
- **origin_coords**: The requester's current tree coordinates (so the
|
||||
response can route back)
|
||||
- **TTL**: Bounds the flood radius
|
||||
- **TTL**: Bounds the forwarding radius
|
||||
|
||||
The request floods through the mesh: each node decrements TTL, adds itself
|
||||
to a visited filter (preventing loops on a single path), and forwards to all
|
||||
peers not in the visited filter. Bloom filters may help direct the flood
|
||||
toward likely candidates.
|
||||
### Bloom-Guided Tree Routing
|
||||
|
||||
**Deduplication**: Nodes maintain a short-lived request_id dedup cache
|
||||
(default 10s window) to drop convergent duplicates (the same request
|
||||
arriving via different paths). This is a protocol requirement, not an
|
||||
optimization.
|
||||
Rather than flooding to all peers, the request is forwarded only to **tree
|
||||
peers** (parent + children) whose bloom filter contains the target. Because
|
||||
bloom filters propagate along tree edges with split-horizon exclusion,
|
||||
typically only one tree peer matches — producing a single directed path
|
||||
through the spanning tree toward the target's subtree. This reduces
|
||||
discovery traffic by roughly 90% compared to flooding.
|
||||
|
||||
If no tree peer's bloom filter matches the target, the request falls back
|
||||
to **non-tree peers** whose bloom filter contains the target. This recovers
|
||||
from dead ends caused by stale bloom filters, tree restructuring, or transit
|
||||
node failures. If no peer at all has a bloom match, the request is dropped
|
||||
at that node.
|
||||
|
||||
**Loop prevention**: The spanning tree is inherently loop-free, so tree-only
|
||||
forwarding cannot loop. The `request_id` dedup cache (default 10s window)
|
||||
provides defense-in-depth, catching edge cases during tree restructuring
|
||||
where a request might arrive via both tree and fallback paths.
|
||||
|
||||
### Retry Logic
|
||||
|
||||
Single-path forwarding is more fragile than flooding — if any transit node
|
||||
on the path has a stale bloom filter or loses a link, the request fails.
|
||||
To compensate, the originator retries:
|
||||
|
||||
- **T=0**: Initial lookup sent
|
||||
- **T=5s**: Retry if no response (configurable via `retry_interval_secs`)
|
||||
- **T=10s**: Timeout, fail (configurable via `timeout_secs`)
|
||||
|
||||
The default `max_attempts` is 2 (initial + one retry). Each retry generates
|
||||
a fresh `request_id` and re-evaluates bloom filter matches, so it can take
|
||||
a different path if the tree has restructured.
|
||||
|
||||
### Originator Backoff
|
||||
|
||||
After a lookup times out or no peer's bloom filter contains the target, the
|
||||
originator enters **exponential backoff** before re-attempting discovery for
|
||||
the same target:
|
||||
|
||||
- **Base delay**: 30s (configurable via `backoff_base_secs`)
|
||||
- **Multiplier**: 2x per consecutive failure
|
||||
- **Cap**: 300s (configurable via `backoff_max_secs`)
|
||||
|
||||
Backoff is **reset on topology changes** that might make previously
|
||||
unreachable targets reachable: parent switch, new peer connection, first
|
||||
RTT measurement from MMP, or peer reconnection.
|
||||
|
||||
### Bloom Filter Pre-Check
|
||||
|
||||
Before initiating a lookup, the originator checks whether *any* peer's
|
||||
bloom filter contains the target. If no peer advertises reachability, the
|
||||
lookup is skipped entirely and recorded as a failure for backoff purposes.
|
||||
This avoids wasting network resources when the target is not in the mesh.
|
||||
|
||||
### Transit-Side Rate Limiting
|
||||
|
||||
Transit nodes enforce a per-target minimum interval (default 2s, configurable
|
||||
via `forward_min_interval_secs`) for forwarded lookups. This is
|
||||
defense-in-depth against misbehaving nodes that generate fresh `request_id`s
|
||||
at high rate to bypass dedup. The rate limiter collapses rapid-fire lookups
|
||||
for the same target regardless of `request_id`.
|
||||
|
||||
### LookupResponse
|
||||
|
||||
@@ -373,14 +428,21 @@ direct peer), a LookupResponse is created containing:
|
||||
authenticates that the response is genuine and the target holds the
|
||||
claimed tree position
|
||||
|
||||
The response routes back to the requester using reverse-path routing as the
|
||||
primary mechanism: each transit node looks up the request_id in its
|
||||
The response routes back to the requester using **reverse-path routing** as
|
||||
the primary mechanism: each transit node looks up the `request_id` in its
|
||||
`recent_requests` table to find the peer that forwarded the original request,
|
||||
and sends the response back through that peer. This ensures the response
|
||||
follows the same path as the request. Greedy tree routing toward the
|
||||
origin_coords is used only as a fallback if the reverse-path entry has
|
||||
`origin_coords` is used only as a fallback if the reverse-path entry has
|
||||
expired.
|
||||
|
||||
**Response-forwarded flag**: Each `recent_requests` entry tracks whether a
|
||||
response has already been forwarded for that `request_id`. If a second
|
||||
response arrives (e.g., from convergent request paths that reached the
|
||||
target via different routes), the transit node drops it. This prevents
|
||||
response routing loops where multiple responses for the same request
|
||||
circulate through the network.
|
||||
|
||||
**Proof verification**: The source verifies the Schnorr proof upon receipt,
|
||||
confirming that the target actually signed the response. The proof covers
|
||||
`(request_id || target || target_coords)` — coordinates are included because
|
||||
@@ -390,12 +452,14 @@ annotation modified at each hop.
|
||||
|
||||
### Discovery Outcome
|
||||
|
||||
On receiving a LookupResponse, the source caches the target's coordinates.
|
||||
Subsequent routing to that destination can proceed via the normal
|
||||
`find_next_hop()` priority chain.
|
||||
On receiving a verified LookupResponse, the source caches the target's
|
||||
coordinates and clears any backoff state for that target. Subsequent routing
|
||||
to that destination can proceed via the normal `find_next_hop()` priority
|
||||
chain.
|
||||
|
||||
If discovery times out (no response), queued packets receive ICMPv6
|
||||
Destination Unreachable.
|
||||
If discovery times out (no response after all retry attempts), queued
|
||||
packets receive ICMPv6 Destination Unreachable and the target enters
|
||||
backoff.
|
||||
|
||||
## SessionSetup Self-Bootstrapping
|
||||
|
||||
@@ -467,7 +531,7 @@ coordinates for the destination. It cannot make a forwarding decision.
|
||||
2. Reset CP warmup counter — subsequent data packets piggyback coordinates
|
||||
when possible, or trigger additional CoordsWarmup messages when
|
||||
piggybacking would exceed the transport MTU
|
||||
3. Initiate discovery (LookupRequest flood) for the destination
|
||||
3. Initiate discovery (bloom-guided LookupRequest) for the destination
|
||||
4. When discovery completes, warmup counter resets again (covers timing gap)
|
||||
|
||||
The crypto session remains active throughout — only routing state is
|
||||
@@ -553,8 +617,9 @@ sequence:
|
||||
identity cache with NodeAddr + PublicKey
|
||||
2. **Session initiation attempt**: Fails because no coordinates are cached
|
||||
for the destination
|
||||
3. **Discovery**: LookupRequest floods through the mesh; LookupResponse
|
||||
returns the destination's coordinates
|
||||
3. **Discovery**: LookupRequest routes through the spanning tree via
|
||||
bloom-guided forwarding; LookupResponse returns the destination's
|
||||
coordinates
|
||||
4. **Session establishment**: SessionSetup carries coordinates, warming
|
||||
transit caches along the path
|
||||
5. **Warmup**: First N data packets include CP flag, reinforcing transit
|
||||
@@ -643,7 +708,7 @@ routing decisions but retains its own end-to-end encryption and identity.
|
||||
| ------- | ------------ | ---- | ---------- |
|
||||
| TreeAnnounce | Variable (depth-dependent) | Topology changes | No (peer-to-peer) |
|
||||
| FilterAnnounce | ~1 KB | Topology changes | No (peer-to-peer) |
|
||||
| LookupRequest | ~300 bytes | First contact, recovery | Yes (flood) |
|
||||
| LookupRequest | ~300 bytes | First contact, recovery | Yes (bloom-guided tree) |
|
||||
| LookupResponse | ~400 bytes | Response to discovery | Yes (greedy routed) |
|
||||
| SessionDatagram + SessionSetup | ~232–402 bytes | Session establishment | Yes (routed) |
|
||||
| SessionDatagram + SessionAck | ~170 bytes | Session confirmation | Yes (routed) |
|
||||
@@ -696,10 +761,14 @@ recovery).
|
||||
| Flap dampening (hysteresis + hold-down) | **Implemented** |
|
||||
| Link liveness (dead timeout) | **Implemented** |
|
||||
| Discovery request deduplication | **Implemented** |
|
||||
| Discovery bloom-guided tree routing | **Implemented** |
|
||||
| Discovery retry logic | **Implemented** |
|
||||
| Discovery originator backoff | **Implemented** |
|
||||
| Discovery transit-side rate limiting | **Implemented** |
|
||||
| Discovery response-forwarded dedup | **Implemented** |
|
||||
| Leaf-only operation | Under development |
|
||||
| Link cost in parent selection (ETX) | **Implemented** |
|
||||
| Link cost in candidate ranking | **Implemented** |
|
||||
| Discovery path accumulation | Future direction |
|
||||
|
||||
## References
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ address, deliver the datagram to that address, and push inbound datagrams up
|
||||
to the FIPS Mesh Protocol (FMP) above.
|
||||
|
||||
The transport layer deals exclusively in **transport addresses** — IP:port
|
||||
tuples, MAC addresses, .onion identifiers, radio device addresses. These are
|
||||
or hostname:port addresses, MAC addresses, .onion identifiers, radio device addresses. These are
|
||||
opaque to every layer above FMP. The mapping from transport address to FIPS
|
||||
identity happens at the link layer after the Noise IK link handshake completes.
|
||||
The word "peer" belongs to the link layer and above; the transport layer
|
||||
@@ -69,10 +69,30 @@ forwarding and LookupResponse transit annotation.
|
||||
|
||||
For connection-oriented transports, manage the underlying connection: TCP
|
||||
handshake, Tor circuit establishment, Bluetooth pairing. FMP cannot begin
|
||||
the Noise IK link handshake until the transport-layer connection is established.
|
||||
the Noise IK link handshake until the transport-layer connection is
|
||||
established.
|
||||
|
||||
Connectionless transports (UDP, raw Ethernet) skip this — datagrams can flow
|
||||
immediately to any reachable address.
|
||||
Connection-oriented transports expose a non-blocking connect interface.
|
||||
`connect(addr)` initiates the connection in a background task and returns
|
||||
immediately. `connection_state(addr)` reports the current status:
|
||||
|
||||
```text
|
||||
ConnectionState {
|
||||
None No connection attempt in progress
|
||||
Connecting Background task running
|
||||
Connected Ready for send()
|
||||
Failed(msg) Error message from failed attempt
|
||||
}
|
||||
```
|
||||
|
||||
Connectionless transports (UDP, raw Ethernet) return `Connected`
|
||||
immediately — no async work needed.
|
||||
|
||||
At the node level, `PendingConnect` entries track links waiting for
|
||||
transport connection. `poll_pending_connects()` runs each tick, checks
|
||||
`connection_state()`, and calls `start_handshake()` on success or
|
||||
`schedule_retry()` on failure. This decouples transport-layer connection
|
||||
(which may take seconds for Tor circuits) from the FMP event loop.
|
||||
|
||||
### Discovery (Optional)
|
||||
|
||||
@@ -95,8 +115,8 @@ for internet connectivity:
|
||||
|
||||
| Transport | Addressing | MTU | Reliability | Notes |
|
||||
| --------- | ---------- | --- | ----------- | ----- |
|
||||
| UDP/IP | IP:port | 1280–1472 | Unreliable | Primary internet transport |
|
||||
| TCP/IP | IP:port | Stream | Reliable | Requires length-prefix framing |
|
||||
| UDP/IP | host:port | 1280–1472 | Unreliable | Primary internet transport |
|
||||
| TCP/IP | host:port | Stream | Reliable | Requires length-prefix framing |
|
||||
| WebSocket | URL | Stream | Reliable | Browser-compatible |
|
||||
| Tor | .onion | Stream | Reliable | High latency, strong anonymity |
|
||||
|
||||
@@ -326,8 +346,7 @@ Ethernet transport started name=eth0 interface=eth0 mac=aa:bb:cc:dd:ee:ff mtu=14
|
||||
## TCP/IP: Firewall Traversal Transport
|
||||
|
||||
For networks where UDP is blocked but TCP port 443 is open, the TCP
|
||||
transport provides an alternative path. It also serves as the foundation
|
||||
for the future Tor transport.
|
||||
transport provides an alternative path.
|
||||
|
||||
FIPS protocols (FMP, FSP, MMP) are all unreliable datagrams. Running them
|
||||
over TCP introduces head-of-line blocking, which adds latency jitter. MMP
|
||||
@@ -338,16 +357,18 @@ penalizes TCP links (higher SRTT leads to higher link cost). ETX will be
|
||||
### Architecture
|
||||
|
||||
Unlike UDP (one socket serves all peers), TCP requires one `TcpStream` per
|
||||
peer. The transport maintains a connection pool (`HashMap<TransportAddr,
|
||||
TcpConnection>`) plus an optional `TcpListener` for inbound connections.
|
||||
peer. The transport maintains two pools: a `ConnectingPool` for background
|
||||
connection attempts in progress, and an established connection pool
|
||||
(`HashMap<TransportAddr, TcpConnection>`) for active connections, plus an
|
||||
optional `TcpListener` for inbound connections.
|
||||
|
||||
| Property | Value |
|
||||
| -------- | ----- |
|
||||
| Addressing | IP:port (same as UDP) |
|
||||
| Addressing | host:port — IP address or DNS hostname |
|
||||
| Default MTU | 1400 bytes |
|
||||
| Per-link MTU | Derived from `TCP_MAXSEG` socket option |
|
||||
| Framing | FMP header-based (zero overhead) |
|
||||
| Connection model | Connect-on-send, optional listener |
|
||||
| Connection model | Non-blocking connect, connect-on-send fallback, optional listener |
|
||||
| Platform | Cross-platform (no `#[cfg]` gates) |
|
||||
|
||||
### FMP Header-Based Framing
|
||||
@@ -364,29 +385,36 @@ packet boundaries:
|
||||
|
||||
This provides zero framing overhead and built-in phase validation. The
|
||||
stream reader is implemented in a separate module (`stream.rs`) for reuse
|
||||
by the future Tor transport.
|
||||
by the Tor transport.
|
||||
|
||||
### Connect-on-Send
|
||||
### Connection Establishment
|
||||
|
||||
When `send(addr, data)` is called with no existing connection:
|
||||
TCP connections use a non-blocking connect model. When FMP needs to reach
|
||||
a configured peer address, the node calls `connect(addr)` on the transport,
|
||||
which spawns a background tokio task to perform the TCP handshake and socket
|
||||
configuration (TCP_NODELAY, keepalive, buffer sizes, TCP_MAXSEG query). The
|
||||
call returns immediately without blocking the event loop.
|
||||
|
||||
1. Connect with configurable timeout (default 5s)
|
||||
2. Configure socket: `TCP_NODELAY`, keepalive, buffer sizes
|
||||
3. Read `TCP_MAXSEG` for per-connection MTU
|
||||
4. Split stream into read/write halves
|
||||
5. Spawn per-connection receive task
|
||||
6. Store connection in pool
|
||||
7. Write packet directly to stream
|
||||
The node tracks each pending connection in a `PendingConnect` entry. On
|
||||
every tick, `poll_pending_connects()` calls `connection_state(addr)` to
|
||||
check progress. When the transport reports `Connected`, the completed
|
||||
connection is promoted to the established pool (stream split into
|
||||
read/write halves, per-connection receive task spawned), and the node
|
||||
initiates the Noise IK link handshake. If the transport reports `Failed`,
|
||||
the node schedules a retry with exponential backoff.
|
||||
|
||||
If connect fails, return error. The node's handshake retry mechanism
|
||||
handles re-attempts.
|
||||
As a fallback, `send(addr, data)` still performs synchronous
|
||||
connect-on-send if no connection exists — this handles the case where a
|
||||
send arrives before the node-level connect path runs. The non-blocking
|
||||
path is the primary mechanism for configured peers.
|
||||
|
||||
### Session Independence
|
||||
|
||||
TCP connection loss does **not** tear down the FIPS peer. Noise keys, MMP
|
||||
state, and FSP sessions are bound to the peer's npub, not the TCP
|
||||
connection. The transport reconnects transparently on the next send via
|
||||
connect-on-send. MMP liveness timeout is the sole authority for peer death.
|
||||
connection. The transport reconnects transparently via the non-blocking
|
||||
connect path or connect-on-send fallback. MMP liveness timeout is the sole
|
||||
authority for peer death.
|
||||
|
||||
### Connection Deduplication
|
||||
|
||||
@@ -408,13 +436,231 @@ transports:
|
||||
recv_buf_size: 2097152 # SO_RCVBUF (2 MB)
|
||||
send_buf_size: 2097152 # SO_SNDBUF (2 MB)
|
||||
max_inbound_connections: 256 # Resource protection limit
|
||||
socks5_proxy: "127.0.0.1:9050" # SOCKS5 for outbound (deferred)
|
||||
```
|
||||
|
||||
If `bind_addr` is configured, the transport accepts inbound connections.
|
||||
Without it, the transport operates in outbound-only mode (no listener
|
||||
socket is created).
|
||||
|
||||
## Tor: The Anonymity Transport
|
||||
|
||||
The Tor transport routes FIPS traffic through the Tor network, hiding
|
||||
a node's IP address from its peers. A node behind Tor connects outbound
|
||||
through a local Tor SOCKS5 proxy; the remote peer sees the Tor exit
|
||||
node's IP, not the initiator's. After the Noise IK handshake, the remote
|
||||
peer knows the initiator's FIPS identity (npub) but not its network
|
||||
location.
|
||||
|
||||
Like TCP, Tor is connection-oriented and reliable. The same TCP-over-TCP
|
||||
considerations apply — MMP correctly measures the elevated latency and
|
||||
cost-based parent selection naturally deprioritizes Tor links.
|
||||
|
||||
### Architecture
|
||||
|
||||
The Tor transport is a separate `TorTransport` implementation, not a TCP
|
||||
variant, because it manages SOCKS5 proxy negotiation, has different
|
||||
address semantics (.onion vs IP:port), and has significantly different
|
||||
latency characteristics. It reuses the FMP header-based stream reader
|
||||
(`tcp/stream.rs`) for packet framing on the underlying TCP connection.
|
||||
|
||||
The transport maintains two pools (same pattern as TCP): a
|
||||
`ConnectingPool` for background SOCKS5 connection attempts, and an
|
||||
established pool of `TorConnection` entries. Each `TorConnection` holds
|
||||
a write half, a per-connection receive task, the negotiated MTU, and
|
||||
a connection timestamp.
|
||||
|
||||
| Property | Value |
|
||||
| -------- | ----- |
|
||||
| Addressing | .onion:port or IP:port |
|
||||
| Default MTU | 1400 bytes |
|
||||
| Framing | FMP header-based (shared with TCP) |
|
||||
| Connection model | Non-blocking connect, outbound SOCKS5 + inbound via onion service |
|
||||
| Platform | Cross-platform (requires external Tor daemon) |
|
||||
|
||||
### Address Types
|
||||
|
||||
The Tor transport accepts three address formats, parsed into a `TorAddr`
|
||||
enum:
|
||||
|
||||
- **Onion**: `.onion:port` — connects to a Tor hidden service. Both
|
||||
sides anonymous. (e.g., `abcdef...xyz.onion:8443`)
|
||||
- **Clearnet IP**: `IP:port` — connects through a Tor exit node to a
|
||||
remote TCP listener. Hides the initiator's IP; the remote peer sees
|
||||
the exit node's IP.
|
||||
- **Clearnet Hostname**: `hostname:port` — hostname is passed through
|
||||
SOCKS5 for Tor-side DNS resolution, avoiding local DNS leaks. Compatible
|
||||
with SafeSocks 1. (e.g., `fips.example.com:8443`)
|
||||
|
||||
All address types are routed through the same SOCKS5 proxy.
|
||||
|
||||
### Connection Establishment
|
||||
|
||||
Connection setup follows the same non-blocking pattern as TCP. When FMP
|
||||
needs to reach a peer, the node calls `connect(addr)` on the transport.
|
||||
The transport spawns a background tokio task that:
|
||||
|
||||
1. Opens a SOCKS5 connection through the local Tor proxy
|
||||
2. Configures the socket: `TCP_NODELAY`, keepalive (30s)
|
||||
3. Returns the connected stream
|
||||
|
||||
The call returns immediately. `connection_state(addr)` reports progress.
|
||||
Tor circuit establishment typically takes 10–60 seconds (vs milliseconds
|
||||
for TCP), making non-blocking connect essential — a blocking connect
|
||||
would stall the entire FMP event loop.
|
||||
|
||||
The connect timeout defaults to 120 seconds (vs 5 seconds for TCP),
|
||||
accounting for Tor circuit setup time. As a fallback, `send(addr, data)`
|
||||
performs synchronous connect-on-send if no connection exists.
|
||||
|
||||
### Inbound via Onion Service (Directory Mode)
|
||||
|
||||
In `directory` mode (recommended for production), Tor manages the onion
|
||||
service via `HiddenServiceDir` in `torrc`. FIPS reads the `.onion` address
|
||||
from the hostname file at startup and binds a local TCP listener that the
|
||||
Tor daemon forwards inbound connections to.
|
||||
|
||||
This mode enables Tor's `Sandbox 1` (seccomp-bpf) — the strongest single
|
||||
hardening option — because no control port interaction is required for
|
||||
onion service management. Tor handles key generation and persistence
|
||||
directly through the `HiddenServiceDir`.
|
||||
|
||||
The inbound accept loop mirrors the TCP transport's pattern: accept
|
||||
connection, configure socket (TCP_NODELAY, keepalive), spawn a
|
||||
per-connection receive loop using the shared FMP stream reader. Inbound
|
||||
connections arrive from `127.0.0.1` (Tor daemon's local forwarding); peer
|
||||
identity is resolved during the Noise IK handshake, not from the transport
|
||||
address.
|
||||
|
||||
Configuration requires coordinating `torrc` and `fips.yaml`:
|
||||
|
||||
```text
|
||||
# torrc
|
||||
HiddenServiceDir /var/lib/tor/fips
|
||||
HiddenServicePort 8443 127.0.0.1:8444
|
||||
|
||||
# fips.yaml tor section
|
||||
mode: "directory"
|
||||
directory_service:
|
||||
hostname_file: "/var/lib/tor/fips/hostname"
|
||||
bind_addr: "127.0.0.1:8444"
|
||||
```
|
||||
|
||||
The `HiddenServicePort` external port (8443) is what peers connect to.
|
||||
The bind_addr must match the `HiddenServicePort` target address.
|
||||
|
||||
### Session Independence
|
||||
|
||||
Same as TCP: Tor connection loss does **not** tear down the FIPS peer.
|
||||
Noise keys, MMP state, and FSP sessions survive reconnection.
|
||||
|
||||
### Bridge Node Pattern
|
||||
|
||||
A node running both Tor and UDP transports acts as a bridge between
|
||||
anonymous and clearnet portions of the mesh:
|
||||
|
||||
```text
|
||||
[Anonymous node] --tor--> [Bridge node] --udp--> [Clearnet node]
|
||||
```
|
||||
|
||||
No special code is needed — FIPS multi-transport routing handles it.
|
||||
Anonymous nodes connect to the bridge via Tor; the bridge forwards
|
||||
traffic to clearnet peers over UDP. Clearnet peers never see the
|
||||
anonymous node's IP.
|
||||
|
||||
### Latency Characteristics
|
||||
|
||||
Tor adds 200ms–2s RTT per circuit. First-packet latency after connection
|
||||
is higher (~2.8s) due to circuit warm-up. MMP measures this elevated
|
||||
latency, and cost-based parent selection penalizes Tor links (high SRTT
|
||||
→ high link cost). ETX is 1.0 since TCP handles retransmission.
|
||||
|
||||
Tor throughput is typically 1–5 Mbps — adequate for control plane and
|
||||
moderate data transfer, not for bulk transfer.
|
||||
|
||||
### Monitoring
|
||||
|
||||
In `control_port` mode and optionally in `directory` mode (when
|
||||
`control_addr` is configured), the transport spawns a background
|
||||
monitoring task that polls the Tor daemon every 10 seconds via the
|
||||
control port. The cached monitoring data is exposed through the
|
||||
`show_transports` control socket query and displayed in fipstop.
|
||||
|
||||
Monitoring data includes:
|
||||
|
||||
- **Bootstrap progress** (0–100%) with INFO logging at milestones
|
||||
(25/50/75/100%) and WARN if stalled >60s
|
||||
- **Circuit status** (whether Tor has a working circuit)
|
||||
- **Network liveness** (up/down) with WARN on transitions
|
||||
- **Dormant mode** detection with WARN on entry
|
||||
- **Tor daemon version** and **traffic counters** (bytes read/written)
|
||||
|
||||
The control port connection uses cookie authentication by default
|
||||
(reading from `/var/run/tor/control.authcookie`). Unix socket
|
||||
connections (`/run/tor/control`) are preferred over TCP for security.
|
||||
|
||||
### Configuration
|
||||
|
||||
```yaml
|
||||
transports:
|
||||
tor:
|
||||
mode: "socks5" # "socks5", "control_port", or "directory"
|
||||
socks5_addr: "127.0.0.1:9050" # SOCKS5 proxy address
|
||||
connect_timeout_ms: 120000 # Connect timeout (120s for Tor circuits)
|
||||
mtu: 1400 # Default MTU
|
||||
# control_port mode: monitoring via Tor control port (no inbound)
|
||||
# control_addr: "/run/tor/control" # Unix socket (preferred) or host:port
|
||||
# control_auth: "cookie" # "cookie" or "password:<secret>"
|
||||
# cookie_path: "/var/run/tor/control.authcookie"
|
||||
# directory mode: inbound via Tor-managed HiddenServiceDir
|
||||
# directory_service:
|
||||
# hostname_file: "/var/lib/tor/fips/hostname"
|
||||
# bind_addr: "127.0.0.1:8444"
|
||||
# max_inbound_connections: 64
|
||||
```
|
||||
|
||||
Three modes are available:
|
||||
|
||||
- **`socks5`** (default): Outbound-only through a SOCKS5 proxy. No
|
||||
control port, no inbound connections.
|
||||
- **`control_port`**: Outbound via SOCKS5 plus control port connection
|
||||
for Tor daemon monitoring. No inbound connections.
|
||||
- **`directory`** (recommended for inbound): Outbound via SOCKS5 plus
|
||||
inbound via Tor-managed `HiddenServiceDir` onion service. Optionally
|
||||
connects to the control port for monitoring when `control_addr` is set.
|
||||
Enables Tor's `Sandbox 1` for maximum security.
|
||||
|
||||
The Tor transport requires an external Tor daemon. Named instances are
|
||||
supported for multiple proxy endpoints.
|
||||
|
||||
### Implementation Roadmap
|
||||
|
||||
- Outbound SOCKS5 connections to .onion, clearnet IP, and clearnet
|
||||
hostname addresses *(implemented)*
|
||||
- Inbound connections via Tor onion service using `HiddenServiceDir`
|
||||
directory mode *(implemented)*
|
||||
- Operator visibility: cached monitoring snapshot, control socket
|
||||
exposure, fipstop display, bootstrap/liveness logging *(implemented)*
|
||||
- Embedded `arti` (Rust Tor implementation) for self-contained operation
|
||||
without an external Tor daemon *(future)*
|
||||
|
||||
### Statistics
|
||||
|
||||
The transport tracks per-instance statistics:
|
||||
|
||||
| Counter | Description |
|
||||
| ------- | ----------- |
|
||||
| `packets_sent` / `bytes_sent` | Successful sends |
|
||||
| `packets_recv` / `bytes_recv` | Successful receives |
|
||||
| `send_errors` / `recv_errors` | Send/receive failures |
|
||||
| `connections_established` | Successful SOCKS5 connections |
|
||||
| `connect_timeouts` | Connection timeout count |
|
||||
| `connect_refused` | Connection refused count |
|
||||
| `socks5_errors` | SOCKS5 protocol errors |
|
||||
| `mtu_exceeded` | Packets rejected for MTU violation |
|
||||
| `connections_accepted` | Accepted inbound connections via onion service |
|
||||
| `connections_rejected` | Rejected inbound connections (limit exceeded) |
|
||||
| `control_errors` | Tor control port errors |
|
||||
|
||||
## Discovery
|
||||
|
||||
Discovery determines that a FIPS-capable endpoint is reachable at a given
|
||||
@@ -423,7 +669,7 @@ detection — a new TCP connection or UDP packet from an unknown source is not
|
||||
discovery; a FIPS-specific announcement or response is.
|
||||
|
||||
Discovery is an optional transport capability. Transports that don't support
|
||||
it (configured UDP endpoints, TCP) simply don't provide discovery events.
|
||||
it (configured UDP endpoints, TCP, Tor) simply don't provide discovery events.
|
||||
FMP handles both cases uniformly: with discovery, it waits for events then
|
||||
initiates link setup; without discovery, it initiates link setup directly to
|
||||
configured addresses.
|
||||
@@ -453,7 +699,7 @@ X." FMP does not need to distinguish beacons from query responses.
|
||||
|
||||
For internet-reachable transports, a node publishes a signed Nostr event
|
||||
containing its FIPS discovery information — public key and reachable
|
||||
transport endpoints (UDP IP:port, TCP IP:port, .onion address). Other FIPS
|
||||
transport endpoints (UDP host:port, TCP host:port, .onion address). Other FIPS
|
||||
nodes subscribing on the same relays learn about available peers.
|
||||
|
||||
Nostr relay discovery is not a transport — it is a discovery service that
|
||||
@@ -472,13 +718,13 @@ Key properties:
|
||||
|
||||
### Current State
|
||||
|
||||
> **Implemented**: UDP and TCP peers are configured via YAML. Ethernet
|
||||
> peers are discovered via beacon broadcast — the `discover()` trait
|
||||
> method returns newly seen endpoints, and per-transport `auto_connect()`
|
||||
> / `accept_connections()` policies control whether discovered peers are
|
||||
> connected automatically or require explicit configuration. TCP has no
|
||||
> discovery mechanism (peers are configured). Nostr relay discovery is
|
||||
> not yet implemented.
|
||||
> **Implemented**: UDP, TCP, Tor, and Ethernet peers can be configured
|
||||
> statically via YAML. Ethernet peers can also be discovered via beacon
|
||||
> broadcast — the `discover()` trait method returns newly seen endpoints,
|
||||
> and per-transport `auto_connect()` / `accept_connections()` policies
|
||||
> control whether discovered peers are connected automatically or require
|
||||
> explicit configuration. TCP and Tor have no discovery mechanism.
|
||||
> Nostr relay discovery is not yet implemented.
|
||||
|
||||
## Transport Interface
|
||||
|
||||
@@ -496,6 +742,8 @@ link_mtu(addr) → u16 Per-link MTU (defaults to mtu())
|
||||
start() → lifecycle Bring transport up (bind socket, open device)
|
||||
stop() → lifecycle Bring transport down
|
||||
send(addr, data) → delivery Send datagram to transport address
|
||||
connect(addr) → () Initiate non-blocking connection (connection-oriented only)
|
||||
connection_state(addr)→ ConnectionState Poll connection status (None/Connecting/Connected/Failed)
|
||||
close_connection(addr)→ () Close a specific connection (no-op for connectionless)
|
||||
congestion() → TransportCongestion Local congestion indicators (optional)
|
||||
discover() → Vec<DiscoveredPeer> Report discovered FIPS endpoints (optional)
|
||||
@@ -553,13 +801,14 @@ on all forwarded datagrams.
|
||||
| Transport | Congestion Source | Mechanism |
|
||||
| --------- | ----------------- | --------- |
|
||||
| UDP | `SO_RXQ_OVFL` kernel drop counter | `recvmsg()` ancillary data on every packet |
|
||||
| TCP | Not yet implemented | Returns `None` (TCP handles congestion internally) |
|
||||
| Ethernet | Not yet implemented | Returns `None` |
|
||||
| TCP | Not implemented | Returns `None` (TCP handles congestion internally) |
|
||||
| Tor | Not implemented | Returns `None` (TCP handles congestion internally) |
|
||||
| Ethernet | Not implemented | Returns `None` |
|
||||
|
||||
### Transport Addresses
|
||||
|
||||
Transport addresses (`TransportAddr`) are opaque byte vectors. The transport
|
||||
layer interprets them (e.g., UDP parses "ip:port" strings); all layers above
|
||||
layer interprets them (e.g., UDP/TCP resolve "host:port" strings (IP fast path, DNS fallback with 60s cache for UDP)); all layers above
|
||||
treat them as opaque handles passed back to the transport for sending.
|
||||
|
||||
### Transport State Machine
|
||||
@@ -579,10 +828,10 @@ transitions through `Starting` to `Up` (operational). `stop()` moves to
|
||||
| Transport | Status | Notes |
|
||||
| --------- | ------ | ----- |
|
||||
| UDP/IP | **Implemented** | Primary transport, AsyncFd/recvmsg, SO_RXQ_OVFL kernel drop detection |
|
||||
| TCP/IP | **Implemented** | FMP header-based framing, connect-on-send, per-connection MSS MTU |
|
||||
| TCP/IP | **Implemented** | FMP header-based framing, non-blocking connect, per-connection MSS MTU |
|
||||
| Ethernet | **Implemented** | AF_PACKET SOCK_DGRAM, EtherType 0x2121, beacon discovery, Linux only |
|
||||
| WiFi | Future direction | Infrastructure mode = Ethernet driver |
|
||||
| Tor | Future direction | High latency, .onion addressing |
|
||||
| Tor | **Implemented** | Outbound SOCKS5, inbound via onion service, .onion and clearnet addressing |
|
||||
| BLE | Future direction | ATT_MTU negotiation, per-link MTU |
|
||||
| Radio | Future direction | Constrained MTU (51–222 bytes) |
|
||||
| Serial | Future direction | SLIP/COBS framing, point-to-point |
|
||||
|
||||
@@ -21,7 +21,8 @@ Datagram-oriented transports (UDP, raw Ethernet, radio) preserve natural
|
||||
packet boundaries and require no additional framing. Stream-oriented
|
||||
transports (TCP, WebSocket, Tor) must delineate FIPS packets within the
|
||||
byte stream; the common prefix `payload_len` field provides this
|
||||
framing directly.
|
||||
framing directly. TCP and Tor share a common stream reader
|
||||
(`tcp/stream.rs`) that implements this framing.
|
||||
|
||||
**Ethernet data frame header.** The Ethernet transport prepends a 3-byte
|
||||
header before the FMP payload on data frames: a 1-byte frame type
|
||||
@@ -254,7 +255,11 @@ With link overhead: 1,072 bytes.
|
||||
|
||||
### LookupRequest (0x30)
|
||||
|
||||
Coordinate discovery request, flooded through the mesh.
|
||||
Coordinate discovery request, routed through the spanning tree via
|
||||
bloom-filter-guided forwarding. Each transit node forwards only to tree
|
||||
peers (parent + children) whose bloom filter contains the target.
|
||||
Request_id dedup in recent_requests handles edge cases from tree
|
||||
restructuring.
|
||||
|
||||

|
||||
|
||||
@@ -268,20 +273,19 @@ Coordinate discovery request, flooded through the mesh.
|
||||
| 42 | min_mtu | 2 bytes LE | Minimum transport MTU the origin requires (0 = no requirement) |
|
||||
| 44 | origin_coords_cnt | 2 bytes LE | Number of coordinate entries |
|
||||
| 46 | origin_coords | 16 x n bytes | Requester's ancestry (NodeAddr only) |
|
||||
| 46 + 16n | visited_hash_cnt | 1 byte | Hash count for visited filter |
|
||||
| 47 + 16n | visited_bits | 256 bytes | Compact bloom of visited nodes |
|
||||
|
||||
**Size**: `303 + (n x 16)` bytes, where n = origin depth + 1
|
||||
**Size**: `46 + (n x 16)` bytes, where n = origin depth + 1
|
||||
|
||||
| Origin Depth | Payload |
|
||||
| ------------ | ------- |
|
||||
| 3 | 351 bytes |
|
||||
| 5 | 383 bytes |
|
||||
| 10 | 463 bytes |
|
||||
| 3 | 110 bytes |
|
||||
| 5 | 142 bytes |
|
||||
| 10 | 222 bytes |
|
||||
|
||||
### LookupResponse (0x31)
|
||||
|
||||
Coordinate discovery response, greedy-routed back to requester.
|
||||
Coordinate discovery response, reverse-path routed back to the
|
||||
requester via the transit nodes that forwarded the request.
|
||||
|
||||

|
||||
|
||||
|
||||
BIN
docs/logos/fips_banner.png
Normal file
BIN
docs/logos/fips_banner.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.2 MiB |
BIN
docs/logos/fips_logo.png
Normal file
BIN
docs/logos/fips_logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.2 MiB |
141
docs/releases/release-notes-v0.2.1.md
Normal file
141
docs/releases/release-notes-v0.2.1.md
Normal file
@@ -0,0 +1,141 @@
|
||||
# FIPS v0.2.1
|
||||
|
||||
**Released**: 2026-05-11
|
||||
|
||||
v0.2.1 is a maintenance release on the v0.2.x line. No new features
|
||||
and no wire-format changes; operators running v0.2.0 can upgrade in
|
||||
place. The release rolls up bug fixes and operational hardening for
|
||||
issues surfaced in v0.2.0 deployments, plus a bloom-filter fill-ratio
|
||||
validation that protects mesh-size estimates from saturated-filter
|
||||
inputs.
|
||||
|
||||
## At a glance
|
||||
|
||||
- 22 commits since v0.2.0, 5 committers plus 2 issue reporters.
|
||||
- All changes are backwards-compatible with v0.2.0 on the wire.
|
||||
- Bloom filter fill-ratio validation hardens the FilterAnnounce
|
||||
ingress path.
|
||||
- TreeAnnounce ancestry validation tightened to match the
|
||||
spanning-tree specification.
|
||||
- Signed-tarball + `.deb` artifact workflow added for tagged
|
||||
releases; AUR auto-publish on stable tags.
|
||||
|
||||
## Behavior changes worth flagging
|
||||
|
||||
- **Bloom filter fill-ratio validation** runs on every inbound
|
||||
`FilterAnnounce`. Filters whose derived false-positive rate exceeds
|
||||
`node.bloom.max_inbound_fpr` (new config field, default `0.05`) are
|
||||
rejected silently on the wire, logged at WARN, and counted in a
|
||||
new `bloom.fill_exceeded` counter. A rate-limited WARN also fires
|
||||
when the local outgoing filter exceeds the cap.
|
||||
`BloomFilter::estimated_count` now takes `max_fpr` and returns
|
||||
`Option<f64>`, returning `None` for saturated filters; this
|
||||
propagates through `compute_mesh_size` into `estimated_mesh_size`.
|
||||
- **TreeAnnounce ancestry validation** is now run before tree-state
|
||||
mutation, enforcing ancestry-self-match, root-single-entry,
|
||||
parent-second-entry, and root-is-minimum-NodeAddr. Non-conforming
|
||||
announces are rejected with a WARN. Mixed v0.2.0 / v0.2.1 meshes
|
||||
may produce WARN log lines on the v0.2.1 side until all peers
|
||||
upgrade; behavior is correct, log noise only.
|
||||
|
||||
## Notable bug fixes
|
||||
|
||||
- **Control socket path detection** in `fipsctl` and `fipstop` now
|
||||
checks for the `/run/fips/` directory instead of the socket file
|
||||
inside it. Users not yet in the `fips` group get a clear
|
||||
"Permission denied" error instead of a misleading "No such file"
|
||||
fallback to `$XDG_RUNTIME_DIR`
|
||||
([#30](https://github.com/jmcorgan/fips/issues/30), reported by
|
||||
[@Sebastix](https://github.com/Sebastix)).
|
||||
- **`fd00::/8` routing protected from Tailscale interception.** The
|
||||
daemon installs an IPv6 routing-policy rule
|
||||
(`ip -6 rule to fd00::/8 lookup main priority 5265`) at TUN setup,
|
||||
so Tailscale's table 52 default route can no longer divert mesh
|
||||
traffic.
|
||||
- **Bloom filter routing greedy-tree fallback.** `find_next_hop` no
|
||||
longer returns `NoRoute` when the bloom candidate set is non-empty
|
||||
but no candidate is strictly closer than the current node; it
|
||||
falls through to greedy tree routing instead. Previously, this
|
||||
caused dropped packets in topologies where the tree parent was
|
||||
closer but not a bloom candidate.
|
||||
- **Auto-connect peers reconnect after a graceful Disconnect.**
|
||||
Previously, a clean upstream shutdown left the auto-connect peer
|
||||
orphaned; only the link-dead, decrypt-fail, and peer-restart paths
|
||||
scheduled a reconnect
|
||||
([#60](https://github.com/jmcorgan/fips/issues/60), reported by
|
||||
[@SwapMarket](https://github.com/SwapMarket)).
|
||||
- **`fipsctl connect` rejects FIPS mesh addresses** (`fd00::/8`) for
|
||||
`udp`, `tcp`, and `ethernet` transports with a clear error message
|
||||
instead of echoing success while the daemon silently failed the
|
||||
bind with `EAFNOSUPPORT`
|
||||
([#61](https://github.com/jmcorgan/fips/issues/61), reported by
|
||||
[@SwapMarket](https://github.com/SwapMarket)).
|
||||
- **OpenWrt ipk** cross-compiles cleanly again after excluding the
|
||||
BLE feature that requires D-Bus, which is unavailable on OpenWrt
|
||||
targets.
|
||||
|
||||
## Packaging
|
||||
|
||||
- **Linux release artifact workflow** builds x86_64 and aarch64
|
||||
tarballs and `.deb` packages on `v*` tag push, with SHA-256
|
||||
checksums, and publishes them to the GitHub release page.
|
||||
- **AUR publish workflow** auto-publishes the `fips` PKGBUILD on
|
||||
stable `v*` tags.
|
||||
|
||||
## Upgrade notes
|
||||
|
||||
Operator-actionable items when moving from v0.2.0 to v0.2.1:
|
||||
|
||||
- **Bloom filter fill-ratio cap (default 0.05).** Inbound
|
||||
`FilterAnnounce` messages whose derived FPR exceeds the cap are
|
||||
rejected silently on the wire. Operators with unusually saturated
|
||||
filters in the field may want to confirm that the default applies
|
||||
cleanly to their deployment; check the new `bloom.fill_exceeded`
|
||||
counter if mesh-size estimates drift after upgrade.
|
||||
- **TreeAnnounce ancestry tightening.** Mixed v0.2.0 / v0.2.1 meshes
|
||||
may produce WARN log lines on the v0.2.1 side until all peers
|
||||
upgrade. Behavior is correct, log noise only.
|
||||
|
||||
## Getting v0.2.1
|
||||
|
||||
- **Linux x86_64 / aarch64**: `.deb` and tarball at the
|
||||
[v0.2.1 release page](https://github.com/jmcorgan/fips/releases/tag/v0.2.1).
|
||||
- **Arch Linux**: `fips` from the AUR.
|
||||
- **OpenWrt**: `.ipk` at the v0.2.1 release page.
|
||||
- **From source**: `cargo build --release` from a checkout of the
|
||||
v0.2.1 tag.
|
||||
|
||||
The full per-commit changelog lives in
|
||||
[`CHANGELOG.md`](../../CHANGELOG.md). Issues and discussion at
|
||||
[github.com/jmcorgan/fips](https://github.com/jmcorgan/fips).
|
||||
|
||||
## Contributors
|
||||
|
||||
Thanks to everyone who contributed code or bug reports to this
|
||||
release.
|
||||
|
||||
**Code and packaging**:
|
||||
|
||||
- [@jcorgan](https://github.com/jmcorgan): release shepherd, bloom
|
||||
fill-ratio validation, auto-connect reconnect fix, `fipsctl`
|
||||
mesh-address rejection, control-socket path detection,
|
||||
Tailscale-vs-`fd00::/8` routing policy, bloom routing greedy
|
||||
fallback, rustfmt baseline.
|
||||
- [@Origami74](https://github.com/Origami74): OpenWrt ipk
|
||||
BLE-feature build fix.
|
||||
- [@jodobear](https://github.com/jodobear): Linux release-artifact
|
||||
workflow and target-aware build scripts.
|
||||
- [@dskvr](https://github.com/dskvr): AUR publish workflow.
|
||||
- [@SatsAndSports](https://github.com/SatsAndSports): TreeAnnounce
|
||||
semantic validation.
|
||||
|
||||
**Issue reports that drove fixes in this release**:
|
||||
|
||||
- [@Sebastix](https://github.com/Sebastix): `fipsctl` / `fipstop`
|
||||
control-socket path detection
|
||||
([#30](https://github.com/jmcorgan/fips/issues/30)).
|
||||
- [@SwapMarket](https://github.com/SwapMarket): auto-connect
|
||||
reconnect after graceful disconnect
|
||||
([#60](https://github.com/jmcorgan/fips/issues/60)) and
|
||||
`fipsctl` mesh-address rejection
|
||||
([#61](https://github.com/jmcorgan/fips/issues/61)).
|
||||
11
examples/k8s-sidecar/.dockerignore
Normal file
11
examples/k8s-sidecar/.dockerignore
Normal file
@@ -0,0 +1,11 @@
|
||||
target/
|
||||
.git/
|
||||
.github/
|
||||
testing/chaos/
|
||||
testing/static/
|
||||
testing/sidecar/
|
||||
testing/tor/
|
||||
fips
|
||||
fipsctl
|
||||
../sidecar/fips
|
||||
../sidecar/fipsctl
|
||||
63
examples/k8s-sidecar/Dockerfile
Normal file
63
examples/k8s-sidecar/Dockerfile
Normal file
@@ -0,0 +1,63 @@
|
||||
# -----------------------------------------------------------------------------
|
||||
# Stage 1 — build
|
||||
# Compile fips and fipsctl inside a Rust toolchain image so no local Rust
|
||||
# install is needed and the image is fully reproducible.
|
||||
# -----------------------------------------------------------------------------
|
||||
FROM rust:slim-trixie AS builder
|
||||
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends \
|
||||
pkg-config \
|
||||
libssl-dev && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /build
|
||||
|
||||
# Copy the full workspace source. .dockerignore filters target/ and other
|
||||
# large paths so the context stays small.
|
||||
COPY . .
|
||||
|
||||
# Build only the two binaries needed at runtime; skip the TUI (fipstop)
|
||||
# to avoid pulling in ratatui and crossterm. The daemon does not require
|
||||
# any default features to run.
|
||||
RUN cargo build --release --no-default-features \
|
||||
--bin fips \
|
||||
--bin fipsctl
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Stage 2 — runtime
|
||||
# Minimal Debian image with only the packages required at runtime.
|
||||
# -----------------------------------------------------------------------------
|
||||
FROM debian:trixie-slim
|
||||
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends \
|
||||
iproute2 \
|
||||
iptables \
|
||||
dnsmasq \
|
||||
procps \
|
||||
curl && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# dnsmasq: forward .fips queries to the FIPS daemon DNS resolver;
|
||||
# forward everything else to the upstream resolver passed in at runtime.
|
||||
# The upstream placeholder is replaced by entrypoint.sh with the pod's
|
||||
# original nameserver(s) from /etc/resolv.conf before dnsmasq starts.
|
||||
RUN printf '%s\n' \
|
||||
'port=53' \
|
||||
'listen-address=127.0.0.1' \
|
||||
'bind-interfaces' \
|
||||
'server=/fips/127.0.0.1#5354' \
|
||||
'no-resolv' \
|
||||
> /etc/dnsmasq.d/fips.conf
|
||||
|
||||
COPY --from=builder /build/target/release/fips /usr/local/bin/fips
|
||||
COPY --from=builder /build/target/release/fipsctl /usr/local/bin/fipsctl
|
||||
|
||||
COPY examples/k8s-sidecar/entrypoint.sh /entrypoint.sh
|
||||
RUN chmod +x /entrypoint.sh
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||
CMD fipsctl show status
|
||||
|
||||
ENTRYPOINT ["/entrypoint.sh"]
|
||||
176
examples/k8s-sidecar/README.md
Normal file
176
examples/k8s-sidecar/README.md
Normal file
@@ -0,0 +1,176 @@
|
||||
# FIPS Kubernetes Sidecar
|
||||
|
||||
Run FIPS as a sidecar container in a Kubernetes Pod, injecting the FIPS mesh
|
||||
network interface into every other container in the Pod. All containers in
|
||||
the Pod share the same network namespace, so once the sidecar creates `fips0`
|
||||
it is immediately visible to the app container(s) without any further
|
||||
configuration.
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# 1. Build the sidecar image
|
||||
cd examples/k8s-sidecar
|
||||
./scripts/build.sh --tag fips-k8s-sidecar:latest
|
||||
|
||||
# 2. Push to your registry (replace with your own)
|
||||
docker tag fips-k8s-sidecar:latest registry.example.com/fips-k8s-sidecar:latest
|
||||
docker push registry.example.com/fips-k8s-sidecar:latest
|
||||
|
||||
# 3. Create the identity secret
|
||||
kubectl create secret generic fips-identity \
|
||||
--from-literal=nsec=$(openssl rand -hex 32)
|
||||
|
||||
# 4. Deploy
|
||||
kubectl apply -f pod.yaml
|
||||
```
|
||||
|
||||
## How It Works
|
||||
|
||||
In Kubernetes, all containers in a Pod share the same network namespace. The
|
||||
fips sidecar:
|
||||
|
||||
1. Generates `/etc/fips/fips.yaml` from environment variables.
|
||||
2. Rewrites `/etc/resolv.conf` to route `.fips` DNS through dnsmasq (which
|
||||
forwards `.fips` names to the FIPS daemon's built-in DNS resolver and
|
||||
everything else to the original cluster DNS).
|
||||
3. Applies `iptables` isolation rules so that the pod's physical interface
|
||||
(`eth0`) only carries FIPS transport traffic.
|
||||
4. Starts dnsmasq and then `exec`s the FIPS daemon.
|
||||
|
||||
The app container starts concurrently and immediately sees `lo`, `eth0`, and
|
||||
`fips0`. DNS for `<npub>.fips` names resolves to `fd::/8` addresses via the
|
||||
dnsmasq → FIPS daemon pipeline.
|
||||
|
||||
```text
|
||||
┌────────────────────────────────────────────────────┐
|
||||
│ Kubernetes Pod (shared network namespace) │
|
||||
│ │
|
||||
│ ┌─────────────────┐ ┌───────────────────────────┐│
|
||||
│ │ fips (sidecar) │ │ app container(s) ││
|
||||
│ │ │ │ ││
|
||||
│ │ fips daemon │ │ your workload ││
|
||||
│ │ fipsctl │ │ sees: lo, eth0, fips0 ││
|
||||
│ │ dnsmasq │ │ ││
|
||||
│ └─────────────────┘ └───────────────────────────┘│
|
||||
│ │
|
||||
│ Interfaces: │
|
||||
│ lo — loopback (unrestricted) │
|
||||
│ eth0 — pod CNI interface (iptables: FIPS only) │
|
||||
│ fips0 — FIPS TUN (fd00::/8, unrestricted) │
|
||||
└────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Network Isolation
|
||||
|
||||
`FIPS_ISOLATE` controls whether the sidecar locks down `eth0`:
|
||||
|
||||
| Value | Behaviour |
|
||||
|---|---|
|
||||
| `false` **(default)** | `fips0` is added alongside normal cluster networking. `eth0` continues to work — services, DNS, other pods, and the internet are all reachable as normal. Application traffic to FIPS mesh peers uses `fips0`. |
|
||||
| `true` | All traffic on `eth0` is dropped except FIPS transport (UDP 2121). The app container can **only** communicate via `fips0`. Use this for deployments where the workload must never bypass the mesh. |
|
||||
|
||||
> **Common gotcha**: if you deploy the sidecar and find that services or
|
||||
> other pods are suddenly unreachable, check that `FIPS_ISOLATE` is not
|
||||
> set to `true`. The mesh-only mode is intentionally strict and will break
|
||||
> normal cluster connectivity.
|
||||
|
||||
## Requirements
|
||||
|
||||
| Requirement | Notes |
|
||||
|---|---|
|
||||
| `NET_ADMIN` capability | Required for TUN creation and iptables |
|
||||
| `/dev/net/tun` | HostPath volume mount (see `pod.yaml`) |
|
||||
| Linux kernel ≥ 4.9 | Standard on all current distributions |
|
||||
| No gVisor / kata-containers | Requires real kernel TUN support |
|
||||
|
||||
## Environment Variables
|
||||
|
||||
| Variable | Default | Description |
|
||||
|---|---|---|
|
||||
| `FIPS_NSEC` | *(required)* | Node secret key (hex or nsec1 bech32) |
|
||||
| `FIPS_PEER_NPUB` | *(empty)* | Single peer npub |
|
||||
| `FIPS_PEER_ADDR` | *(empty)* | Single peer transport address (`host:port`) |
|
||||
| `FIPS_PEER_ALIAS` | `peer` | Single peer alias |
|
||||
| `FIPS_PEER_TRANSPORT` | `udp` | Single peer transport type |
|
||||
| `FIPS_PEERS_JSON` | *(empty)* | JSON array of peer objects (overrides single-peer vars). **Note**: the included parser handles simple host:port addresses only; IPv6 addresses with brackets may not parse correctly. |
|
||||
| `FIPS_UDP_BIND` | `0.0.0.0:2121` | UDP transport bind address |
|
||||
| `FIPS_UDP_PORT` | *(from `FIPS_UDP_BIND`)* | UDP port for iptables rules |
|
||||
| `FIPS_TCP_BIND` | *(disabled)* | TCP transport bind address |
|
||||
| `FIPS_TUN_NAME` | `fips0` | TUN interface name |
|
||||
| `FIPS_TUN_MTU` | `1280` | TUN MTU |
|
||||
| `FIPS_ISOLATE` | `false` | Apply iptables isolation (blocks all eth0 traffic except FIPS transport) |
|
||||
| `FIPS_POD_IFACE` | `eth0` | Pod physical interface name |
|
||||
| `FIPS_REWRITE_DNS` | `true` | Rewrite `/etc/resolv.conf` |
|
||||
| `RUST_LOG` | `info` | FIPS log level |
|
||||
|
||||
### Multiple Peers
|
||||
|
||||
Use `FIPS_PEERS_JSON` to configure more than one peer:
|
||||
|
||||
```yaml
|
||||
- name: FIPS_PEERS_JSON
|
||||
value: |
|
||||
[
|
||||
{"npub":"npub1abc...","alias":"gw1","addr":"203.0.113.10:2121","transport":"udp"},
|
||||
{"npub":"npub1def...","alias":"gw2","addr":"198.51.100.5:2121","transport":"udp"}
|
||||
]
|
||||
```
|
||||
|
||||
Each object supports the keys: `npub` (required), `addr` (required),
|
||||
`alias`, `transport`, `priority`.
|
||||
|
||||
Alternatively, mount a hand-crafted `fips.yaml` and set `FIPS_NSEC` to
|
||||
anything (the mount takes precedence because the entrypoint writes to
|
||||
`/etc/fips/fips.yaml` which is then overridden by the volume):
|
||||
|
||||
```yaml
|
||||
volumes:
|
||||
- name: fips-config
|
||||
configMap:
|
||||
name: my-fips-config
|
||||
containers:
|
||||
- name: fips
|
||||
volumeMounts:
|
||||
- name: fips-config
|
||||
mountPath: /etc/fips/fips.yaml
|
||||
subPath: fips.yaml
|
||||
```
|
||||
|
||||
### JSON Parsing Limitations
|
||||
|
||||
`FIPS_PEERS_JSON` supports parsing with a simple awk-based parser. It handles common `host:port` addresses but does not support IPv6 addresses with brackets (e.g., `[::1]:2121`) or values containing embedded commas/colons. For complex configs, install `jq` in the container and modify the entrypoint, or use a mounted `fips.yaml`.
|
||||
|
||||
## Files
|
||||
|
||||
| File | Purpose |
|
||||
|---|---|
|
||||
| `Dockerfile` | Builds the sidecar image |
|
||||
| `entrypoint.sh` | Generates config, rewrites DNS, applies iptables, starts fips |
|
||||
| `pod.yaml` | Example Pod manifest (single app container + fips sidecar) |
|
||||
| `scripts/build.sh` | Compiles FIPS and builds the Docker image |
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**`FIPS_NSEC is required`** — The secret was not injected. Verify the
|
||||
`secretKeyRef` name and key match the secret you created with `kubectl
|
||||
create secret`.
|
||||
|
||||
**`fips0` not visible in app container** — Check that the fips sidecar
|
||||
started successfully: `kubectl logs <pod> -c fips`. The sidecar must start
|
||||
and run the daemon before `fips0` appears. Add a startup probe or
|
||||
`postStart` lifecycle hook to your app container if you need to wait.
|
||||
|
||||
**iptables errors** — The `NET_ADMIN` capability and `/dev/net/tun` volume
|
||||
mount are both required. Verify they are present in the Pod spec.
|
||||
|
||||
**`.fips` DNS not resolving** — Check that `FIPS_REWRITE_DNS=true` and that
|
||||
dnsmasq started: `kubectl exec <pod> -c fips -- pgrep dnsmasq`. Verify the
|
||||
FIPS DNS listener: `kubectl exec <pod> -c fips -- fipsctl show status`.
|
||||
|
||||
**CNI uses a different interface name** — Set `FIPS_POD_IFACE` to match your
|
||||
CNI's interface name (e.g. `ens3`, `net1` for Multus secondary interfaces).
|
||||
|
||||
**gVisor / kata-containers** — These sandboxed runtimes intercept syscalls
|
||||
and do not support `AF_PACKET` or `/dev/net/tun` in the same way as a
|
||||
standard kernel. Use a standard RuntimeClass for FIPS sidecar pods.
|
||||
309
examples/k8s-sidecar/entrypoint.sh
Executable file
309
examples/k8s-sidecar/entrypoint.sh
Executable file
@@ -0,0 +1,309 @@
|
||||
#!/bin/bash
|
||||
# FIPS Kubernetes sidecar entrypoint.
|
||||
#
|
||||
# Generates /etc/fips/fips.yaml from environment variables, rewrites the
|
||||
# pod's resolv.conf to route .fips DNS through dnsmasq, applies iptables
|
||||
# isolation rules so that the shared pod network namespace can only reach
|
||||
# the outside world via the FIPS mesh, then launches the FIPS daemon.
|
||||
#
|
||||
# Required environment variables:
|
||||
# FIPS_NSEC Node secret key (hex or nsec1 bech32)
|
||||
#
|
||||
# Optional environment variables (single peer shorthand):
|
||||
# FIPS_PEER_NPUB Peer's npub
|
||||
# FIPS_PEER_ADDR Peer's transport address (e.g. 203.0.113.10:2121)
|
||||
# FIPS_PEER_ALIAS Human-readable peer name (default: peer)
|
||||
# FIPS_PEER_TRANSPORT Transport type (default: udp)
|
||||
#
|
||||
# Multiple peers via JSON (overrides single-peer vars when set):
|
||||
# FIPS_PEERS_JSON JSON array of peer objects, e.g.:
|
||||
# '[{"npub":"npub1...","alias":"gw","addr":"1.2.3.4:2121","transport":"udp"}]'
|
||||
#
|
||||
# Transport / TUN tuning:
|
||||
# FIPS_UDP_BIND UDP bind address (default: 0.0.0.0:2121)
|
||||
# FIPS_UDP_PORT UDP transport port (derived from FIPS_UDP_BIND when unset)
|
||||
# FIPS_TCP_BIND TCP bind address (default: disabled)
|
||||
# FIPS_TUN_NAME TUN interface name (default: fips0)
|
||||
# FIPS_TUN_MTU TUN interface MTU (default: 1280, IPv6 minimum)
|
||||
#
|
||||
# Network isolation:
|
||||
# FIPS_ISOLATE Apply iptables isolation (default: false)
|
||||
# Set to "true" for mesh-only deployments where the app
|
||||
# must not communicate outside the FIPS mesh.
|
||||
# Set to "false" to add fips0 alongside normal cluster
|
||||
# networking without restricting eth0.
|
||||
# FIPS_POD_IFACE Pod network interface (default: eth0)
|
||||
# Adjust if your CNI uses a different name (e.g. ens3).
|
||||
#
|
||||
# DNS:
|
||||
# FIPS_REWRITE_DNS Rewrite /etc/resolv.conf (default: true)
|
||||
# Set to "false" if you manage DNS via another mechanism.
|
||||
#
|
||||
# Logging:
|
||||
# RUST_LOG FIPS log level (default: info)
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# IPv6 kernel configuration
|
||||
#
|
||||
# net.ipv6.conf.all.disable_ipv6, net.ipv6.conf.default.disable_ipv6, and
|
||||
# net.ipv6.bindv6only are set via the pod securityContext.sysctls field in
|
||||
# pod.yaml so that kubelet applies them before any container starts.
|
||||
# Nothing to do here.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Defaults
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
FIPS_NSEC="${FIPS_NSEC:?FIPS_NSEC is required — provide your node secret key}"
|
||||
|
||||
FIPS_UDP_BIND="${FIPS_UDP_BIND:-0.0.0.0:2121}"
|
||||
FIPS_TCP_BIND="${FIPS_TCP_BIND:-}"
|
||||
FIPS_TUN_NAME="${FIPS_TUN_NAME:-fips0}"
|
||||
FIPS_TUN_MTU="${FIPS_TUN_MTU:-1280}"
|
||||
FIPS_ISOLATE="${FIPS_ISOLATE:-false}"
|
||||
FIPS_POD_IFACE="${FIPS_POD_IFACE:-eth0}"
|
||||
FIPS_REWRITE_DNS="${FIPS_REWRITE_DNS:-true}"
|
||||
|
||||
FIPS_PEER_TRANSPORT="${FIPS_PEER_TRANSPORT:-udp}"
|
||||
FIPS_PEER_ALIAS="${FIPS_PEER_ALIAS:-peer}"
|
||||
|
||||
# Derive the UDP port for iptables rules from FIPS_UDP_BIND (last colon-field).
|
||||
FIPS_UDP_PORT="${FIPS_UDP_PORT:-${FIPS_UDP_BIND##*:}}"
|
||||
|
||||
mkdir -p /etc/fips
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Build peers YAML section
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
build_peers_yaml() {
|
||||
# FIPS_PEERS_JSON wins when set (supports multiple peers).
|
||||
if [ -n "${FIPS_PEERS_JSON:-}" ]; then
|
||||
# Parse the JSON array with awk — no jq required.
|
||||
# Expected element keys: npub, alias, addr, transport, priority
|
||||
#
|
||||
# Note: This parser handles simple host:port addresses only.
|
||||
# IPv6 addresses with brackets ([::1]:2121) or values containing
|
||||
# commas/colons may not parse correctly. Use single-peer env vars
|
||||
# for complex configs or install jq and replace this block.
|
||||
echo "$FIPS_PEERS_JSON" | awk '
|
||||
BEGIN { RS="}"; FS="," }
|
||||
{
|
||||
npub=""; alias="peer"; addr=""; transport="udp"; priority=100
|
||||
for (i=1; i<=NF; i++) {
|
||||
gsub(/[{}\[\] \t\n\r]/, "", $i)
|
||||
if ($i ~ /"npub"/) { split($i,a,":"); npub=a[2]; gsub(/"/,"",npub) }
|
||||
if ($i ~ /"alias"/) { split($i,a,":"); alias=a[2]; gsub(/"/,"",alias) }
|
||||
if ($i ~ /"addr"/) { n=split($i,a,":"); addr=a[2]":"a[3]; gsub(/"/,"",addr) }
|
||||
if ($i ~ /"transport"/) { split($i,a,":"); transport=a[2]; gsub(/"/,"",transport) }
|
||||
if ($i ~ /"priority"/) { split($i,a,":"); priority=a[2]; gsub(/"/,"",priority) }
|
||||
}
|
||||
if (npub != "" && addr != "") {
|
||||
printf " - npub: \"%s\"\n alias: \"%s\"\n addresses:\n - transport: %s\n addr: \"%s\"\n priority: %s\n connect_policy: auto_connect\n auto_reconnect: true\n", npub, alias, transport, addr, priority
|
||||
}
|
||||
}'
|
||||
return
|
||||
fi
|
||||
|
||||
# Single-peer shorthand via individual env vars.
|
||||
if [ -n "${FIPS_PEER_NPUB:-}" ] && [ -n "${FIPS_PEER_ADDR:-}" ]; then
|
||||
cat <<EOF
|
||||
- npub: "${FIPS_PEER_NPUB}"
|
||||
alias: "${FIPS_PEER_ALIAS}"
|
||||
addresses:
|
||||
- transport: ${FIPS_PEER_TRANSPORT}
|
||||
addr: "${FIPS_PEER_ADDR}"
|
||||
connect_policy: auto_connect
|
||||
auto_reconnect: true
|
||||
EOF
|
||||
return
|
||||
fi
|
||||
|
||||
# No peers configured — start standalone (useful for a new node joining
|
||||
# via discovery or when peers are added later via fipsctl).
|
||||
echo " []"
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Build optional TCP transport stanza
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
build_tcp_yaml() {
|
||||
if [ -n "$FIPS_TCP_BIND" ]; then
|
||||
cat <<EOF
|
||||
tcp:
|
||||
bind_addr: "${FIPS_TCP_BIND}"
|
||||
EOF
|
||||
fi
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Generate /etc/fips/fips.yaml
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
PEERS_YAML="$(build_peers_yaml)"
|
||||
TCP_YAML="$(build_tcp_yaml)"
|
||||
|
||||
cat > /etc/fips/fips.yaml <<EOF
|
||||
node:
|
||||
identity:
|
||||
nsec: "${FIPS_NSEC}"
|
||||
control:
|
||||
enabled: true
|
||||
socket_path: "/run/fips/control.sock"
|
||||
|
||||
tun:
|
||||
enabled: true
|
||||
name: ${FIPS_TUN_NAME}
|
||||
mtu: ${FIPS_TUN_MTU}
|
||||
|
||||
dns:
|
||||
enabled: true
|
||||
bind_addr: "127.0.0.1"
|
||||
port: 5354
|
||||
|
||||
transports:
|
||||
udp:
|
||||
bind_addr: "${FIPS_UDP_BIND}"
|
||||
${TCP_YAML}
|
||||
peers:
|
||||
${PEERS_YAML}
|
||||
EOF
|
||||
|
||||
echo "[fips-sidecar] Generated /etc/fips/fips.yaml"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Rewrite /etc/resolv.conf to route .fips DNS through dnsmasq
|
||||
#
|
||||
# Strategy:
|
||||
# 1. Harvest the nameservers the kubelet injected.
|
||||
# 2. Write dnsmasq upstream rules for each harvested nameserver.
|
||||
# 3. Point /etc/resolv.conf at 127.0.0.1 (dnsmasq).
|
||||
# 4. dnsmasq forwards .fips → FIPS daemon (127.0.0.1:5354),
|
||||
# everything else → original cluster DNS (typically 169.254.20.10 or
|
||||
# the kube-dns ClusterIP injected by kubelet).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
if [ "$FIPS_REWRITE_DNS" = "true" ]; then
|
||||
# Collect existing upstream nameservers before we overwrite resolv.conf.
|
||||
UPSTREAM_NS=()
|
||||
while IFS= read -r line; do
|
||||
if [[ "$line" =~ ^nameserver[[:space:]]+(.+)$ ]]; then
|
||||
ns="${BASH_REMATCH[1]}"
|
||||
# Skip loopback — we're about to replace it.
|
||||
if [[ "$ns" != "127."* ]] && [[ "$ns" != "::1" ]]; then
|
||||
UPSTREAM_NS+=("$ns")
|
||||
fi
|
||||
fi
|
||||
done < /etc/resolv.conf
|
||||
|
||||
# Append upstream server lines to dnsmasq fips config.
|
||||
# (The placeholder in the image has no upstream server= lines yet.)
|
||||
for ns in "${UPSTREAM_NS[@]}"; do
|
||||
echo "server=${ns}" >> /etc/dnsmasq.d/fips.conf
|
||||
done
|
||||
|
||||
# If no upstream was found fall back to Google (better than no resolution).
|
||||
if [ "${#UPSTREAM_NS[@]}" -eq 0 ]; then
|
||||
echo "[fips-sidecar] WARNING: no upstream nameserver found in /etc/resolv.conf; falling back to 8.8.8.8"
|
||||
echo "server=8.8.8.8" >> /etc/dnsmasq.d/fips.conf
|
||||
fi
|
||||
|
||||
# Preserve the search/domain lines so pod DNS short-names still work.
|
||||
SEARCH_LINE=""
|
||||
while IFS= read -r line; do
|
||||
if [[ "$line" =~ ^(search|domain)[[:space:]] ]]; then
|
||||
SEARCH_LINE="$line"
|
||||
break
|
||||
fi
|
||||
done < /etc/resolv.conf
|
||||
|
||||
{
|
||||
echo "nameserver 127.0.0.1"
|
||||
[ -n "$SEARCH_LINE" ] && echo "$SEARCH_LINE"
|
||||
} > /etc/resolv.conf
|
||||
|
||||
echo "[fips-sidecar] DNS rewritten: upstream ${UPSTREAM_NS[*]:-8.8.8.8} → dnsmasq → fips/cluster"
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# iptables isolation
|
||||
#
|
||||
# Goal: only FIPS transport traffic may leave/enter the pod via the physical
|
||||
# interface. All other pod-to-external traffic is dropped. Traffic on the
|
||||
# fips0 TUN and loopback is unrestricted. The app container sharing this
|
||||
# network namespace therefore can only communicate over the FIPS mesh.
|
||||
#
|
||||
# Skip when FIPS_ISOLATE=false (e.g. already handled by a NetworkPolicy,
|
||||
# or when the operator wants unrestricted egress alongside mesh traffic).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
if [ "$FIPS_ISOLATE" = "true" ]; then
|
||||
IFACE="$FIPS_POD_IFACE"
|
||||
UDP_PORT="$FIPS_UDP_PORT"
|
||||
|
||||
# IPv4: allow loopback + FIPS UDP transport; drop everything else on
|
||||
# the pod's physical interface.
|
||||
iptables -A OUTPUT -o lo -j ACCEPT
|
||||
iptables -A INPUT -i lo -j ACCEPT
|
||||
|
||||
iptables -A OUTPUT -o "$IFACE" -p udp --dport "$UDP_PORT" -j ACCEPT
|
||||
iptables -A OUTPUT -o "$IFACE" -p udp --sport "$UDP_PORT" -j ACCEPT
|
||||
iptables -A INPUT -i "$IFACE" -p udp --dport "$UDP_PORT" -j ACCEPT
|
||||
iptables -A INPUT -i "$IFACE" -p udp --sport "$UDP_PORT" -j ACCEPT
|
||||
|
||||
# Allow TCP transport port when configured.
|
||||
if [ -n "$FIPS_TCP_BIND" ]; then
|
||||
TCP_PORT="${FIPS_TCP_BIND##*:}"
|
||||
iptables -A OUTPUT -o "$IFACE" -p tcp --dport "$TCP_PORT" -j ACCEPT
|
||||
iptables -A INPUT -i "$IFACE" -p tcp --sport "$TCP_PORT" -j ACCEPT
|
||||
iptables -A OUTPUT -o "$IFACE" -p tcp --sport "$TCP_PORT" -j ACCEPT
|
||||
iptables -A INPUT -i "$IFACE" -p tcp --dport "$TCP_PORT" -j ACCEPT
|
||||
fi
|
||||
|
||||
# Allow established/related so existing connections aren't torn down mid-flight.
|
||||
iptables -A INPUT -i "$IFACE" -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT
|
||||
iptables -A OUTPUT -o "$IFACE" -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT
|
||||
|
||||
iptables -A OUTPUT -o "$IFACE" -j DROP
|
||||
iptables -A INPUT -i "$IFACE" -j DROP
|
||||
|
||||
# IPv6: allow loopback and fips TUN; block physical interface entirely.
|
||||
ip6tables -A OUTPUT -o lo -j ACCEPT
|
||||
ip6tables -A INPUT -i lo -j ACCEPT
|
||||
ip6tables -A OUTPUT -o "$FIPS_TUN_NAME" -j ACCEPT
|
||||
ip6tables -A INPUT -i "$FIPS_TUN_NAME" -j ACCEPT
|
||||
ip6tables -A OUTPUT -o "$IFACE" -j DROP
|
||||
ip6tables -A INPUT -i "$IFACE" -j DROP
|
||||
|
||||
echo "[fips-sidecar] iptables isolation applied on ${IFACE} (UDP ${UDP_PORT})"
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TCP MSS clamping on fips0
|
||||
#
|
||||
# The kernel derives MSS from the TUN MTU: 1280 - 60 = 1220. But FIPS adds
|
||||
# FIPS_IPV6_OVERHEAD (77) bytes of encapsulation that the kernel doesn't know
|
||||
# Clamp the MSS in TCP SYN packets traversing fips0 to the path MTU so that
|
||||
# segments fit within the effective MTU after FIPS encapsulation.
|
||||
# This is applied regardless of FIPS_ISOLATE mode since it's needed for
|
||||
# correct TCP negotiation even when normal network access is allowed.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
ip6tables -t mangle -A FORWARD -o "$FIPS_TUN_NAME" -p tcp --tcp-flags SYN,RST SYN -j TCPMSS --clamp-mss-to-pmtu
|
||||
ip6tables -t mangle -A FORWARD -i "$FIPS_TUN_NAME" -p tcp --tcp-flags SYN,RST SYN -j TCPMSS --clamp-mss-to-pmtu
|
||||
ip6tables -t mangle -A OUTPUT -o "$FIPS_TUN_NAME" -p tcp --tcp-flags SYN,RST SYN -j TCPMSS --clamp-mss-to-pmtu
|
||||
|
||||
echo "[fips-sidecar] TCP MSS clamped to PMTU on ${FIPS_TUN_NAME}"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Start dnsmasq and launch FIPS daemon
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
dnsmasq --conf-dir=/etc/dnsmasq.d
|
||||
echo "[fips-sidecar] dnsmasq started"
|
||||
|
||||
exec fips --config /etc/fips/fips.yaml
|
||||
207
examples/k8s-sidecar/pod.yaml
Normal file
207
examples/k8s-sidecar/pod.yaml
Normal file
@@ -0,0 +1,207 @@
|
||||
# FIPS Kubernetes sidecar — example Pod manifest
|
||||
#
|
||||
# WARNING: The Secret below contains a placeholder nsec. Replace it with your
|
||||
# real key before applying. Do NOT commit real keys to version control.
|
||||
# Generate a fresh key with: openssl rand -hex 32
|
||||
#
|
||||
# Apply:
|
||||
# kubectl apply -f pod.yaml
|
||||
#
|
||||
# Requirements on the node:
|
||||
# - /dev/net/tun must exist (standard on all modern Linux kernels)
|
||||
# - The container runtime must honour NET_ADMIN (true for containerd/CRI-O
|
||||
# with default settings; may require a RuntimeClass tweak for gVisor)
|
||||
#
|
||||
# Kubernetes version: 1.28+
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Secret — node identity
|
||||
# Replace the nsec value with your real key (hex or nsec1 bech32).
|
||||
# In production, manage this with a secrets operator (External Secrets,
|
||||
# Sealed Secrets, Vault Agent, etc.) rather than a plaintext manifest.
|
||||
# -----------------------------------------------------------------------------
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: fips-identity
|
||||
type: Opaque
|
||||
stringData:
|
||||
nsec: "replace-this-with-your-hex-or-nsec1-secret-key"
|
||||
|
||||
---
|
||||
|
||||
apiVersion: v1
|
||||
kind: Pod
|
||||
metadata:
|
||||
name: fips-sidecar-example
|
||||
labels:
|
||||
app: fips-sidecar-example
|
||||
spec:
|
||||
# Containers in the same Pod share the same network namespace automatically.
|
||||
# The fips sidecar creates fips0; the app container sees it immediately.
|
||||
#
|
||||
# Start order: initContainers run first, then all containers start
|
||||
# concurrently. If your app needs the TUN interface to be ready before it
|
||||
# starts, use a readiness probe on the sidecar and a dependency in the app
|
||||
# (or use a startup init container that waits for fips0 to appear).
|
||||
|
||||
# net.ipv6.* sysctls are namespaced per-pod in Linux. Declaring them here
|
||||
# allows the fips sidecar entrypoint to set them at runtime via sysctl(8).
|
||||
# bindv6only=0 means apps listening on 0.0.0.0 will also receive IPv6
|
||||
# connections arriving via fips0, without needing to be rewritten.
|
||||
# These are "unsafe" sysctls and require the kubelet --allowed-unsafe-sysctls
|
||||
# flag or a corresponding RuntimeClass / PodSecurityPolicy exception.
|
||||
securityContext:
|
||||
sysctls:
|
||||
- name: net.ipv6.conf.all.disable_ipv6
|
||||
value: "0"
|
||||
- name: net.ipv6.conf.default.disable_ipv6
|
||||
value: "0"
|
||||
- name: net.ipv6.bindv6only
|
||||
value: "0"
|
||||
|
||||
volumes:
|
||||
- name: tun-device
|
||||
hostPath:
|
||||
path: /dev/net/tun
|
||||
type: CharDevice
|
||||
# Optional: mount a pre-built fips.yaml instead of generating from env.
|
||||
# - name: fips-config
|
||||
# configMap:
|
||||
# name: fips-config
|
||||
|
||||
containers:
|
||||
# -----------------------------------------------------------------------
|
||||
# FIPS sidecar — owns the network namespace setup
|
||||
# -----------------------------------------------------------------------
|
||||
- name: fips
|
||||
image: your-registry.example.com/fips-sidecar:latest
|
||||
imagePullPolicy: Always
|
||||
|
||||
securityContext:
|
||||
capabilities:
|
||||
add:
|
||||
- NET_ADMIN # required: TUN creation and iptables
|
||||
runAsNonRoot: false # fips needs root for iptables/TUN
|
||||
runAsUser: 0
|
||||
|
||||
volumeMounts:
|
||||
- name: tun-device
|
||||
mountPath: /dev/net/tun
|
||||
|
||||
env:
|
||||
# --- Identity (required) ---
|
||||
- name: FIPS_NSEC
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: fips-identity
|
||||
key: nsec
|
||||
|
||||
# --- Single peer shorthand ---
|
||||
# Remove these and use FIPS_PEERS_JSON below for multiple peers.
|
||||
- name: FIPS_PEER_NPUB
|
||||
value: "" # e.g. npub1abc...
|
||||
- name: FIPS_PEER_ADDR
|
||||
value: "" # e.g. 203.0.113.10:2121
|
||||
- name: FIPS_PEER_ALIAS
|
||||
value: "gateway"
|
||||
- name: FIPS_PEER_TRANSPORT
|
||||
value: "udp"
|
||||
|
||||
# --- Multiple peers via JSON (overrides single-peer vars when non-empty) ---
|
||||
# - name: FIPS_PEERS_JSON
|
||||
# value: |
|
||||
# [
|
||||
# {"npub":"npub1abc...","alias":"gw1","addr":"203.0.113.10:2121","transport":"udp"},
|
||||
# {"npub":"npub1def...","alias":"gw2","addr":"198.51.100.5:2121","transport":"udp"}
|
||||
# ]
|
||||
|
||||
# --- Transport tuning ---
|
||||
- name: FIPS_UDP_BIND
|
||||
value: "0.0.0.0:2121"
|
||||
# Uncomment to enable TCP transport (useful on networks that block UDP):
|
||||
# - name: FIPS_TCP_BIND
|
||||
# value: "0.0.0.0:8443"
|
||||
|
||||
# --- TUN interface ---
|
||||
- name: FIPS_TUN_NAME
|
||||
value: "fips0"
|
||||
- name: FIPS_TUN_MTU
|
||||
value: "1280"
|
||||
|
||||
# --- Network isolation ---
|
||||
# "false" — fips0 is added alongside normal cluster networking. eth0
|
||||
# continues to work as usual (services, DNS, other pods all reachable).
|
||||
# "true" — eth0 is locked to FIPS transport only; app can only talk
|
||||
# via fips0. Use for high-security mesh-only deployments.
|
||||
- name: FIPS_ISOLATE
|
||||
value: "false"
|
||||
# Adjust if your CNI names the pod interface differently (e.g. ens3, net1).
|
||||
- name: FIPS_POD_IFACE
|
||||
value: "eth0"
|
||||
|
||||
# --- DNS ---
|
||||
# Set to "false" if you manage pod DNS externally.
|
||||
- name: FIPS_REWRITE_DNS
|
||||
value: "true"
|
||||
|
||||
# --- Logging ---
|
||||
- name: RUST_LOG
|
||||
value: "info"
|
||||
|
||||
ports:
|
||||
- name: fips-udp
|
||||
containerPort: 2121
|
||||
protocol: UDP
|
||||
# - name: fips-tcp
|
||||
# containerPort: 8443
|
||||
# protocol: TCP
|
||||
|
||||
readinessProbe:
|
||||
exec:
|
||||
command: ["fipsctl", "show", "status"]
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 10
|
||||
failureThreshold: 3
|
||||
|
||||
livenessProbe:
|
||||
exec:
|
||||
command: ["fipsctl", "show", "status"]
|
||||
initialDelaySeconds: 15
|
||||
periodSeconds: 30
|
||||
failureThreshold: 3
|
||||
|
||||
resources:
|
||||
requests:
|
||||
cpu: "50m"
|
||||
memory: "32Mi"
|
||||
limits:
|
||||
cpu: "500m"
|
||||
memory: "128Mi"
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# App container — shares the network namespace created by the fips sidecar
|
||||
# -----------------------------------------------------------------------
|
||||
- name: app
|
||||
image: your-app:latest
|
||||
imagePullPolicy: IfNotPresent
|
||||
|
||||
# No special capabilities needed — app uses fips0 like a normal interface.
|
||||
securityContext:
|
||||
runAsNonRoot: true
|
||||
|
||||
# The app sees: lo, eth0 (isolated by iptables), fips0 (mesh).
|
||||
# All outbound traffic from the app flows through fips0.
|
||||
# DNS resolution for <npub>.fips names works via dnsmasq on 127.0.0.1.
|
||||
command: ["sleep", "infinity"] # replace with your actual workload command
|
||||
|
||||
resources:
|
||||
requests:
|
||||
cpu: "100m"
|
||||
memory: "64Mi"
|
||||
limits:
|
||||
cpu: "1000m"
|
||||
memory: "512Mi"
|
||||
|
||||
# Restart policy for the Pod. Use "Always" for long-running workloads.
|
||||
restartPolicy: Always
|
||||
37
examples/k8s-sidecar/scripts/build.sh
Executable file
37
examples/k8s-sidecar/scripts/build.sh
Executable file
@@ -0,0 +1,37 @@
|
||||
#!/bin/bash
|
||||
# Build the FIPS Kubernetes sidecar Docker image.
|
||||
# The build happens entirely inside Docker (multi-arch friendly).
|
||||
# Usage: ./scripts/build.sh [--tag TAG] [-- <extra docker buildx args>]
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
DOCKER_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
PROJECT_ROOT="$(cd "$DOCKER_DIR/../.." && pwd)"
|
||||
|
||||
IMAGE_TAG="fips-k8s-sidecar:latest"
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--tag) IMAGE_TAG="$2"; shift 2 ;;
|
||||
--) shift; break ;;
|
||||
*) echo "Unknown option: $1" >&2; exit 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [ ! -f "$PROJECT_ROOT/Cargo.toml" ]; then
|
||||
echo "Error: Cannot find Cargo.toml at $PROJECT_ROOT" >&2
|
||||
echo "Expected layout: <project-root>/examples/k8s-sidecar/scripts/build.sh" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Building Docker image: $IMAGE_TAG"
|
||||
docker build \
|
||||
-t "$IMAGE_TAG" \
|
||||
-f "$DOCKER_DIR/Dockerfile" \
|
||||
"$@" \
|
||||
"$PROJECT_ROOT"
|
||||
|
||||
echo ""
|
||||
echo "Done. Image: $IMAGE_TAG"
|
||||
echo ""
|
||||
echo "Push to your registry, then apply the example manifest:"
|
||||
echo " kubectl apply -f examples/k8s-sidecar/pod.yaml"
|
||||
12
examples/sidecar-nostr-relay/.env
Normal file
12
examples/sidecar-nostr-relay/.env
Normal file
@@ -0,0 +1,12 @@
|
||||
# FIPS sidecar default configuration.
|
||||
# Override these values or create a .env.local file.
|
||||
|
||||
# Node identity — generate with: fipsctl keygen
|
||||
# Must be set before running: export FIPS_NSEC=<your-nsec>
|
||||
FIPS_NSEC=
|
||||
|
||||
# Peer configuration (leave empty for standalone operation)
|
||||
FIPS_PEER_NPUB=npub1qmc3cvfz0yu2hx96nq3gp55zdan2qclealn7xshgr448d3nh6lks7zel98
|
||||
FIPS_PEER_ADDR=217.77.8.91:2121
|
||||
FIPS_PEER_ALIAS=vps
|
||||
FIPS_PEER_TRANSPORT=udp
|
||||
3
examples/sidecar-nostr-relay/.gitignore
vendored
Normal file
3
examples/sidecar-nostr-relay/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
fips
|
||||
fipsctl
|
||||
fipstop
|
||||
@@ -2,11 +2,31 @@ FROM debian:trixie-slim
|
||||
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends \
|
||||
ca-certificates \
|
||||
iproute2 iputils-ping dnsutils dnsmasq iptables \
|
||||
openssh-client openssh-server python3 \
|
||||
tcpdump netcat-openbsd curl iperf3 && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install nak (Nostr Army Knife) — detect arch and download the correct binary.
|
||||
# Asset naming: nak-<version>-linux-<arch>
|
||||
RUN ARCH=$(dpkg --print-architecture) && \
|
||||
case "$ARCH" in \
|
||||
amd64) NAK_ARCH="linux-amd64" ;; \
|
||||
arm64) NAK_ARCH="linux-arm64" ;; \
|
||||
armhf) NAK_ARCH="linux-arm" ;; \
|
||||
*) echo "Unsupported arch: $ARCH" && exit 1 ;; \
|
||||
esac && \
|
||||
NAK_VERSION=$(curl -sSL --retry 3 \
|
||||
"https://api.github.com/repos/fiatjaf/nak/releases/latest" \
|
||||
| grep '"tag_name"' | head -1 | sed 's/.*"tag_name": *"\(.*\)".*/\1/') && \
|
||||
echo "Installing nak ${NAK_VERSION} for ${NAK_ARCH}" && \
|
||||
curl -sSL --retry 3 \
|
||||
"https://github.com/fiatjaf/nak/releases/download/${NAK_VERSION}/nak-${NAK_VERSION}-${NAK_ARCH}" \
|
||||
-o /usr/local/bin/nak && \
|
||||
chmod +x /usr/local/bin/nak && \
|
||||
nak --version
|
||||
|
||||
# Setup SSH server with no authentication (test only!)
|
||||
RUN mkdir -p /var/run/sshd && \
|
||||
ssh-keygen -A && \
|
||||
49
examples/sidecar-nostr-relay/Dockerfile.app
Normal file
49
examples/sidecar-nostr-relay/Dockerfile.app
Normal file
@@ -0,0 +1,49 @@
|
||||
# strfry: official multi-arch image (linux/amd64 + linux/arm64)
|
||||
# replaces the nostr-rs-relay source build which was amd64-only.
|
||||
#
|
||||
# IMPORTANT: use alpine (same musl-based libc as the strfry source image) as
|
||||
# the final stage. Copying the strfry binary into a glibc-based image such as
|
||||
# debian:bookworm-slim causes "not found" at exec time because the musl dynamic
|
||||
# linker (/lib/ld-musl-*.so.1) and its shared libraries are absent there.
|
||||
FROM ghcr.io/hoytech/strfry:latest AS strfry
|
||||
|
||||
FROM alpine:3.18
|
||||
|
||||
RUN apk add --no-cache nginx
|
||||
|
||||
# nginx: reverse proxy port 80 (IPv4 + IPv6) → strfry 127.0.0.1:7777
|
||||
RUN printf 'server {\n\
|
||||
listen 80;\n\
|
||||
listen [::]:80;\n\
|
||||
location / {\n\
|
||||
proxy_pass http://127.0.0.1:7777;\n\
|
||||
proxy_http_version 1.1;\n\
|
||||
proxy_read_timeout 1d;\n\
|
||||
proxy_send_timeout 1d;\n\
|
||||
proxy_set_header Upgrade $http_upgrade;\n\
|
||||
proxy_set_header Connection "Upgrade";\n\
|
||||
proxy_set_header Host $host;\n\
|
||||
}\n\
|
||||
}\n' > /etc/nginx/http.d/nostr-relay.conf && \
|
||||
rm -f /etc/nginx/http.d/default.conf
|
||||
|
||||
COPY --from=strfry /app/strfry /usr/local/bin/strfry
|
||||
# Copy musl shared libraries that strfry depends on from the source image.
|
||||
COPY --from=strfry \
|
||||
/usr/lib/liblmdb.so.0 \
|
||||
/usr/lib/libcrypto.so.50 \
|
||||
/usr/lib/libssl.so.53 \
|
||||
/usr/lib/libsecp256k1.so.2 \
|
||||
/usr/lib/libzstd.so.1 \
|
||||
/usr/lib/libstdc++.so.6 \
|
||||
/usr/lib/libgcc_s.so.1 \
|
||||
/usr/lib/
|
||||
COPY --from=strfry /lib/libz.so.1 /lib/
|
||||
|
||||
WORKDIR /usr/src/app
|
||||
RUN mkdir -p strfry-db
|
||||
|
||||
RUN printf '#!/bin/sh\nnginx\nexec strfry relay\n' > /entrypoint-app.sh && \
|
||||
chmod +x /entrypoint-app.sh
|
||||
|
||||
ENTRYPOINT ["/entrypoint-app.sh"]
|
||||
289
examples/sidecar-nostr-relay/README.md
Normal file
289
examples/sidecar-nostr-relay/README.md
Normal file
@@ -0,0 +1,289 @@
|
||||
# FIPS Nostr Relay Sidecar
|
||||
|
||||
Runs a [strfry](https://github.com/hoytech/strfry) Nostr relay reachable
|
||||
exclusively over the FIPS mesh. The relay container shares the FIPS
|
||||
sidecar's network namespace and is isolated from the host network by
|
||||
iptables — it can only be reached via the node's `.fips` name.
|
||||
|
||||
## How to Run
|
||||
|
||||
### 1. Build the FIPS binaries
|
||||
|
||||
From the repo root, compile FIPS for Linux and copy the binaries into the
|
||||
Docker build context:
|
||||
|
||||
```bash
|
||||
cd examples/sidecar-nostr-relay
|
||||
./scripts/build.sh
|
||||
```
|
||||
|
||||
Cross-compilation from macOS is supported via `cargo-zigbuild`.
|
||||
|
||||
### 2. Set your node identity
|
||||
|
||||
The relay needs a unique FIPS identity. Generate one with:
|
||||
|
||||
```bash
|
||||
fipsctl keygen
|
||||
```
|
||||
|
||||
Then set it in `.env`:
|
||||
|
||||
```bash
|
||||
# .env
|
||||
FIPS_NSEC=nsec1... # paste your nsec here
|
||||
```
|
||||
|
||||
`FIPS_NSEC` is required — the container will refuse to start without it.
|
||||
|
||||
### 3. Start the stack
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
This starts two containers that share a network namespace:
|
||||
|
||||
- **fips** — FIPS daemon + dnsmasq. Owns the namespace, creates `fips0`.
|
||||
- **app** — strfry relay + nginx. Joins the namespace via `network_mode: service:fips`.
|
||||
|
||||
### 4. Verify
|
||||
|
||||
```bash
|
||||
# FIPS node is up and has a mesh address:
|
||||
docker exec sidecar-nostr-relay-fips-1 fipsctl show status
|
||||
|
||||
# Relay is listening (should show nginx on :80 and strfry on :7777):
|
||||
docker exec sidecar-nostr-relay-fips-1 ss -tlnp
|
||||
|
||||
# Peer link to the public node is established:
|
||||
docker exec sidecar-nostr-relay-fips-1 fipsctl show peers
|
||||
|
||||
# Or check the logs:
|
||||
docker compose logs -f
|
||||
```
|
||||
|
||||
### 5. Connect to the relay
|
||||
|
||||
Your node's npub (and therefore its `.fips` name) is derived from its keypair:
|
||||
|
||||
```bash
|
||||
docker exec sidecar-nostr-relay-fips-1 fipsctl show status
|
||||
```
|
||||
|
||||
Connect from any FIPS-peered client using the node's npub:
|
||||
|
||||
```
|
||||
ws://npub1xxxx.fips:80
|
||||
```
|
||||
|
||||
## Security Model
|
||||
|
||||
The sidecar pattern enforces strict network isolation on the app container:
|
||||
|
||||
- **No IPv4 access**: iptables blocks all eth0 traffic except FIPS UDP
|
||||
transport (port 2121) and TCP transport (port 443). The app container
|
||||
cannot reach the Docker bridge, the host network, or any IPv4 address.
|
||||
- **No IPv6 on eth0**: ip6tables blocks all IPv6 traffic on eth0. The app
|
||||
container cannot use link-local or any Docker-assigned IPv6 addresses.
|
||||
- **FIPS mesh only**: The only routable network path is through `fips0`
|
||||
(`fd::/8`). All application traffic traverses the FIPS mesh with
|
||||
end-to-end encryption.
|
||||
- **Loopback allowed**: `lo` is unrestricted for inter-process communication
|
||||
within the shared namespace.
|
||||
|
||||
This means the app container treats the FIPS mesh as its sole network. Even
|
||||
if the application is compromised, it cannot bypass the mesh or communicate
|
||||
with the transport layer directly.
|
||||
|
||||
## Architecture
|
||||
|
||||
```text
|
||||
┌───────────────────────────────────────────────────┐
|
||||
│ Shared network namespace │
|
||||
│ │
|
||||
│ ┌───────────────┐ ┌──────────────────────────┐ │
|
||||
│ │ fips-sidecar │ │ fips-app │ │
|
||||
│ │ │ │ │ │
|
||||
│ │ fips daemon │ │ your workload │ │
|
||||
│ │ fipsctl │ │ │ │
|
||||
│ │ dnsmasq │ │ │ │
|
||||
│ └───────────────┘ └──────────────────────────┘ │
|
||||
│ │
|
||||
│ Interfaces: │
|
||||
│ lo — loopback (unrestricted) │
|
||||
│ eth0 — Docker bridge (iptables: FIPS only) │
|
||||
│ fips0 — FIPS TUN (fd::/8, unrestricted) │
|
||||
└───────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
The FIPS sidecar owns the network namespace and creates the `fips0` TUN
|
||||
interface. The app container joins via `network_mode: service:fips` and
|
||||
sees the same interfaces. The entrypoint script applies iptables rules
|
||||
before launching the FIPS daemon:
|
||||
|
||||
**IPv4 rules** (iptables):
|
||||
|
||||
- ACCEPT on `lo` (both directions)
|
||||
- ACCEPT UDP sport/dport 2121 on `eth0` (FIPS UDP transport)
|
||||
- ACCEPT TCP dport 443 / sport 443 on `eth0` (FIPS TCP transport)
|
||||
- DROP everything else on `eth0`
|
||||
|
||||
**IPv6 rules** (ip6tables):
|
||||
|
||||
- ACCEPT on `lo` (both directions)
|
||||
- ACCEPT on `fips0` (both directions)
|
||||
- DROP everything on `eth0`
|
||||
|
||||
### DNS Resolution
|
||||
|
||||
DNS inside the container is handled by dnsmasq (127.0.0.1:53):
|
||||
|
||||
- `.fips` queries are forwarded to the FIPS daemon's built-in DNS resolver
|
||||
(127.0.0.1:5354), which resolves npub-based names to `fd::/8` addresses
|
||||
- All other queries are forwarded to Docker's embedded DNS (127.0.0.11)
|
||||
|
||||
The `resolv.conf` mount points the container's resolver at 127.0.0.1,
|
||||
where dnsmasq handles the routing.
|
||||
|
||||
## Build
|
||||
|
||||
```bash
|
||||
cd examples/sidecar-nostr-relay
|
||||
./scripts/build.sh
|
||||
```
|
||||
|
||||
This compiles FIPS for Linux, copies the binaries into the Docker context,
|
||||
and builds the sidecar and app images. Cross-compilation from macOS is
|
||||
supported via `cargo-zigbuild`.
|
||||
|
||||
## Run with Peers
|
||||
|
||||
To connect the sidecar to an existing mesh, provide the peer's npub and
|
||||
transport address:
|
||||
|
||||
```bash
|
||||
FIPS_PEER_NPUB=npub1... \
|
||||
FIPS_PEER_ADDR=203.0.113.10:2121 \
|
||||
FIPS_PEER_ALIAS=gateway \
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Verify the peer link:
|
||||
|
||||
```bash
|
||||
docker exec sidecar-nostr-relay-fips-1 fipsctl show peers
|
||||
docker exec sidecar-nostr-relay-fips-1 fipsctl show links
|
||||
```
|
||||
|
||||
## Verify Connectivity and Isolation
|
||||
|
||||
From the app container:
|
||||
|
||||
```bash
|
||||
# Ping a mesh node by npub (resolves via .fips DNS):
|
||||
docker exec sidecar-nostr-relay-app-1 ping6 -c3 npub1xxxx.fips
|
||||
|
||||
# Fetch a web page from a mesh node over FIPS:
|
||||
docker exec sidecar-nostr-relay-app-1 curl -6 "http://npub1xxxx.fips:8000/"
|
||||
|
||||
# Docker bridge is blocked — this should fail:
|
||||
docker exec sidecar-nostr-relay-app-1 ping -c1 -W2 172.20.0.13
|
||||
|
||||
# Loopback is allowed:
|
||||
docker exec sidecar-nostr-relay-app-1 ping -c1 127.0.0.1
|
||||
```
|
||||
|
||||
## Environment Variables
|
||||
|
||||
| Variable | Default | Description |
|
||||
|---|---|---|
|
||||
| `FIPS_NSEC` | *(required)* | Node secret key (hex or nsec1 bech32) |
|
||||
| `FIPS_PEER_NPUB` | *(empty)* | Peer's npub to connect to |
|
||||
| `FIPS_PEER_ADDR` | *(empty)* | Peer's transport address (e.g. `203.0.113.10:2121`) |
|
||||
| `FIPS_PEER_ALIAS` | `peer` | Human-readable peer name |
|
||||
| `FIPS_UDP_BIND` | `0.0.0.0:2121` | UDP transport bind address |
|
||||
| `FIPS_TCP_BIND` | `0.0.0.0:8443` | TCP transport bind address |
|
||||
| `FIPS_PEER_TRANSPORT` | `udp` | Peer transport type (`udp` or `tcp`) |
|
||||
| `FIPS_TUN_MTU` | `1280` | TUN interface MTU |
|
||||
| `FIPS_NETWORK` | `fips-sidecar-net` | Docker network name (set to join external network) |
|
||||
| `FIPS_SUBNET` | `172.20.1.0/24` | Docker network subnet |
|
||||
| `FIPS_IPV4` | `172.20.1.20` | Sidecar's IPv4 address on the Docker network |
|
||||
| `RUST_LOG` | `info` | FIPS log level |
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**`FIPS_NSEC is required`** — The `FIPS_NSEC` environment variable is not
|
||||
set. Either add it to `.env` or pass it on the command line. Generate a
|
||||
random key with: `openssl rand -hex 32`
|
||||
|
||||
**`fips0` interface not appearing** — The FIPS daemon needs `/dev/net/tun`
|
||||
and `NET_ADMIN` capability. Check that the compose file includes both:
|
||||
|
||||
```yaml
|
||||
cap_add:
|
||||
- NET_ADMIN
|
||||
devices:
|
||||
- /dev/net/tun:/dev/net/tun
|
||||
```
|
||||
|
||||
**No peer connection established** — Verify the peer address is reachable
|
||||
from the sidecar container (`docker exec sidecar-nostr-relay-fips-1 ping -c1 <peer-ip>`).
|
||||
If joining an external Docker network, ensure `FIPS_NETWORK`, `FIPS_SUBNET`,
|
||||
and `FIPS_IPV4` match the target network. Check logs with
|
||||
`docker logs sidecar-nostr-relay-fips-1`.
|
||||
|
||||
**DNS not resolving `.fips` names** — Verify dnsmasq is running:
|
||||
`docker exec sidecar-nostr-relay-fips-1 pgrep dnsmasq`. Check that `resolv.conf` is
|
||||
mounted (should contain `nameserver 127.0.0.1`). Verify the FIPS DNS
|
||||
resolver is listening: `docker exec sidecar-nostr-relay-fips-1 dig @127.0.0.1 -p 5354 <npub>.fips AAAA`.
|
||||
|
||||
**iptables errors in entrypoint** — The sidecar container requires
|
||||
`NET_ADMIN` capability for iptables. Without it, the isolation rules
|
||||
cannot be applied and the entrypoint will fail.
|
||||
|
||||
## Production Considerations
|
||||
|
||||
**Secrets management**: The default `.env` contains a hardcoded nsec for
|
||||
development. In production, use Docker secrets, a vault, or inject the key
|
||||
via a secure CI/CD pipeline. Never commit production keys to version control.
|
||||
|
||||
**Logging**: Set `RUST_LOG` to control log verbosity (`debug`, `info`,
|
||||
`warn`, `error`). For production, configure the Docker logging driver with
|
||||
size limits:
|
||||
|
||||
```yaml
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
```
|
||||
|
||||
**Resource limits**: Add memory and CPU constraints in the compose file:
|
||||
|
||||
```yaml
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 256M
|
||||
cpus: "0.5"
|
||||
```
|
||||
|
||||
**Multiple peers**: The entrypoint supports a single peer via environment
|
||||
variables. For multiple peers, mount a custom `fips.yaml` directly:
|
||||
|
||||
```yaml
|
||||
volumes:
|
||||
- ./my-fips.yaml:/etc/fips/fips.yaml:ro
|
||||
```
|
||||
|
||||
**Health checks**: Add a Docker health check using `fipsctl`:
|
||||
|
||||
```yaml
|
||||
healthcheck:
|
||||
test: ["CMD", "fipsctl", "show", "status"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
```
|
||||
@@ -0,0 +1,6 @@
|
||||
# Override: use a pre-existing external network instead of creating one.
|
||||
# Used by test-sidecar.sh for multi-instance testing on a shared network.
|
||||
networks:
|
||||
fips-net:
|
||||
external: true
|
||||
name: ${FIPS_NETWORK:-fips-sidecar-net}
|
||||
54
examples/sidecar-nostr-relay/docker-compose.yml
Normal file
54
examples/sidecar-nostr-relay/docker-compose.yml
Normal file
@@ -0,0 +1,54 @@
|
||||
networks:
|
||||
fips-net:
|
||||
name: ${FIPS_NETWORK:-fips-sidecar-net}
|
||||
driver: bridge
|
||||
ipam:
|
||||
config:
|
||||
- subnet: ${FIPS_SUBNET:-172.20.1.0/24}
|
||||
|
||||
services:
|
||||
fips:
|
||||
build:
|
||||
context: .
|
||||
hostname: fips-sidecar
|
||||
cap_add:
|
||||
- NET_ADMIN
|
||||
devices:
|
||||
- /dev/net/tun:/dev/net/tun
|
||||
sysctls:
|
||||
- net.ipv6.conf.all.disable_ipv6=0
|
||||
restart: "no"
|
||||
ports:
|
||||
- "2121:2121/udp" # FIPS UDP transport
|
||||
- "8443:8443/tcp" # FIPS TCP transport
|
||||
- "80:80/tcp" # Nostr relay WebSocket (via nginx)
|
||||
environment:
|
||||
- RUST_LOG=${RUST_LOG:-info}
|
||||
- FIPS_NSEC=${FIPS_NSEC}
|
||||
- FIPS_PEER_NPUB=${FIPS_PEER_NPUB:-}
|
||||
- FIPS_PEER_ADDR=${FIPS_PEER_ADDR:-}
|
||||
- FIPS_PEER_ALIAS=${FIPS_PEER_ALIAS:-peer}
|
||||
- FIPS_UDP_BIND=${FIPS_UDP_BIND:-0.0.0.0:2121}
|
||||
- FIPS_TUN_MTU=${FIPS_TUN_MTU:-1280}
|
||||
- FIPS_PEER_TRANSPORT=${FIPS_PEER_TRANSPORT:-udp}
|
||||
volumes:
|
||||
- ./resolv.conf:/etc/resolv.conf:ro
|
||||
networks:
|
||||
fips-net:
|
||||
ipv4_address: ${FIPS_IPV4:-172.20.1.20}
|
||||
|
||||
app:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.app
|
||||
network_mode: "service:fips"
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- fips
|
||||
volumes:
|
||||
- relay-data:/usr/src/app/strfry-db
|
||||
- ./relay/strfry.conf:/usr/src/app/strfry.conf:ro
|
||||
- ./resolv.conf:/etc/resolv.conf:ro
|
||||
|
||||
volumes:
|
||||
relay-data:
|
||||
@@ -6,6 +6,7 @@ set -e
|
||||
|
||||
FIPS_NSEC="${FIPS_NSEC:?FIPS_NSEC is required}"
|
||||
FIPS_UDP_BIND="${FIPS_UDP_BIND:-0.0.0.0:2121}"
|
||||
FIPS_TCP_BIND="${FIPS_TCP_BIND:-0.0.0.0:8443}"
|
||||
FIPS_TUN_MTU="${FIPS_TUN_MTU:-1280}"
|
||||
FIPS_PEER_TRANSPORT="${FIPS_PEER_TRANSPORT:-udp}"
|
||||
|
||||
@@ -41,7 +42,8 @@ transports:
|
||||
udp:
|
||||
bind_addr: "${FIPS_UDP_BIND}"
|
||||
mtu: 1472
|
||||
tcp: {}
|
||||
tcp:
|
||||
bind_addr: "${FIPS_TCP_BIND}"
|
||||
|
||||
peers:
|
||||
${PEERS_SECTION:- []}
|
||||
49
examples/sidecar-nostr-relay/relay/config.toml
Normal file
49
examples/sidecar-nostr-relay/relay/config.toml
Normal file
@@ -0,0 +1,49 @@
|
||||
# nostr-rs-relay configuration for FIPS mesh deployment.
|
||||
# Full reference: https://github.com/scsibug/nostr-rs-relay
|
||||
|
||||
[info]
|
||||
# Shown in NIP-11 relay information document.
|
||||
relay_url = "" # Set to ws://[<your-fips-ipv6>]:8080 after first start
|
||||
name = "FIPS Nostr Relay"
|
||||
description = "A Nostr relay accessible over the FIPS mesh network."
|
||||
pubkey = "" # Optional: your npub (hex) as relay operator
|
||||
contact = "" # Optional: contact address
|
||||
|
||||
[database]
|
||||
# SQLite database path inside the container (mapped to relay-data volume).
|
||||
data_directory = "/usr/src/app/db"
|
||||
|
||||
[network]
|
||||
# Bind on all interfaces (IPv4 + IPv6) — FIPS delivers traffic via the fips0
|
||||
# TUN as IPv6 (fd00::/8). Using 0.0.0.0 with port 8080; the relay also needs
|
||||
# to accept IPv6 connections so we set port separately and rely on the OS
|
||||
# dual-stack socket (IPV6_V6ONLY=0).
|
||||
# nostr-rs-relay address field is just the IP, port is separate.
|
||||
address = "0.0.0.0"
|
||||
port = 8080
|
||||
ping_interval_seconds = 300
|
||||
|
||||
[limits]
|
||||
# Adjust to taste. These are conservative defaults for a personal relay.
|
||||
messages_per_sec = 10
|
||||
subscriptions_per_min = 20
|
||||
max_blocking_threads = 4
|
||||
max_event_bytes = 131072 # 128 KiB per event
|
||||
max_ws_message_bytes = 131072
|
||||
max_ws_frame_bytes = 131072
|
||||
broadcast_buffer = 16384
|
||||
event_persist_buffer = 4096
|
||||
|
||||
[authorization]
|
||||
# Set to true to require NIP-42 AUTH before accepting events.
|
||||
# Useful for a private relay — only authenticated users can publish.
|
||||
nip42_auth = false
|
||||
nip42_dms = false
|
||||
|
||||
[verified_users]
|
||||
# NIP-05 verification (optional).
|
||||
mode = "disabled"
|
||||
|
||||
[pay_to_relay]
|
||||
# Lightning payments for relay access (optional).
|
||||
enabled = false
|
||||
71
examples/sidecar-nostr-relay/relay/strfry.conf
Normal file
71
examples/sidecar-nostr-relay/relay/strfry.conf
Normal file
@@ -0,0 +1,71 @@
|
||||
##
|
||||
## strfry configuration for FIPS mesh deployment.
|
||||
## Full reference: https://github.com/hoytech/strfry
|
||||
##
|
||||
|
||||
db = "/usr/src/app/strfry-db/"
|
||||
|
||||
dbParams {
|
||||
# Maximum size of the database (bytes). 10 GiB is a safe default.
|
||||
mapsize = 10737418240
|
||||
}
|
||||
|
||||
relay {
|
||||
# Bind on all interfaces so the FIPS TUN (IPv4 + IPv6) can reach it.
|
||||
bind = "0.0.0.0"
|
||||
port = 7777
|
||||
|
||||
nofiles = 0
|
||||
|
||||
info {
|
||||
name = "FIPS Nostr Relay"
|
||||
description = "A Nostr relay accessible over the FIPS mesh network."
|
||||
pubkey = ""
|
||||
contact = ""
|
||||
}
|
||||
|
||||
# Maximum size of an inbound WebSocket message (bytes).
|
||||
maxWebsocketPayloadSize = 131072
|
||||
|
||||
# Send a ping every N seconds to keep connections alive.
|
||||
autoPingSeconds = 55
|
||||
|
||||
# Enable per-message compression.
|
||||
enableTcpNoDelay = false
|
||||
|
||||
rejectFutureEventsSeconds = 900
|
||||
rejectEphemeralEventsOlderThanSeconds = 60
|
||||
rejectEventsNewerThanSeconds = 900
|
||||
|
||||
maxFilterLimit = 500
|
||||
maxSubsPerConnection = 20
|
||||
|
||||
writePolicy {
|
||||
# Plugin executable for write-policy decisions (leave empty to allow all).
|
||||
plugin = ""
|
||||
}
|
||||
|
||||
compression {
|
||||
enabled = true
|
||||
slidingWindow = true
|
||||
}
|
||||
|
||||
logging {
|
||||
dumpInAll = false
|
||||
dumpInEvents = false
|
||||
dumpInReqs = false
|
||||
dbScanPerf = false
|
||||
}
|
||||
|
||||
numThreads {
|
||||
ingester = 3
|
||||
reqWorker = 3
|
||||
reqMonitor = 3
|
||||
negentropy = 2
|
||||
}
|
||||
|
||||
negentropy {
|
||||
enabled = true
|
||||
maxSyncEvents = 1000000
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,7 @@ DOCKER_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
PROJECT_ROOT="$(cd "$DOCKER_DIR/../.." && pwd)"
|
||||
if [ ! -f "$PROJECT_ROOT/Cargo.toml" ]; then
|
||||
echo "Error: Cannot find Cargo.toml at $PROJECT_ROOT" >&2
|
||||
echo "Expected layout: <project-root>/testing/sidecar/scripts/build.sh" >&2
|
||||
echo "Expected layout: <project-root>/examples/sidecar-nostr-relay/scripts/build.sh" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -54,4 +54,4 @@ echo ""
|
||||
echo "Building Docker image..."
|
||||
docker compose -f "$DOCKER_DIR/docker-compose.yml" build
|
||||
echo ""
|
||||
echo "Ready: cd testing/sidecar && docker compose up -d"
|
||||
echo "Ready: cd examples/sidecar-nostr-relay && docker compose up -d"
|
||||
31
packaging/Makefile
Normal file
31
packaging/Makefile
Normal file
@@ -0,0 +1,31 @@
|
||||
# FIPS Packaging Makefile
|
||||
#
|
||||
# Builds release packages for supported target platforms.
|
||||
# All outputs are placed in deploy/ at the project root.
|
||||
#
|
||||
# Usage:
|
||||
# make deb Build a Debian/Ubuntu .deb package
|
||||
# make tarball Build a systemd install tarball
|
||||
# make ipk Build an OpenWrt .ipk package
|
||||
# make all Build deb and tarball (default)
|
||||
# make clean Remove deploy/ directory
|
||||
|
||||
SHELL := /bin/bash
|
||||
PACKAGING_DIR := $(dir $(abspath $(lastword $(MAKEFILE_LIST))))
|
||||
PROJECT_ROOT := $(abspath $(PACKAGING_DIR)/..)
|
||||
|
||||
.PHONY: all deb tarball ipk clean
|
||||
|
||||
all: deb tarball
|
||||
|
||||
deb:
|
||||
@bash $(PACKAGING_DIR)/debian/build-deb.sh
|
||||
|
||||
tarball:
|
||||
@bash $(PACKAGING_DIR)/systemd/build-tarball.sh
|
||||
|
||||
ipk:
|
||||
@bash $(PACKAGING_DIR)/openwrt/build-ipk.sh
|
||||
|
||||
clean:
|
||||
rm -rf $(PROJECT_ROOT)/deploy
|
||||
86
packaging/README.md
Normal file
86
packaging/README.md
Normal file
@@ -0,0 +1,86 @@
|
||||
# FIPS Packaging
|
||||
|
||||
This directory contains packaging for all supported target platforms.
|
||||
All build outputs go to `deploy/` at the project root.
|
||||
|
||||
## Quick Start
|
||||
|
||||
```sh
|
||||
make deb # Debian/Ubuntu .deb
|
||||
make tarball # systemd install tarball
|
||||
make ipk # OpenWrt .ipk
|
||||
make all # deb + tarball (default)
|
||||
```
|
||||
|
||||
## Directory Structure
|
||||
|
||||
```text
|
||||
packaging/
|
||||
common/ Shared assets (default config, hosts file)
|
||||
debian/ Debian/Ubuntu .deb packaging via cargo-deb
|
||||
systemd/ Generic Linux systemd tarball packaging
|
||||
openwrt/ OpenWrt .ipk packaging via cargo-zigbuild
|
||||
```
|
||||
|
||||
## Formats
|
||||
|
||||
### Debian/Ubuntu (`.deb`)
|
||||
|
||||
Built with [cargo-deb](https://github.com/kornelski/cargo-deb). Installs
|
||||
`fips`, `fipsctl`, and `fipstop` to `/usr/bin/`, places config at
|
||||
`/etc/fips/fips.yaml` (preserved on upgrade), and enables the systemd
|
||||
service.
|
||||
|
||||
```sh
|
||||
# Build
|
||||
make deb
|
||||
|
||||
# Install
|
||||
sudo dpkg -i deploy/fips_<version>_<arch>.deb
|
||||
|
||||
# Remove (preserves config and keys)
|
||||
sudo dpkg -r fips
|
||||
|
||||
# Purge (removes config and identity keys)
|
||||
sudo dpkg -P fips
|
||||
```
|
||||
|
||||
### systemd Tarball
|
||||
|
||||
A self-contained tarball with binaries and an `install.sh` script for
|
||||
any systemd-based Linux distribution.
|
||||
|
||||
```sh
|
||||
# Build
|
||||
make tarball
|
||||
|
||||
# Install (on target host)
|
||||
tar -xzf deploy/fips-<version>-linux-<arch>.tar.gz
|
||||
sudo ./fips-<version>-linux-<arch>/install.sh
|
||||
```
|
||||
|
||||
See [systemd/README.install.md](systemd/README.install.md) for full
|
||||
installation and configuration instructions.
|
||||
|
||||
### OpenWrt (`.ipk`)
|
||||
|
||||
Cross-compiled with cargo-zigbuild and assembled as a standard `.ipk`
|
||||
archive. Supports aarch64, mipsel, mips, arm, and x86\_64 targets.
|
||||
|
||||
```sh
|
||||
# Build (default: aarch64)
|
||||
make ipk
|
||||
|
||||
# Build for a specific architecture
|
||||
bash packaging/openwrt/build-ipk.sh --arch mipsel
|
||||
```
|
||||
|
||||
See [openwrt/README.md](openwrt/README.md) for router-specific
|
||||
installation instructions.
|
||||
|
||||
## Shared Assets
|
||||
|
||||
`common/` contains assets used across packaging formats:
|
||||
|
||||
- `fips.yaml` — default configuration (ephemeral identity, UDP/TCP/TUN/DNS)
|
||||
- `hosts` — static hostname-to-npub mappings for `.fips` DNS resolution
|
||||
@@ -43,5 +43,5 @@ peers: []
|
||||
# alias: "gateway"
|
||||
# addresses:
|
||||
# - transport: udp
|
||||
# addr: "217.77.8.91:2121" # public FIPS testing node
|
||||
# addr: "217.77.8.91:2121" # IP or hostname (e.g., "peer.example.com:2121")
|
||||
# connect_policy: auto_connect
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
#!/usr/bin/env bash
|
||||
# Build a .deb package for FIPS using cargo-deb.
|
||||
#
|
||||
# Usage: ./build-deb.sh
|
||||
# Usage: ./build-deb.sh [--target <triple>] [--version <version>] [--no-build]
|
||||
#
|
||||
# Prerequisites: cargo-deb (install with: cargo install cargo-deb)
|
||||
# Output: deploy/fips_<version>_<arch>.deb
|
||||
@@ -11,6 +11,48 @@ set -euo pipefail
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
PROJECT_ROOT="${SCRIPT_DIR}/../.."
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage: packaging/debian/build-deb.sh [options]
|
||||
|
||||
Options:
|
||||
--target <triple> Rust target triple to build/package
|
||||
--version <version> Override Debian package version
|
||||
--no-build Package existing binaries without running cargo build
|
||||
-h, --help Show this help
|
||||
EOF
|
||||
}
|
||||
|
||||
TARGET_TRIPLE=""
|
||||
VERSION_OVERRIDE=""
|
||||
NO_BUILD=0
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--target)
|
||||
TARGET_TRIPLE="${2:?missing value for --target}"
|
||||
shift 2
|
||||
;;
|
||||
--version)
|
||||
VERSION_OVERRIDE="${2:?missing value for --version}"
|
||||
shift 2
|
||||
;;
|
||||
--no-build)
|
||||
NO_BUILD=1
|
||||
shift
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "Unknown option: $1" >&2
|
||||
usage >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
cd "${PROJECT_ROOT}"
|
||||
|
||||
# Ensure cargo-deb is available
|
||||
@@ -19,16 +61,34 @@ if ! command -v cargo-deb &>/dev/null; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Derive SOURCE_DATE_EPOCH from git if not already set (reproducible builds)
|
||||
if [ -z "${SOURCE_DATE_EPOCH:-}" ]; then
|
||||
export SOURCE_DATE_EPOCH=$(git log -1 --format=%ct)
|
||||
fi
|
||||
|
||||
# Build the .deb package
|
||||
echo "Building .deb package..."
|
||||
cargo deb
|
||||
OUTPUT_DIR="$(mktemp -d)"
|
||||
trap 'rm -rf "${OUTPUT_DIR}"' EXIT
|
||||
|
||||
cargo_args=(deb --output "${OUTPUT_DIR}")
|
||||
if [[ -n "${TARGET_TRIPLE}" ]]; then
|
||||
cargo_args+=(--target "${TARGET_TRIPLE}")
|
||||
fi
|
||||
if [[ -n "${VERSION_OVERRIDE}" ]]; then
|
||||
cargo_args+=(--deb-version "${VERSION_OVERRIDE}")
|
||||
fi
|
||||
if [[ "${NO_BUILD}" -eq 1 ]]; then
|
||||
cargo_args+=(--no-build)
|
||||
fi
|
||||
cargo "${cargo_args[@]}"
|
||||
|
||||
# Move output to deploy/
|
||||
mkdir -p deploy
|
||||
DEB_FILE=$(find target/debian -name '*.deb' -printf '%T@ %p\n' | sort -rn | head -1 | cut -d' ' -f2)
|
||||
DEB_FILE=$(find "${OUTPUT_DIR}" -maxdepth 1 -name '*.deb' -printf '%T@ %p\n' | sort -rn | head -1 | cut -d' ' -f2)
|
||||
|
||||
if [ -z "${DEB_FILE}" ]; then
|
||||
echo "Error: No .deb file found in target/debian/" >&2
|
||||
echo "Error: No .deb file found in ${OUTPUT_DIR}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
||||
@@ -10,11 +10,10 @@ Restart=on-failure
|
||||
RestartSec=5
|
||||
|
||||
# Logging: RUST_LOG controls verbosity.
|
||||
# Use "info" for production, "debug" for troubleshooting.
|
||||
Environment=RUST_LOG=info
|
||||
Environment=RUST_LOG=debug
|
||||
|
||||
# Control socket directory (/run/fips/).
|
||||
# Group-readable so 'fips' group members can use fipsctl/fipstop.
|
||||
# Group-accessible so 'fips' group members can use fipsctl/fipstop.
|
||||
RuntimeDirectory=fips
|
||||
RuntimeDirectoryMode=0750
|
||||
|
||||
|
||||
@@ -109,6 +109,8 @@ cd "$PROJECT_ROOT"
|
||||
cargo zigbuild \
|
||||
--release \
|
||||
--target "$RUST_TARGET" \
|
||||
--no-default-features \
|
||||
--features tui \
|
||||
--bin fips \
|
||||
--bin fipsctl \
|
||||
--bin fipstop
|
||||
@@ -244,7 +246,11 @@ fi
|
||||
ipk_tar() {
|
||||
# ipk_tar <output.tar.gz> <source-dir> [paths...]
|
||||
local out="$1" src="$2"; shift 2
|
||||
COPYFILE_DISABLE=1 "$TAR_CMD" $TAR_EXTRA_FLAGS -czf "$out" -C "$src" "$@"
|
||||
local mtime_flags=""
|
||||
if [ -n "${SOURCE_DATE_EPOCH:-}" ]; then
|
||||
mtime_flags="--mtime=@$SOURCE_DATE_EPOCH"
|
||||
fi
|
||||
COPYFILE_DISABLE=1 "$TAR_CMD" $TAR_EXTRA_FLAGS $mtime_flags -czf "$out" -C "$src" "$@"
|
||||
}
|
||||
|
||||
ipk_tar "$IPK_WORK/control.tar.gz" "$CONTROL_DIR" .
|
||||
@@ -61,4 +61,4 @@ peers: []
|
||||
# alias: "gateway"
|
||||
# addresses:
|
||||
# - transport: udp
|
||||
# addr: "1.2.3.4:2121"
|
||||
# addr: "1.2.3.4:2121" # IP or hostname
|
||||
@@ -72,7 +72,7 @@ peers:
|
||||
alias: "gateway"
|
||||
addresses:
|
||||
- transport: udp
|
||||
addr: "217.77.8.91:2121" # public FIPS testing node
|
||||
addr: "217.77.8.91:2121" # IP or hostname (e.g., "peer.example.com:2121")
|
||||
connect_policy: auto_connect
|
||||
```
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
#!/usr/bin/env bash
|
||||
# Build FIPS release binaries and create an install tarball.
|
||||
#
|
||||
# Usage: ./packaging/build-tarball.sh
|
||||
# Usage: ./packaging/build-tarball.sh [--target <triple>] [--version <version>] [--arch <arch>] [--no-build]
|
||||
# Output: deploy/fips-<version>-linux-<arch>.tar.gz
|
||||
|
||||
set -euo pipefail
|
||||
@@ -10,29 +10,108 @@ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
PACKAGING_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
||||
PROJECT_ROOT="$(cd "${PACKAGING_DIR}/.." && pwd)"
|
||||
|
||||
# Extract version from Cargo.toml
|
||||
VERSION=$(grep '^version' "${PROJECT_ROOT}/Cargo.toml" | head -1 | sed 's/.*"\(.*\)"/\1/')
|
||||
ARCH=$(uname -m)
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage: packaging/systemd/build-tarball.sh [options]
|
||||
|
||||
Options:
|
||||
--target <triple> Rust target triple to build/package
|
||||
--version <version> Override artifact version
|
||||
--arch <arch> Override artifact architecture name
|
||||
--no-build Package existing binaries without running cargo build
|
||||
-h, --help Show this help
|
||||
EOF
|
||||
}
|
||||
|
||||
target_to_arch() {
|
||||
local target="$1"
|
||||
printf '%s\n' "${target%%-*}"
|
||||
}
|
||||
|
||||
VERSION_OVERRIDE=""
|
||||
TARGET_TRIPLE=""
|
||||
ARCH_OVERRIDE=""
|
||||
NO_BUILD=0
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--target)
|
||||
TARGET_TRIPLE="${2:?missing value for --target}"
|
||||
shift 2
|
||||
;;
|
||||
--version)
|
||||
VERSION_OVERRIDE="${2:?missing value for --version}"
|
||||
shift 2
|
||||
;;
|
||||
--arch)
|
||||
ARCH_OVERRIDE="${2:?missing value for --arch}"
|
||||
shift 2
|
||||
;;
|
||||
--no-build)
|
||||
NO_BUILD=1
|
||||
shift
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "Unknown option: $1" >&2
|
||||
usage >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
VERSION="${VERSION_OVERRIDE:-$(grep '^version' "${PROJECT_ROOT}/Cargo.toml" | head -1 | sed 's/.*"\(.*\)"/\1/')}"
|
||||
if [[ -n "${ARCH_OVERRIDE}" ]]; then
|
||||
ARCH="${ARCH_OVERRIDE}"
|
||||
elif [[ -n "${TARGET_TRIPLE}" ]]; then
|
||||
ARCH="$(target_to_arch "${TARGET_TRIPLE}")"
|
||||
else
|
||||
ARCH="$(uname -m)"
|
||||
fi
|
||||
TARBALL_NAME="fips-${VERSION}-linux-${ARCH}"
|
||||
DEPLOY_DIR="${PROJECT_ROOT}/deploy"
|
||||
STAGING_DIR="${DEPLOY_DIR}/${TARBALL_NAME}"
|
||||
STRIP_BIN="${STRIP:-strip}"
|
||||
|
||||
if [[ -n "${TARGET_TRIPLE}" ]]; then
|
||||
BINARY_DIR="${PROJECT_ROOT}/target/${TARGET_TRIPLE}/release"
|
||||
else
|
||||
BINARY_DIR="${PROJECT_ROOT}/target/release"
|
||||
fi
|
||||
|
||||
echo "Building FIPS v${VERSION} for ${ARCH}..."
|
||||
|
||||
# Build release binaries (tui is a default feature, includes fipstop)
|
||||
cargo build --release --manifest-path="${PROJECT_ROOT}/Cargo.toml"
|
||||
if [[ "${NO_BUILD}" -eq 0 ]]; then
|
||||
cargo_args=(build --release --manifest-path="${PROJECT_ROOT}/Cargo.toml")
|
||||
if [[ -n "${TARGET_TRIPLE}" ]]; then
|
||||
cargo_args+=(--target "${TARGET_TRIPLE}")
|
||||
fi
|
||||
cargo "${cargo_args[@]}"
|
||||
fi
|
||||
|
||||
# Create staging directory
|
||||
rm -rf "${STAGING_DIR}"
|
||||
mkdir -p "${STAGING_DIR}"
|
||||
|
||||
# Copy binaries
|
||||
cp "${PROJECT_ROOT}/target/release/fips" "${STAGING_DIR}/"
|
||||
cp "${PROJECT_ROOT}/target/release/fipsctl" "${STAGING_DIR}/"
|
||||
cp "${PROJECT_ROOT}/target/release/fipstop" "${STAGING_DIR}/"
|
||||
for bin in fips fipsctl fipstop; do
|
||||
if [[ ! -f "${BINARY_DIR}/${bin}" ]]; then
|
||||
echo "Missing binary: ${BINARY_DIR}/${bin}" >&2
|
||||
exit 1
|
||||
fi
|
||||
cp "${BINARY_DIR}/${bin}" "${STAGING_DIR}/"
|
||||
done
|
||||
|
||||
# Strip binaries to reduce size
|
||||
strip "${STAGING_DIR}/fips" "${STAGING_DIR}/fipsctl" "${STAGING_DIR}/fipstop"
|
||||
if ! command -v "${STRIP_BIN}" &>/dev/null; then
|
||||
echo "Strip tool not found: ${STRIP_BIN}" >&2
|
||||
exit 1
|
||||
fi
|
||||
"${STRIP_BIN}" "${STAGING_DIR}/fips" "${STAGING_DIR}/fipsctl" "${STAGING_DIR}/fipstop"
|
||||
|
||||
# Copy packaging files
|
||||
cp "${SCRIPT_DIR}/install.sh" "${STAGING_DIR}/"
|
||||
@@ -45,9 +124,20 @@ cp "${SCRIPT_DIR}/README.install.md" "${STAGING_DIR}/"
|
||||
|
||||
chmod +x "${STAGING_DIR}/install.sh" "${STAGING_DIR}/uninstall.sh"
|
||||
|
||||
# Create tarball
|
||||
# Create tarball (reproducible: normalize timestamps, ownership, and sort order)
|
||||
cd "${DEPLOY_DIR}"
|
||||
tar czf "${TARBALL_NAME}.tar.gz" "${TARBALL_NAME}/"
|
||||
|
||||
# Default SOURCE_DATE_EPOCH to git commit timestamp if not set
|
||||
if [ -z "${SOURCE_DATE_EPOCH:-}" ]; then
|
||||
SOURCE_DATE_EPOCH=$(git -C "${PROJECT_ROOT}" log -1 --format=%ct)
|
||||
export SOURCE_DATE_EPOCH
|
||||
fi
|
||||
|
||||
TAR_REPRO_FLAGS="--mtime=@${SOURCE_DATE_EPOCH} --sort=name"
|
||||
if tar --version 2>/dev/null | grep -q 'GNU tar'; then
|
||||
TAR_REPRO_FLAGS="${TAR_REPRO_FLAGS} --numeric-owner --owner=0 --group=0"
|
||||
fi
|
||||
COPYFILE_DISABLE=1 tar ${TAR_REPRO_FLAGS} -czf "${TARBALL_NAME}.tar.gz" "${TARBALL_NAME}/"
|
||||
rm -rf "${STAGING_DIR}"
|
||||
|
||||
echo ""
|
||||
|
||||
@@ -10,11 +10,10 @@ Restart=on-failure
|
||||
RestartSec=5
|
||||
|
||||
# Logging: RUST_LOG controls verbosity.
|
||||
# Use "info" for production, "debug" for troubleshooting.
|
||||
Environment=RUST_LOG=info
|
||||
Environment=RUST_LOG=debug
|
||||
|
||||
# Control socket directory (/run/fips/).
|
||||
# Group-readable so 'fips' group members can use fipsctl/fipstop.
|
||||
# Group-accessible so 'fips' group members can use fipsctl/fipstop.
|
||||
RuntimeDirectory=fips
|
||||
RuntimeDirectoryMode=0750
|
||||
|
||||
|
||||
83
packaging/torrc.fips
Normal file
83
packaging/torrc.fips
Normal file
@@ -0,0 +1,83 @@
|
||||
### FIPS-specific Tor configuration ###
|
||||
###
|
||||
### Reference torrc for running Tor alongside the FIPS daemon.
|
||||
### Uses HiddenServiceDir (directory mode) for the onion service,
|
||||
### which enables Sandbox mode — the strongest single hardening option.
|
||||
###
|
||||
### Install: cp torrc.fips /etc/tor/torrc
|
||||
### Verify: tor --verify-config -f /etc/tor/torrc
|
||||
###
|
||||
### The corresponding fips.yaml transport section should be:
|
||||
###
|
||||
### transports:
|
||||
### tor:
|
||||
### mode: "directory"
|
||||
### directory_service:
|
||||
### hostname_file: "/var/lib/tor/fips_onion_service/hostname"
|
||||
### bind_addr: "127.0.0.1:8443"
|
||||
|
||||
## Identity
|
||||
DataDirectory /var/lib/tor
|
||||
User debian-tor
|
||||
|
||||
## SOCKS proxy (for outbound peer connections)
|
||||
## IsolateSOCKSAuth: username/password fields serve as circuit isolation
|
||||
## keys — each FIPS peer destination gets its own Tor circuit.
|
||||
## IsolateDestAddr + IsolateDestPort: additional isolation by destination.
|
||||
## IsolateSOCKSAuth: required — FIPS uses SOCKS5 username/password fields
|
||||
## as per-destination circuit isolation keys (not real authentication).
|
||||
##
|
||||
## SafeSocks 1 rejects SOCKS5 CONNECT to raw IP addresses. FIPS supports
|
||||
## DNS hostnames in peer addresses — when clearnet peers use hostnames,
|
||||
## they are passed through SOCKS5 for Tor-side resolution (no local DNS
|
||||
## leak). Enable SafeSocks 1 when all clearnet peers use hostnames or
|
||||
## when running .onion-only. Not enabled by default for compatibility
|
||||
## with IP-addressed peers.
|
||||
SocksPort 127.0.0.1:9050 IsolateSOCKSAuth
|
||||
#TestSocks 1 # Enable during development only
|
||||
|
||||
## Control access (Unix socket, not TCP — filesystem permission control)
|
||||
## Only needed if fips uses control_port mode. In directory mode, the
|
||||
## control socket is not required but can be useful for monitoring.
|
||||
ControlSocket /run/tor/control GroupWritable RelaxDirModeCheck
|
||||
ControlSocketsGroupWritable 1
|
||||
## Do NOT use ControlPort 9051 — TCP control ports are accessible to any
|
||||
## local process that authenticates. Use Unix socket only.
|
||||
|
||||
## Authentication
|
||||
CookieAuthentication 1
|
||||
CookieAuthFileGroupReadable 1
|
||||
CookieAuthFile /run/tor/control.authcookie
|
||||
|
||||
## Onion service (persistent, filesystem-managed by Tor)
|
||||
## Tor manages the key in HiddenServiceDir. FIPS reads the .onion
|
||||
## address from the hostname file at startup.
|
||||
HiddenServiceDir /var/lib/tor/fips_onion_service
|
||||
HiddenServicePort 8443 127.0.0.1:8443
|
||||
|
||||
## Onion service hardening
|
||||
HiddenServiceMaxStreams 100
|
||||
HiddenServiceMaxStreamsCloseCircuit 1
|
||||
|
||||
## Onion service DoS protection (Tor 0.4.8+)
|
||||
## Intro point rate limiting — prevents introduction cell floods.
|
||||
HiddenServiceEnableIntroDoSDefense 1
|
||||
HiddenServiceEnableIntroDoSRatePerSec 25
|
||||
HiddenServiceEnableIntroDoSBurstPerSec 200
|
||||
|
||||
## Proof-of-work defense — auto-scaling computational puzzle for clients.
|
||||
## Minimal cost to legitimate peers, expensive for flood attacks.
|
||||
HiddenServicePoWDefensesEnabled 1
|
||||
HiddenServicePoWQueueRate 250
|
||||
HiddenServicePoWQueueBurst 2500
|
||||
|
||||
## Security
|
||||
ExitRelay 0
|
||||
ExitPolicy reject *:*
|
||||
Sandbox 1
|
||||
VanguardsLiteEnabled 1
|
||||
ConnectionPadding 1
|
||||
SafeLogging 1
|
||||
|
||||
## Logging
|
||||
Log notice syslog
|
||||
3
rust-toolchain.toml
Normal file
3
rust-toolchain.toml
Normal file
@@ -0,0 +1,3 @@
|
||||
[toolchain]
|
||||
channel = "1.94.1"
|
||||
components = ["rustfmt"]
|
||||
1
rustfmt.toml
Normal file
1
rustfmt.toml
Normal file
@@ -0,0 +1 @@
|
||||
# Use stable defaults
|
||||
@@ -3,12 +3,12 @@
|
||||
//! Loads configuration and creates the top-level node instance.
|
||||
|
||||
use clap::Parser;
|
||||
use fips::config::{resolve_identity, IdentitySource};
|
||||
use fips::config::{IdentitySource, resolve_identity};
|
||||
use fips::version;
|
||||
use fips::{Config, Node};
|
||||
use std::path::PathBuf;
|
||||
use tracing::{error, info, warn, Level};
|
||||
use tracing_subscriber::{fmt, EnvFilter};
|
||||
use tracing::{Level, error, info, warn};
|
||||
use tracing_subscriber::{EnvFilter, fmt};
|
||||
|
||||
/// FIPS mesh network daemon
|
||||
#[derive(Parser, Debug)]
|
||||
@@ -31,10 +31,7 @@ async fn main() {
|
||||
.with_default_directive(Level::INFO.into())
|
||||
.from_env_lossy();
|
||||
|
||||
fmt()
|
||||
.with_env_filter(filter)
|
||||
.with_target(true)
|
||||
.init();
|
||||
fmt().with_env_filter(filter).with_target(true).init();
|
||||
|
||||
let args = Args::parse();
|
||||
|
||||
@@ -47,7 +44,11 @@ async fn main() {
|
||||
match Config::load_file(config_path) {
|
||||
Ok(config) => (config, vec![config_path.clone()]),
|
||||
Err(e) => {
|
||||
error!("Failed to load configuration from {}: {}", config_path.display(), e);
|
||||
error!(
|
||||
"Failed to load configuration from {}: {}",
|
||||
config_path.display(),
|
||||
e
|
||||
);
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
@@ -80,8 +81,12 @@ async fn main() {
|
||||
};
|
||||
match &resolved.source {
|
||||
IdentitySource::Config => info!("Using identity from configuration"),
|
||||
IdentitySource::KeyFile(p) => info!(path = %p.display(), "Loaded persistent identity from key file"),
|
||||
IdentitySource::Generated(p) => info!(path = %p.display(), "Generated persistent identity, saved to key file"),
|
||||
IdentitySource::KeyFile(p) => {
|
||||
info!(path = %p.display(), "Loaded persistent identity from key file")
|
||||
}
|
||||
IdentitySource::Generated(p) => {
|
||||
info!(path = %p.display(), "Generated persistent identity, saved to key file")
|
||||
}
|
||||
IdentitySource::Ephemeral => info!("Using ephemeral identity (new keypair each start)"),
|
||||
}
|
||||
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
//! fipsctl — FIPS control client
|
||||
//!
|
||||
//! Connects to the FIPS daemon's Unix domain control socket, sends a
|
||||
//! query command, and pretty-prints the JSON response.
|
||||
//! Connects to the FIPS daemon's Unix domain control socket, sends
|
||||
//! commands, and pretty-prints the JSON response.
|
||||
|
||||
use clap::{Parser, Subcommand};
|
||||
use fips::config::{write_key_file, write_pub_file};
|
||||
use fips::upper::hosts::HostMap;
|
||||
use fips::version;
|
||||
use fips::{encode_nsec, Identity};
|
||||
use fips::{Identity, encode_nsec};
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::net::{Ipv6Addr, SocketAddrV6};
|
||||
use std::os::unix::net::UnixStream;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::Duration;
|
||||
@@ -18,7 +20,7 @@ use std::time::Duration;
|
||||
name = "fipsctl",
|
||||
version = version::short_version(),
|
||||
long_version = version::long_version(),
|
||||
about = "Query a running FIPS daemon"
|
||||
about = "Control a running FIPS daemon"
|
||||
)]
|
||||
struct Cli {
|
||||
/// Control socket path override
|
||||
@@ -48,6 +50,20 @@ enum Commands {
|
||||
#[arg(short = 's', long = "stdout")]
|
||||
stdout: bool,
|
||||
},
|
||||
/// Connect to a peer
|
||||
Connect {
|
||||
/// Peer identifier: npub (bech32) or hostname from /etc/fips/hosts
|
||||
peer: String,
|
||||
/// Transport address (e.g., "192.168.1.1:2121")
|
||||
address: String,
|
||||
/// Transport type: udp, tcp, tor, ethernet
|
||||
transport: String,
|
||||
},
|
||||
/// Disconnect a peer
|
||||
Disconnect {
|
||||
/// Peer identifier: npub (bech32) or hostname from /etc/fips/hosts
|
||||
peer: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
@@ -98,8 +114,10 @@ impl ShowCommands {
|
||||
///
|
||||
/// Checks the system-wide path first (used when the daemon runs as a
|
||||
/// systemd service), then falls back to the user's XDG runtime directory.
|
||||
/// Uses directory existence rather than socket file existence so the check
|
||||
/// works even when the user lacks traverse permission on /run/fips/ (0750).
|
||||
fn default_socket_path() -> PathBuf {
|
||||
if Path::new("/run/fips/control.sock").exists() {
|
||||
if Path::new("/run/fips").exists() {
|
||||
PathBuf::from("/run/fips/control.sock")
|
||||
} else if let Ok(runtime_dir) = std::env::var("XDG_RUNTIME_DIR") {
|
||||
PathBuf::from(format!("{runtime_dir}/fips/control.sock"))
|
||||
@@ -108,123 +126,58 @@ fn default_socket_path() -> PathBuf {
|
||||
}
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let cli = Cli::parse();
|
||||
|
||||
// Commands that don't require a running daemon
|
||||
match &cli.command {
|
||||
Commands::Keygen {
|
||||
dir,
|
||||
force,
|
||||
stdout,
|
||||
} => {
|
||||
let identity = Identity::generate();
|
||||
let nsec = encode_nsec(&identity.keypair().secret_key());
|
||||
let npub = identity.npub();
|
||||
|
||||
if *stdout {
|
||||
println!("{}", nsec);
|
||||
println!("{}", npub);
|
||||
return;
|
||||
}
|
||||
|
||||
let key_path = dir.join("fips.key");
|
||||
let pub_path = dir.join("fips.pub");
|
||||
|
||||
if key_path.exists() && !force {
|
||||
eprintln!("error: key file already exists: {}", key_path.display());
|
||||
eprintln!("Use --force to overwrite.");
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
if let Err(e) = std::fs::create_dir_all(dir) {
|
||||
eprintln!("error: cannot create directory {}: {}", dir.display(), e);
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
if let Err(e) = write_key_file(&key_path, &nsec) {
|
||||
eprintln!("error: failed to write key file: {}", e);
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
if let Err(e) = write_pub_file(&pub_path, &npub) {
|
||||
eprintln!("error: failed to write pub file: {}", e);
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
eprintln!("{}", npub);
|
||||
eprintln!("Key files written to: {}/", dir.display());
|
||||
eprintln!();
|
||||
eprintln!("NOTE: Set 'node.identity.persistent: true' in fips.yaml");
|
||||
eprintln!(" or these keys will be overwritten on next daemon start.");
|
||||
return;
|
||||
}
|
||||
Commands::Show { .. } => {}
|
||||
}
|
||||
|
||||
let socket_path = cli.socket.unwrap_or_else(default_socket_path);
|
||||
let command_name = match &cli.command {
|
||||
Commands::Show { what } => what.command_name(),
|
||||
Commands::Keygen { .. } => unreachable!(),
|
||||
};
|
||||
|
||||
// Connect to the control socket
|
||||
let mut stream = match UnixStream::connect(&socket_path) {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
eprintln!(
|
||||
"error: cannot connect to {}: {}",
|
||||
/// Send a JSON request to the control socket and return the response.
|
||||
fn send_request(socket_path: &Path, request_json: &str) -> Result<serde_json::Value, String> {
|
||||
let mut stream = UnixStream::connect(socket_path).map_err(|e| {
|
||||
if e.kind() == std::io::ErrorKind::PermissionDenied {
|
||||
format!(
|
||||
"cannot connect to {}: {}\n\
|
||||
Hint: add your user to the 'fips' group: sudo usermod -aG fips $USER\n\
|
||||
Then log out and back in for the change to take effect.",
|
||||
socket_path.display(),
|
||||
e
|
||||
);
|
||||
if e.kind() == std::io::ErrorKind::PermissionDenied {
|
||||
eprintln!(
|
||||
"Hint: add your user to the 'fips' group: sudo usermod -aG fips $USER"
|
||||
);
|
||||
eprintln!("Then log out and back in for the change to take effect.");
|
||||
} else {
|
||||
eprintln!("Is the FIPS daemon running?");
|
||||
}
|
||||
std::process::exit(1);
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"cannot connect to {}: {}\nIs the FIPS daemon running?",
|
||||
socket_path.display(),
|
||||
e
|
||||
)
|
||||
}
|
||||
};
|
||||
})?;
|
||||
|
||||
// Set timeouts
|
||||
let timeout = Duration::from_secs(2);
|
||||
let timeout = Duration::from_secs(5);
|
||||
let _ = stream.set_read_timeout(Some(timeout));
|
||||
let _ = stream.set_write_timeout(Some(timeout));
|
||||
|
||||
// Send request
|
||||
let request = format!("{{\"command\":\"{}\"}}\n", command_name);
|
||||
if let Err(e) = stream.write_all(request.as_bytes()) {
|
||||
eprintln!("error: failed to send request: {}", e);
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
// Shutdown write half to signal end of request
|
||||
stream
|
||||
.write_all(request_json.as_bytes())
|
||||
.map_err(|e| format!("failed to send request: {e}"))?;
|
||||
let _ = stream.shutdown(std::net::Shutdown::Write);
|
||||
|
||||
// Read response
|
||||
let reader = BufReader::new(&stream);
|
||||
let response_line = match reader.lines().next() {
|
||||
Some(Ok(line)) => line,
|
||||
Some(Err(e)) => {
|
||||
eprintln!("error: failed to read response: {}", e);
|
||||
std::process::exit(1);
|
||||
}
|
||||
None => {
|
||||
eprintln!("error: no response from daemon");
|
||||
std::process::exit(1);
|
||||
}
|
||||
};
|
||||
let line = reader
|
||||
.lines()
|
||||
.next()
|
||||
.ok_or("no response from daemon")?
|
||||
.map_err(|e| format!("failed to read response: {e}"))?;
|
||||
|
||||
// Parse and pretty-print
|
||||
let Ok(value) = serde_json::from_str::<serde_json::Value>(&response_line) else {
|
||||
// Not JSON, print raw
|
||||
println!("{}", response_line);
|
||||
return;
|
||||
};
|
||||
serde_json::from_str(&line).map_err(|e| format!("invalid response JSON: {e}"))
|
||||
}
|
||||
|
||||
/// Build a request JSON string for a simple command (no params).
|
||||
fn build_query(command: &str) -> String {
|
||||
format!("{{\"command\":\"{command}\"}}\n")
|
||||
}
|
||||
|
||||
/// Build a request JSON string for a command with params.
|
||||
fn build_command(command: &str, params: serde_json::Value) -> String {
|
||||
let req = serde_json::json!({"command": command, "params": params});
|
||||
format!("{}\n", serde_json::to_string(&req).unwrap())
|
||||
}
|
||||
|
||||
/// Print a control socket response, handling error status.
|
||||
fn print_response(value: &serde_json::Value) {
|
||||
let status = value
|
||||
.get("status")
|
||||
.and_then(|v| v.as_str())
|
||||
@@ -235,15 +188,223 @@ fn main() {
|
||||
.get("message")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("unknown error");
|
||||
eprintln!("error: {}", msg);
|
||||
eprintln!("error: {msg}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
// Pretty-print the data field (or whole response if no data field)
|
||||
let output = if let Some(data) = value.get("data") {
|
||||
serde_json::to_string_pretty(data).unwrap_or(response_line)
|
||||
serde_json::to_string_pretty(data)
|
||||
} else {
|
||||
serde_json::to_string_pretty(&value).unwrap_or(response_line)
|
||||
serde_json::to_string_pretty(value)
|
||||
};
|
||||
println!("{}", output);
|
||||
println!("{}", output.unwrap_or_else(|_| format!("{value}")));
|
||||
}
|
||||
|
||||
/// Check if `address` is an IPv6 literal in `fd00::/8` (FIPS mesh ULA range).
|
||||
///
|
||||
/// Handles three common syntaxes:
|
||||
/// - bare IPv6: `fd9d:...`
|
||||
/// - bracketed + port: `[fd9d:...]:2121`
|
||||
/// - bare IPv6 + port: `fd9d:...:2121` (ambiguous; accepted if tail is numeric)
|
||||
fn is_fips_mesh_address(address: &str) -> bool {
|
||||
let is_ula = |a: &Ipv6Addr| a.octets()[0] == 0xfd;
|
||||
|
||||
if let Ok(a) = address.parse::<Ipv6Addr>() {
|
||||
return is_ula(&a);
|
||||
}
|
||||
if let Ok(sa) = address.parse::<SocketAddrV6>() {
|
||||
return is_ula(sa.ip());
|
||||
}
|
||||
if let Some((host, port)) = address.rsplit_once(':')
|
||||
&& port.chars().all(|c| c.is_ascii_digit())
|
||||
&& !port.is_empty()
|
||||
{
|
||||
let host = host.trim_start_matches('[').trim_end_matches(']');
|
||||
if let Ok(a) = host.parse::<Ipv6Addr>() {
|
||||
return is_ula(&a);
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Reject `fd00::/8` addresses for transports that expect a reachable network endpoint.
|
||||
///
|
||||
/// FIPS mesh ULAs are derived from npubs and only make sense as destinations
|
||||
/// inside an already-established mesh — they are not valid udp/tcp/ethernet
|
||||
/// transport endpoints. Without this check the CLI echoes success while the
|
||||
/// daemon rejects the bind with EAFNOSUPPORT (issue #61).
|
||||
fn validate_connect_address(address: &str, transport: &str) -> Result<(), String> {
|
||||
let checked = matches!(transport, "udp" | "tcp" | "ethernet");
|
||||
if checked && is_fips_mesh_address(address) {
|
||||
return Err(format!(
|
||||
"'{address}' is a FIPS mesh address (fd00::/8), not a reachable {transport} endpoint.\n\
|
||||
Provide the peer's routable IP/hostname and port (e.g., '192.0.2.1:2121' or 'peer.example.com:2121')."
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Resolve a peer identifier to an npub.
|
||||
///
|
||||
/// If the identifier starts with "npub1", it's returned as-is.
|
||||
/// Otherwise, it's looked up as a hostname in /etc/fips/hosts.
|
||||
fn resolve_peer(peer: &str) -> String {
|
||||
if peer.starts_with("npub1") {
|
||||
return peer.to_string();
|
||||
}
|
||||
|
||||
let hosts = HostMap::load_hosts_file(Path::new(fips::upper::hosts::DEFAULT_HOSTS_PATH));
|
||||
match hosts.lookup_npub(peer) {
|
||||
Some(npub) => npub.to_string(),
|
||||
None => {
|
||||
eprintln!("error: unknown host '{peer}'");
|
||||
eprintln!("Not found in /etc/fips/hosts and not an npub.");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let cli = Cli::parse();
|
||||
|
||||
// Commands that don't require a running daemon
|
||||
if let Commands::Keygen { dir, force, stdout } = &cli.command {
|
||||
let identity = Identity::generate();
|
||||
let nsec = encode_nsec(&identity.keypair().secret_key());
|
||||
let npub = identity.npub();
|
||||
|
||||
if *stdout {
|
||||
println!("{nsec}");
|
||||
println!("{npub}");
|
||||
return;
|
||||
}
|
||||
|
||||
let key_path = dir.join("fips.key");
|
||||
let pub_path = dir.join("fips.pub");
|
||||
|
||||
if key_path.exists() && !force {
|
||||
eprintln!("error: key file already exists: {}", key_path.display());
|
||||
eprintln!("Use --force to overwrite.");
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
if let Err(e) = std::fs::create_dir_all(dir) {
|
||||
eprintln!("error: cannot create directory {}: {e}", dir.display());
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
if let Err(e) = write_key_file(&key_path, &nsec) {
|
||||
eprintln!("error: failed to write key file: {e}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
if let Err(e) = write_pub_file(&pub_path, &npub) {
|
||||
eprintln!("error: failed to write pub file: {e}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
eprintln!("{npub}");
|
||||
eprintln!("Key files written to: {}/", dir.display());
|
||||
eprintln!();
|
||||
eprintln!("NOTE: Set 'node.identity.persistent: true' in fips.yaml");
|
||||
eprintln!(" or these keys will be overwritten on next daemon start.");
|
||||
return;
|
||||
}
|
||||
|
||||
let socket_path = cli.socket.unwrap_or_else(default_socket_path);
|
||||
|
||||
let request = match &cli.command {
|
||||
Commands::Show { what } => build_query(what.command_name()),
|
||||
Commands::Connect {
|
||||
peer,
|
||||
address,
|
||||
transport,
|
||||
} => {
|
||||
if let Err(e) = validate_connect_address(address, transport) {
|
||||
eprintln!("error: {e}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
let npub = resolve_peer(peer);
|
||||
build_command(
|
||||
"connect",
|
||||
serde_json::json!({
|
||||
"npub": npub,
|
||||
"address": address,
|
||||
"transport": transport,
|
||||
}),
|
||||
)
|
||||
}
|
||||
Commands::Disconnect { peer } => {
|
||||
let npub = resolve_peer(peer);
|
||||
build_command("disconnect", serde_json::json!({"npub": npub}))
|
||||
}
|
||||
Commands::Keygen { .. } => unreachable!(),
|
||||
};
|
||||
|
||||
match send_request(&socket_path, &request) {
|
||||
Ok(value) => print_response(&value),
|
||||
Err(e) => {
|
||||
eprintln!("error: {e}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn detects_bare_ula_literal() {
|
||||
assert!(is_fips_mesh_address("fd9d:abcd::1"));
|
||||
assert!(is_fips_mesh_address("fd00::"));
|
||||
assert!(is_fips_mesh_address(
|
||||
"fdff:ffff:ffff:ffff:ffff:ffff:ffff:ffff"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_bracketed_ula_with_port() {
|
||||
assert!(is_fips_mesh_address("[fd9d:abcd::1]:2121"));
|
||||
assert!(is_fips_mesh_address("[fd00::1]:8443"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_bare_ula_with_port() {
|
||||
assert!(is_fips_mesh_address("fd9d:abcd::1:2121"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_non_ula_ipv6() {
|
||||
// fc00::/7 other half (fcXX:) is also ULA but not fd00::/8 — we only
|
||||
// block the fd-prefixed half that FIPS actually uses.
|
||||
assert!(!is_fips_mesh_address("fc00::1"));
|
||||
assert!(!is_fips_mesh_address("::1"));
|
||||
assert!(!is_fips_mesh_address("2001:db8::1"));
|
||||
assert!(!is_fips_mesh_address("[2001:db8::1]:2121"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_ipv4_and_hostnames() {
|
||||
assert!(!is_fips_mesh_address("192.0.2.1:2121"));
|
||||
assert!(!is_fips_mesh_address("peer.example.com:2121"));
|
||||
assert!(!is_fips_mesh_address("coinos.pro:2121"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validates_only_target_transports() {
|
||||
assert!(validate_connect_address("fd9d::1:2121", "udp").is_err());
|
||||
assert!(validate_connect_address("fd9d::1:2121", "tcp").is_err());
|
||||
assert!(validate_connect_address("fd9d::1:2121", "ethernet").is_err());
|
||||
// Other transports are not inspected — they may legitimately accept
|
||||
// non-IP endpoints (tor onion, etc.).
|
||||
assert!(validate_connect_address("fd9d::1:2121", "tor").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn allows_valid_endpoints() {
|
||||
assert!(validate_connect_address("192.0.2.1:2121", "udp").is_ok());
|
||||
assert!(validate_connect_address("peer.example.com:2121", "tcp").is_ok());
|
||||
assert!(validate_connect_address("[2001:db8::1]:2121", "udp").is_ok());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,10 +94,7 @@ impl Tab {
|
||||
|
||||
/// Whether this tab has a table view with row selection.
|
||||
pub fn has_table(&self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
Tab::Peers | Tab::Sessions | Tab::Transports
|
||||
)
|
||||
matches!(self, Tab::Peers | Tab::Sessions | Tab::Transports)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -172,7 +169,10 @@ impl App {
|
||||
return;
|
||||
}
|
||||
let state = self.table_states.entry(self.active_tab).or_default();
|
||||
let i = state.selected().map(|s| (s + 1).min(count - 1)).unwrap_or(0);
|
||||
let i = state
|
||||
.selected()
|
||||
.map(|s| (s + 1).min(count - 1))
|
||||
.unwrap_or(0);
|
||||
state.select(Some(i));
|
||||
}
|
||||
|
||||
|
||||
@@ -17,27 +17,29 @@ impl EventHandler {
|
||||
pub fn new(tick_rate: Duration) -> Self {
|
||||
let (tx, rx) = mpsc::channel();
|
||||
|
||||
thread::spawn(move || loop {
|
||||
if event::poll(tick_rate).unwrap_or(false) {
|
||||
if let Ok(evt) = event::read() {
|
||||
match evt {
|
||||
CrosstermEvent::Key(key) => {
|
||||
if tx.send(Event::Key(key)).is_err() {
|
||||
return;
|
||||
thread::spawn(move || {
|
||||
loop {
|
||||
if event::poll(tick_rate).unwrap_or(false) {
|
||||
if let Ok(evt) = event::read() {
|
||||
match evt {
|
||||
CrosstermEvent::Key(key) => {
|
||||
if tx.send(Event::Key(key)).is_err() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
CrosstermEvent::Resize(..) => {
|
||||
if tx.send(Event::Resize).is_err() {
|
||||
return;
|
||||
CrosstermEvent::Resize(..) => {
|
||||
if tx.send(Event::Resize).is_err() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Poll timed out — send a tick
|
||||
if tx.send(Event::Tick).is_err() {
|
||||
return;
|
||||
} else {
|
||||
// Poll timed out — send a tick
|
||||
if tx.send(Event::Tick).is_err() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -34,8 +34,10 @@ struct Cli {
|
||||
///
|
||||
/// Checks the system-wide path first (used when the daemon runs as a
|
||||
/// systemd service), then falls back to the user's XDG runtime directory.
|
||||
/// Uses directory existence rather than socket file existence so the check
|
||||
/// works even when the user lacks traverse permission on /run/fips/ (0750).
|
||||
fn default_socket_path() -> PathBuf {
|
||||
if Path::new("/run/fips/control.sock").exists() {
|
||||
if Path::new("/run/fips").exists() {
|
||||
PathBuf::from("/run/fips/control.sock")
|
||||
} else if let Ok(runtime_dir) = std::env::var("XDG_RUNTIME_DIR") {
|
||||
PathBuf::from(format!("{runtime_dir}/fips/control.sock"))
|
||||
|
||||
@@ -12,8 +12,8 @@ pub fn draw(frame: &mut Frame, app: &App, area: Rect) {
|
||||
let data = match app.data.get(&Tab::Bloom) {
|
||||
Some(d) => d,
|
||||
None => {
|
||||
let msg = Paragraph::new(" Waiting for data...")
|
||||
.style(Style::default().fg(Color::DarkGray));
|
||||
let msg =
|
||||
Paragraph::new(" Waiting for data...").style(Style::default().fg(Color::DarkGray));
|
||||
frame.render_widget(msg, area);
|
||||
return;
|
||||
}
|
||||
@@ -22,7 +22,7 @@ pub fn draw(frame: &mut Frame, app: &App, area: Rect) {
|
||||
let chunks = Layout::vertical([
|
||||
Constraint::Length(7), // Bloom Filter State
|
||||
Constraint::Length(15), // Bloom Announce Stats
|
||||
Constraint::Min(3), // Peer Filters
|
||||
Constraint::Min(3), // Peer Filters
|
||||
])
|
||||
.split(area);
|
||||
|
||||
@@ -64,16 +64,28 @@ fn draw_stats(frame: &mut Frame, data: &serde_json::Value, area: Rect) {
|
||||
helpers::section_header("Inbound"),
|
||||
helpers::kv_line("Received", &helpers::nested_u64(data, "stats", "received")),
|
||||
helpers::kv_line("Accepted", &helpers::nested_u64(data, "stats", "accepted")),
|
||||
helpers::kv_line("Decode Error", &helpers::nested_u64(data, "stats", "decode_error")),
|
||||
helpers::kv_line(
|
||||
"Decode Error",
|
||||
&helpers::nested_u64(data, "stats", "decode_error"),
|
||||
),
|
||||
helpers::kv_line("Invalid", &helpers::nested_u64(data, "stats", "invalid")),
|
||||
helpers::kv_line("Non-V1", &helpers::nested_u64(data, "stats", "non_v1")),
|
||||
helpers::kv_line("Unknown Peer", &helpers::nested_u64(data, "stats", "unknown_peer")),
|
||||
helpers::kv_line(
|
||||
"Unknown Peer",
|
||||
&helpers::nested_u64(data, "stats", "unknown_peer"),
|
||||
),
|
||||
helpers::kv_line("Stale", &helpers::nested_u64(data, "stats", "stale")),
|
||||
Line::from(""),
|
||||
helpers::section_header("Outbound"),
|
||||
helpers::kv_line("Sent", &helpers::nested_u64(data, "stats", "sent")),
|
||||
helpers::kv_line("Debounce Suppressed", &helpers::nested_u64(data, "stats", "debounce_suppressed")),
|
||||
helpers::kv_line("Send Failed", &helpers::nested_u64(data, "stats", "send_failed")),
|
||||
helpers::kv_line(
|
||||
"Debounce Suppressed",
|
||||
&helpers::nested_u64(data, "stats", "debounce_suppressed"),
|
||||
),
|
||||
helpers::kv_line(
|
||||
"Send Failed",
|
||||
&helpers::nested_u64(data, "stats", "send_failed"),
|
||||
),
|
||||
];
|
||||
|
||||
let max_lines = inner.height as usize;
|
||||
@@ -97,8 +109,7 @@ fn draw_peer_filters(frame: &mut Frame, data: &serde_json::Value, area: Rect) {
|
||||
frame.render_widget(block, area);
|
||||
|
||||
if filters.is_empty() {
|
||||
let msg =
|
||||
Paragraph::new(" No peers").style(Style::default().fg(Color::DarkGray));
|
||||
let msg = Paragraph::new(" No peers").style(Style::default().fg(Color::DarkGray));
|
||||
frame.render_widget(msg, inner);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -12,18 +12,18 @@ pub fn draw(frame: &mut Frame, app: &App, area: Rect) {
|
||||
let data = match app.data.get(&crate::app::Tab::Node) {
|
||||
Some(d) => d,
|
||||
None => {
|
||||
let msg = Paragraph::new(" Waiting for data...")
|
||||
.style(Style::default().fg(Color::DarkGray));
|
||||
let msg =
|
||||
Paragraph::new(" Waiting for data...").style(Style::default().fg(Color::DarkGray));
|
||||
frame.render_widget(msg, area);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let chunks = Layout::vertical([
|
||||
Constraint::Length(7), // Runtime
|
||||
Constraint::Length(7), // Identity
|
||||
Constraint::Length(5), // State
|
||||
Constraint::Length(9), // Traffic
|
||||
Constraint::Length(7), // Runtime
|
||||
Constraint::Length(7), // Identity
|
||||
Constraint::Length(5), // State
|
||||
Constraint::Length(9), // Traffic
|
||||
Constraint::Min(0), // remaining
|
||||
])
|
||||
.split(area);
|
||||
@@ -35,16 +35,17 @@ pub fn draw(frame: &mut Frame, app: &App, area: Rect) {
|
||||
}
|
||||
|
||||
fn draw_runtime(frame: &mut Frame, data: &serde_json::Value, area: Rect) {
|
||||
let block = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.title(" Runtime ");
|
||||
let block = Block::default().borders(Borders::ALL).title(" Runtime ");
|
||||
let inner = block.inner(area);
|
||||
frame.render_widget(block, area);
|
||||
|
||||
let version = helpers::str_field(data, "version");
|
||||
let pid = helpers::u64_field(data, "pid");
|
||||
let exe = helpers::str_field(data, "exe_path");
|
||||
let uptime_secs = data.get("uptime_secs").and_then(|v| v.as_u64()).unwrap_or(0);
|
||||
let uptime_secs = data
|
||||
.get("uptime_secs")
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(0);
|
||||
let uptime = format_uptime(uptime_secs);
|
||||
let socket = helpers::str_field(data, "control_socket");
|
||||
let tun_name = helpers::str_field(data, "tun_name");
|
||||
@@ -78,9 +79,7 @@ fn draw_runtime(frame: &mut Frame, data: &serde_json::Value, area: Rect) {
|
||||
}
|
||||
|
||||
fn draw_identity(frame: &mut Frame, data: &serde_json::Value, area: Rect) {
|
||||
let block = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.title(" Identity ");
|
||||
let block = Block::default().borders(Borders::ALL).title(" Identity ");
|
||||
let inner = block.inner(area);
|
||||
frame.render_widget(block, area);
|
||||
|
||||
@@ -109,9 +108,7 @@ fn draw_identity(frame: &mut Frame, data: &serde_json::Value, area: Rect) {
|
||||
}
|
||||
|
||||
fn draw_state(frame: &mut Frame, data: &serde_json::Value, area: Rect) {
|
||||
let block = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.title(" State ");
|
||||
let block = Block::default().borders(Borders::ALL).title(" State ");
|
||||
let inner = block.inner(area);
|
||||
frame.render_widget(block, area);
|
||||
|
||||
@@ -167,9 +164,7 @@ fn draw_state(frame: &mut Frame, data: &serde_json::Value, area: Rect) {
|
||||
}
|
||||
|
||||
fn draw_node_stats(frame: &mut Frame, data: &serde_json::Value, area: Rect) {
|
||||
let block = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.title(" Traffic ");
|
||||
let block = Block::default().borders(Borders::ALL).title(" Traffic ");
|
||||
let inner = block.inner(area);
|
||||
frame.render_widget(block, area);
|
||||
|
||||
@@ -197,7 +192,10 @@ fn fwd_line(data: &serde_json::Value, label: &str, pkt_key: &str, byte_key: &str
|
||||
.and_then(|f| f.get(byte_key))
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(0);
|
||||
helpers::kv_line(label, &format!("{} pkts ({})", pkts, helpers::format_bytes(bytes)))
|
||||
helpers::kv_line(
|
||||
label,
|
||||
&format!("{} pkts ({})", pkts, helpers::format_bytes(bytes)),
|
||||
)
|
||||
}
|
||||
|
||||
/// Format seconds as human-readable uptime (e.g., "3d 2h 15m 4s").
|
||||
|
||||
@@ -119,7 +119,13 @@ pub fn nested_f64(data: &Value, outer: &str, inner: &str, decimals: usize) -> St
|
||||
}
|
||||
|
||||
/// Get a nested f64 field, preferring `preferred` key with fallback to `fallback` key.
|
||||
pub fn nested_f64_prefer(data: &Value, outer: &str, preferred: &str, fallback: &str, decimals: usize) -> String {
|
||||
pub fn nested_f64_prefer(
|
||||
data: &Value,
|
||||
outer: &str,
|
||||
preferred: &str,
|
||||
fallback: &str,
|
||||
decimals: usize,
|
||||
) -> String {
|
||||
data.get(outer)
|
||||
.and_then(|o| o.get(preferred).or_else(|| o.get(fallback)))
|
||||
.and_then(|v| v.as_f64())
|
||||
@@ -161,11 +167,7 @@ pub fn section_header(title: &str) -> Line<'static> {
|
||||
/// Key-value line for detail views.
|
||||
pub fn kv_line(key: &str, value: &str) -> Line<'static> {
|
||||
Line::from(vec![
|
||||
Span::styled(
|
||||
format!(" {key}: "),
|
||||
Style::default().fg(Color::DarkGray),
|
||||
),
|
||||
Span::styled(format!(" {key}: "), Style::default().fg(Color::DarkGray)),
|
||||
Span::raw(value.to_string()),
|
||||
])
|
||||
}
|
||||
|
||||
|
||||
@@ -12,18 +12,15 @@ pub fn draw(frame: &mut Frame, app: &App, area: Rect) {
|
||||
let data = match app.data.get(&Tab::Mmp) {
|
||||
Some(d) => d,
|
||||
None => {
|
||||
let msg = Paragraph::new(" Waiting for data...")
|
||||
.style(Style::default().fg(Color::DarkGray));
|
||||
let msg =
|
||||
Paragraph::new(" Waiting for data...").style(Style::default().fg(Color::DarkGray));
|
||||
frame.render_widget(msg, area);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let chunks = Layout::vertical([
|
||||
Constraint::Percentage(60),
|
||||
Constraint::Percentage(40),
|
||||
])
|
||||
.split(area);
|
||||
let chunks =
|
||||
Layout::vertical([Constraint::Percentage(60), Constraint::Percentage(40)]).split(area);
|
||||
|
||||
draw_link_mmp(frame, data, chunks[0]);
|
||||
draw_session_mmp(frame, data, chunks[1]);
|
||||
@@ -44,8 +41,7 @@ fn draw_link_mmp(frame: &mut Frame, data: &serde_json::Value, area: Rect) {
|
||||
frame.render_widget(block, area);
|
||||
|
||||
if peers.is_empty() {
|
||||
let msg =
|
||||
Paragraph::new(" No peers").style(Style::default().fg(Color::DarkGray));
|
||||
let msg = Paragraph::new(" No peers").style(Style::default().fg(Color::DarkGray));
|
||||
frame.render_widget(msg, inner);
|
||||
return;
|
||||
}
|
||||
@@ -152,8 +148,7 @@ fn draw_session_mmp(frame: &mut Frame, data: &serde_json::Value, area: Rect) {
|
||||
frame.render_widget(block, area);
|
||||
|
||||
if sessions.is_empty() {
|
||||
let msg = Paragraph::new(" No sessions")
|
||||
.style(Style::default().fg(Color::DarkGray));
|
||||
let msg = Paragraph::new(" No sessions").style(Style::default().fg(Color::DarkGray));
|
||||
frame.render_widget(msg, inner);
|
||||
return;
|
||||
}
|
||||
@@ -234,4 +229,3 @@ fn trend_color(trend: &str, bad_rising: bool) -> Color {
|
||||
_ => Color::DarkGray, // "stable"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ use crate::app::{App, ConnectionState, Tab};
|
||||
pub fn draw(frame: &mut Frame, app: &mut App) {
|
||||
let chunks = Layout::vertical([
|
||||
Constraint::Length(3), // tab bar
|
||||
Constraint::Min(1), // content
|
||||
Constraint::Min(1), // content
|
||||
Constraint::Length(1), // status bar
|
||||
])
|
||||
.split(frame.area());
|
||||
@@ -51,7 +51,11 @@ fn draw_tab_bar(frame: &mut Frame, app: &App, area: Rect) {
|
||||
spans.push(Span::styled(" | ", divider));
|
||||
}
|
||||
}
|
||||
let style = if *tab == app.active_tab { highlight } else { normal };
|
||||
let style = if *tab == app.active_tab {
|
||||
highlight
|
||||
} else {
|
||||
normal
|
||||
};
|
||||
spans.push(Span::styled(tab.label(), style));
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,9 @@ use ratatui::Frame;
|
||||
use ratatui::layout::{Constraint, Layout, Rect};
|
||||
use ratatui::style::{Color, Modifier, Style};
|
||||
use ratatui::text::Line;
|
||||
use ratatui::widgets::{Block, Borders, Cell, Paragraph, Row, Scrollbar, ScrollbarOrientation, ScrollbarState, Table};
|
||||
use ratatui::widgets::{
|
||||
Block, Borders, Cell, Paragraph, Row, Scrollbar, ScrollbarOrientation, ScrollbarState, Table,
|
||||
};
|
||||
|
||||
use crate::app::{App, Tab};
|
||||
|
||||
@@ -14,11 +16,8 @@ pub fn draw(frame: &mut Frame, app: &mut App, area: Rect) {
|
||||
|
||||
if app.detail_view.is_some() {
|
||||
// Split: left 40% table, right 60% detail
|
||||
let chunks = Layout::horizontal([
|
||||
Constraint::Percentage(40),
|
||||
Constraint::Percentage(60),
|
||||
])
|
||||
.split(area);
|
||||
let chunks = Layout::horizontal([Constraint::Percentage(40), Constraint::Percentage(60)])
|
||||
.split(area);
|
||||
|
||||
draw_table(frame, app, chunks[0], &peers, row_count);
|
||||
draw_detail(frame, app, chunks[1], &peers);
|
||||
@@ -29,7 +28,8 @@ pub fn draw(frame: &mut Frame, app: &mut App, area: Rect) {
|
||||
|
||||
/// Get peers sorted by LQI ascending (best first). Peers without LQI sort last.
|
||||
fn get_peers_sorted(app: &App) -> Vec<serde_json::Value> {
|
||||
let mut peers = app.data
|
||||
let mut peers = app
|
||||
.data
|
||||
.get(&Tab::Peers)
|
||||
.and_then(|v| v.get("peers"))
|
||||
.and_then(|v| v.as_array())
|
||||
@@ -37,8 +37,14 @@ fn get_peers_sorted(app: &App) -> Vec<serde_json::Value> {
|
||||
.unwrap_or_default();
|
||||
|
||||
peers.sort_by(|a, b| {
|
||||
let lqi_a = a.get("mmp").and_then(|m| m.get("lqi")).and_then(|v| v.as_f64());
|
||||
let lqi_b = b.get("mmp").and_then(|m| m.get("lqi")).and_then(|v| v.as_f64());
|
||||
let lqi_a = a
|
||||
.get("mmp")
|
||||
.and_then(|m| m.get("lqi"))
|
||||
.and_then(|v| v.as_f64());
|
||||
let lqi_b = b
|
||||
.get("mmp")
|
||||
.and_then(|m| m.get("lqi"))
|
||||
.and_then(|v| v.as_f64());
|
||||
match (lqi_a, lqi_b) {
|
||||
(Some(a), Some(b)) => a.partial_cmp(&b).unwrap_or(std::cmp::Ordering::Equal),
|
||||
(Some(_), None) => std::cmp::Ordering::Less,
|
||||
@@ -50,7 +56,13 @@ fn get_peers_sorted(app: &App) -> Vec<serde_json::Value> {
|
||||
peers
|
||||
}
|
||||
|
||||
fn draw_table(frame: &mut Frame, app: &mut App, area: Rect, peers: &[serde_json::Value], row_count: usize) {
|
||||
fn draw_table(
|
||||
frame: &mut Frame,
|
||||
app: &mut App,
|
||||
area: Rect,
|
||||
peers: &[serde_json::Value],
|
||||
row_count: usize,
|
||||
) {
|
||||
let header = Row::new(vec![
|
||||
Cell::from("Name"),
|
||||
Cell::from("Npub"),
|
||||
@@ -74,13 +86,25 @@ fn draw_table(frame: &mut Frame, app: &mut App, area: Rect, peers: &[serde_json:
|
||||
.map(|peer| {
|
||||
let name = helpers::str_field(peer, "display_name");
|
||||
let npub = helpers::str_field(peer, "npub");
|
||||
let is_parent = peer.get("is_parent").and_then(|v| v.as_bool()).unwrap_or(false);
|
||||
let is_child = peer.get("is_child").and_then(|v| v.as_bool()).unwrap_or(false);
|
||||
let is_parent = peer
|
||||
.get("is_parent")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
let is_child = peer
|
||||
.get("is_child")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
|
||||
// Transport: "type addr" (e.g., "udp 1.2.3.4:2121")
|
||||
let transport = {
|
||||
let t_type = peer.get("transport_type").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let t_addr = peer.get("transport_addr").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let t_type = peer
|
||||
.get("transport_type")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
let t_addr = peer
|
||||
.get("transport_addr")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
if t_type.is_empty() && t_addr.is_empty() {
|
||||
"-".to_string()
|
||||
} else if t_type.is_empty() {
|
||||
@@ -92,11 +116,15 @@ fn draw_table(frame: &mut Frame, app: &mut App, area: Rect, peers: &[serde_json:
|
||||
}
|
||||
};
|
||||
|
||||
let dir = peer.get("direction").and_then(|v| v.as_str()).map(|d| match d {
|
||||
"inbound" => "in",
|
||||
"outbound" => "out",
|
||||
other => other,
|
||||
}).unwrap_or("-");
|
||||
let dir = peer
|
||||
.get("direction")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|d| match d {
|
||||
"inbound" => "in",
|
||||
"outbound" => "out",
|
||||
other => other,
|
||||
})
|
||||
.unwrap_or("-");
|
||||
let srtt = helpers::nested_f64(peer, "mmp", "srtt_ms", 1);
|
||||
let loss = helpers::nested_f64_prefer(peer, "mmp", "smoothed_loss", "loss_rate", 3);
|
||||
let lqi = helpers::nested_f64(peer, "mmp", "lqi", 2);
|
||||
@@ -130,16 +158,16 @@ fn draw_table(frame: &mut Frame, app: &mut App, area: Rect, peers: &[serde_json:
|
||||
.collect();
|
||||
|
||||
let widths = [
|
||||
Constraint::Min(12), // Name
|
||||
Constraint::Length(67), // Npub (full bech32)
|
||||
Constraint::Min(20), // Transport
|
||||
Constraint::Length(4), // Dir
|
||||
Constraint::Length(8), // SRTT
|
||||
Constraint::Length(7), // Loss
|
||||
Constraint::Length(6), // LQI
|
||||
Constraint::Length(10), // Goodput
|
||||
Constraint::Length(9), // Pkts Tx
|
||||
Constraint::Length(9), // Pkts Rx
|
||||
Constraint::Min(12), // Name
|
||||
Constraint::Length(67), // Npub (full bech32)
|
||||
Constraint::Min(20), // Transport
|
||||
Constraint::Length(4), // Dir
|
||||
Constraint::Length(8), // SRTT
|
||||
Constraint::Length(7), // Loss
|
||||
Constraint::Length(6), // LQI
|
||||
Constraint::Length(10), // Goodput
|
||||
Constraint::Length(9), // Pkts Tx
|
||||
Constraint::Length(9), // Pkts Rx
|
||||
];
|
||||
|
||||
let table = Table::new(rows, widths)
|
||||
@@ -189,10 +217,22 @@ fn draw_detail(frame: &mut Frame, app: &App, area: Rect, peers: &[serde_json::Va
|
||||
return;
|
||||
};
|
||||
|
||||
let has_tree = peer.get("has_tree_position").and_then(|v| v.as_bool()).unwrap_or(false);
|
||||
let has_bloom = peer.get("has_bloom_filter").and_then(|v| v.as_bool()).unwrap_or(false);
|
||||
let is_parent = peer.get("is_parent").and_then(|v| v.as_bool()).unwrap_or(false);
|
||||
let is_child = peer.get("is_child").and_then(|v| v.as_bool()).unwrap_or(false);
|
||||
let has_tree = peer
|
||||
.get("has_tree_position")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
let has_bloom = peer
|
||||
.get("has_bloom_filter")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
let is_parent = peer
|
||||
.get("is_parent")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
let is_child = peer
|
||||
.get("is_child")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
|
||||
let tree_role = if is_parent {
|
||||
"parent"
|
||||
@@ -215,7 +255,12 @@ fn draw_detail(frame: &mut Frame, app: &App, area: Rect, peers: &[serde_json::Va
|
||||
helpers::section_header("Connection"),
|
||||
helpers::kv_line("Connectivity", helpers::str_field(peer, "connectivity")),
|
||||
helpers::kv_line("Link ID", &helpers::u64_field(peer, "link_id")),
|
||||
helpers::kv_line("Direction", peer.get("direction").and_then(|v| v.as_str()).unwrap_or("-")),
|
||||
helpers::kv_line(
|
||||
"Direction",
|
||||
peer.get("direction")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("-"),
|
||||
),
|
||||
];
|
||||
if let Some(addr) = peer.get("transport_addr").and_then(|v| v.as_str()) {
|
||||
lines.push(helpers::kv_line("Transport Addr", addr));
|
||||
@@ -227,30 +272,52 @@ fn draw_detail(frame: &mut Frame, app: &App, area: Rect, peers: &[serde_json::Va
|
||||
let link_id = peer.get("link_id").and_then(|v| v.as_u64());
|
||||
let link = lookup_link(app, link_id);
|
||||
if let Some(ref link) = link {
|
||||
lines.push(helpers::kv_line("Link State", helpers::str_field(link, "state")));
|
||||
lines.push(helpers::kv_line(
|
||||
"Link State",
|
||||
helpers::str_field(link, "state"),
|
||||
));
|
||||
}
|
||||
lines.extend([
|
||||
helpers::kv_line("Authenticated", &helpers::format_elapsed_ms(
|
||||
peer.get("authenticated_at_ms").and_then(|v| v.as_u64()).unwrap_or(0),
|
||||
)),
|
||||
helpers::kv_line("Last Seen", &helpers::format_elapsed_ms(
|
||||
peer.get("last_seen_ms").and_then(|v| v.as_u64()).unwrap_or(0),
|
||||
)),
|
||||
helpers::kv_line(
|
||||
"Authenticated",
|
||||
&helpers::format_elapsed_ms(
|
||||
peer.get("authenticated_at_ms")
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(0),
|
||||
),
|
||||
),
|
||||
helpers::kv_line(
|
||||
"Last Seen",
|
||||
&helpers::format_elapsed_ms(
|
||||
peer.get("last_seen_ms")
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(0),
|
||||
),
|
||||
),
|
||||
Line::from(""),
|
||||
]);
|
||||
|
||||
// Transport info (cross-referenced from link -> transport)
|
||||
if let Some(transport) = link.as_ref().and_then(|l| lookup_transport(app, l)) {
|
||||
lines.push(helpers::section_header("Transport"));
|
||||
lines.push(helpers::kv_line("Type", helpers::str_field(&transport, "type")));
|
||||
lines.push(helpers::kv_line(
|
||||
"Type",
|
||||
helpers::str_field(&transport, "type"),
|
||||
));
|
||||
if let Some(name) = transport.get("name").and_then(|v| v.as_str()) {
|
||||
lines.push(helpers::kv_line("Name", name));
|
||||
}
|
||||
lines.push(helpers::kv_line("MTU", &helpers::u64_field(&transport, "mtu")));
|
||||
lines.push(helpers::kv_line(
|
||||
"MTU",
|
||||
&helpers::u64_field(&transport, "mtu"),
|
||||
));
|
||||
if let Some(addr) = transport.get("local_addr").and_then(|v| v.as_str()) {
|
||||
lines.push(helpers::kv_line("Local Addr", addr));
|
||||
}
|
||||
lines.push(helpers::kv_line("State", helpers::str_field(&transport, "state")));
|
||||
lines.push(helpers::kv_line(
|
||||
"State",
|
||||
helpers::str_field(&transport, "state"),
|
||||
));
|
||||
lines.push(Line::from(""));
|
||||
}
|
||||
|
||||
@@ -268,28 +335,70 @@ fn draw_detail(frame: &mut Frame, app: &App, area: Rect, peers: &[serde_json::Va
|
||||
Line::from(""),
|
||||
// Stats
|
||||
helpers::section_header("Link Stats"),
|
||||
helpers::kv_line("Pkts Sent", &helpers::nested_u64(peer, "stats", "packets_sent")),
|
||||
helpers::kv_line("Pkts Recv", &helpers::nested_u64(peer, "stats", "packets_recv")),
|
||||
helpers::kv_line("Bytes Sent", &helpers::format_bytes(
|
||||
peer.get("stats").and_then(|s| s.get("bytes_sent")).and_then(|v| v.as_u64()).unwrap_or(0),
|
||||
)),
|
||||
helpers::kv_line("Bytes Recv", &helpers::format_bytes(
|
||||
peer.get("stats").and_then(|s| s.get("bytes_recv")).and_then(|v| v.as_u64()).unwrap_or(0),
|
||||
)),
|
||||
helpers::kv_line(
|
||||
"Pkts Sent",
|
||||
&helpers::nested_u64(peer, "stats", "packets_sent"),
|
||||
),
|
||||
helpers::kv_line(
|
||||
"Pkts Recv",
|
||||
&helpers::nested_u64(peer, "stats", "packets_recv"),
|
||||
),
|
||||
helpers::kv_line(
|
||||
"Bytes Sent",
|
||||
&helpers::format_bytes(
|
||||
peer.get("stats")
|
||||
.and_then(|s| s.get("bytes_sent"))
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(0),
|
||||
),
|
||||
),
|
||||
helpers::kv_line(
|
||||
"Bytes Recv",
|
||||
&helpers::format_bytes(
|
||||
peer.get("stats")
|
||||
.and_then(|s| s.get("bytes_recv"))
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(0),
|
||||
),
|
||||
),
|
||||
Line::from(""),
|
||||
]);
|
||||
|
||||
// MMP (if present)
|
||||
if peer.get("mmp").is_some() {
|
||||
lines.push(helpers::section_header("MMP Metrics"));
|
||||
lines.push(helpers::kv_line("Mode", &helpers::nested_str(peer, "mmp", "mode")));
|
||||
lines.push(helpers::kv_line("SRTT", &format!("{}ms", helpers::nested_f64(peer, "mmp", "srtt_ms", 1))));
|
||||
lines.push(helpers::kv_line("Loss Rate", &helpers::nested_f64_prefer(peer, "mmp", "smoothed_loss", "loss_rate", 4)));
|
||||
lines.push(helpers::kv_line("ETX", &helpers::nested_f64_prefer(peer, "mmp", "smoothed_etx", "etx", 2)));
|
||||
lines.push(helpers::kv_line("LQI", &helpers::nested_f64(peer, "mmp", "lqi", 2)));
|
||||
lines.push(helpers::kv_line("Goodput", &helpers::nested_throughput(peer, "mmp", "goodput_bps")));
|
||||
lines.push(helpers::kv_line("Delivery Fwd", &helpers::nested_f64(peer, "mmp", "delivery_ratio_forward", 3)));
|
||||
lines.push(helpers::kv_line("Delivery Rev", &helpers::nested_f64(peer, "mmp", "delivery_ratio_reverse", 3)));
|
||||
lines.push(helpers::kv_line(
|
||||
"Mode",
|
||||
&helpers::nested_str(peer, "mmp", "mode"),
|
||||
));
|
||||
lines.push(helpers::kv_line(
|
||||
"SRTT",
|
||||
&format!("{}ms", helpers::nested_f64(peer, "mmp", "srtt_ms", 1)),
|
||||
));
|
||||
lines.push(helpers::kv_line(
|
||||
"Loss Rate",
|
||||
&helpers::nested_f64_prefer(peer, "mmp", "smoothed_loss", "loss_rate", 4),
|
||||
));
|
||||
lines.push(helpers::kv_line(
|
||||
"ETX",
|
||||
&helpers::nested_f64_prefer(peer, "mmp", "smoothed_etx", "etx", 2),
|
||||
));
|
||||
lines.push(helpers::kv_line(
|
||||
"LQI",
|
||||
&helpers::nested_f64(peer, "mmp", "lqi", 2),
|
||||
));
|
||||
lines.push(helpers::kv_line(
|
||||
"Goodput",
|
||||
&helpers::nested_throughput(peer, "mmp", "goodput_bps"),
|
||||
));
|
||||
lines.push(helpers::kv_line(
|
||||
"Delivery Fwd",
|
||||
&helpers::nested_f64(peer, "mmp", "delivery_ratio_forward", 3),
|
||||
));
|
||||
lines.push(helpers::kv_line(
|
||||
"Delivery Rev",
|
||||
&helpers::nested_f64(peer, "mmp", "delivery_ratio_reverse", 3),
|
||||
));
|
||||
}
|
||||
|
||||
let detail_scroll = app.detail_view.as_ref().map(|d| d.scroll).unwrap_or(0);
|
||||
@@ -320,4 +429,3 @@ fn lookup_transport(app: &App, link: &serde_json::Value) -> Option<serde_json::V
|
||||
.find(|t| t.get("transport_id").and_then(|v| v.as_u64()) == Some(transport_id))
|
||||
.cloned()
|
||||
}
|
||||
|
||||
|
||||
@@ -12,16 +12,16 @@ pub fn draw(frame: &mut Frame, app: &App, area: Rect) {
|
||||
let data = match app.data.get(&Tab::Routing) {
|
||||
Some(d) => d,
|
||||
None => {
|
||||
let msg = Paragraph::new(" Waiting for data...")
|
||||
.style(Style::default().fg(Color::DarkGray));
|
||||
let msg =
|
||||
Paragraph::new(" Waiting for data...").style(Style::default().fg(Color::DarkGray));
|
||||
frame.render_widget(msg, area);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let chunks = Layout::vertical([
|
||||
Constraint::Length(7), // Routing State
|
||||
Constraint::Length(8), // Coordinate Cache
|
||||
Constraint::Length(7), // Routing State
|
||||
Constraint::Length(8), // Coordinate Cache
|
||||
Constraint::Min(3), // Routing Statistics
|
||||
])
|
||||
.split(area);
|
||||
@@ -33,7 +33,10 @@ pub fn draw(frame: &mut Frame, app: &App, area: Rect) {
|
||||
|
||||
fn draw_routing_state(frame: &mut Frame, data: &serde_json::Value, area: Rect) {
|
||||
let lines = vec![
|
||||
helpers::kv_line("Coord Cache", &helpers::u64_field(data, "coord_cache_entries")),
|
||||
helpers::kv_line(
|
||||
"Coord Cache",
|
||||
&helpers::u64_field(data, "coord_cache_entries"),
|
||||
),
|
||||
helpers::kv_line(
|
||||
"Identity Cache",
|
||||
&helpers::u64_field(data, "identity_cache_entries"),
|
||||
@@ -68,7 +71,10 @@ fn fwd_line(data: &serde_json::Value, label: &str, pkt_key: &str, byte_key: &str
|
||||
.and_then(|f| f.get(byte_key))
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(0);
|
||||
helpers::kv_line(label, &format!("{} pkts ({})", pkts, helpers::format_bytes(bytes)))
|
||||
helpers::kv_line(
|
||||
label,
|
||||
&format!("{} pkts ({})", pkts, helpers::format_bytes(bytes)),
|
||||
)
|
||||
}
|
||||
|
||||
fn draw_routing_stats(frame: &mut Frame, data: &serde_json::Value, area: Rect) {
|
||||
@@ -78,11 +84,8 @@ fn draw_routing_stats(frame: &mut Frame, data: &serde_json::Value, area: Rect) {
|
||||
let inner = block.inner(area);
|
||||
frame.render_widget(block, area);
|
||||
|
||||
let cols = Layout::horizontal([
|
||||
Constraint::Percentage(50),
|
||||
Constraint::Percentage(50),
|
||||
])
|
||||
.split(inner);
|
||||
let cols =
|
||||
Layout::horizontal([Constraint::Percentage(50), Constraint::Percentage(50)]).split(inner);
|
||||
|
||||
// Left column: Forwarding + Discovery
|
||||
let mut left = vec![
|
||||
@@ -91,45 +94,147 @@ fn draw_routing_stats(frame: &mut Frame, data: &serde_json::Value, area: Rect) {
|
||||
fwd_line(data, "Delivered", "delivered_packets", "delivered_bytes"),
|
||||
fwd_line(data, "Forwarded", "forwarded_packets", "forwarded_bytes"),
|
||||
fwd_line(data, "Originated", "originated_packets", "originated_bytes"),
|
||||
fwd_line(data, "Decode Error", "decode_error_packets", "decode_error_bytes"),
|
||||
fwd_line(data, "TTL Exhausted", "ttl_exhausted_packets", "ttl_exhausted_bytes"),
|
||||
fwd_line(data, "No Route", "drop_no_route_packets", "drop_no_route_bytes"),
|
||||
fwd_line(data, "MTU Exceeded", "drop_mtu_exceeded_packets", "drop_mtu_exceeded_bytes"),
|
||||
fwd_line(data, "Send Error", "drop_send_error_packets", "drop_send_error_bytes"),
|
||||
fwd_line(
|
||||
data,
|
||||
"Decode Error",
|
||||
"decode_error_packets",
|
||||
"decode_error_bytes",
|
||||
),
|
||||
fwd_line(
|
||||
data,
|
||||
"TTL Exhausted",
|
||||
"ttl_exhausted_packets",
|
||||
"ttl_exhausted_bytes",
|
||||
),
|
||||
fwd_line(
|
||||
data,
|
||||
"No Route",
|
||||
"drop_no_route_packets",
|
||||
"drop_no_route_bytes",
|
||||
),
|
||||
fwd_line(
|
||||
data,
|
||||
"MTU Exceeded",
|
||||
"drop_mtu_exceeded_packets",
|
||||
"drop_mtu_exceeded_bytes",
|
||||
),
|
||||
fwd_line(
|
||||
data,
|
||||
"Send Error",
|
||||
"drop_send_error_packets",
|
||||
"drop_send_error_bytes",
|
||||
),
|
||||
Line::from(""),
|
||||
helpers::section_header("Discovery Requests"),
|
||||
helpers::kv_line("Received", &helpers::nested_u64(data, "discovery", "req_received")),
|
||||
helpers::kv_line("Forwarded", &helpers::nested_u64(data, "discovery", "req_forwarded")),
|
||||
helpers::kv_line("Initiated", &helpers::nested_u64(data, "discovery", "req_initiated")),
|
||||
helpers::kv_line("Deduplicated", &helpers::nested_u64(data, "discovery", "req_deduplicated")),
|
||||
helpers::kv_line("Target Is Us", &helpers::nested_u64(data, "discovery", "req_target_is_us")),
|
||||
helpers::kv_line("Duplicate", &helpers::nested_u64(data, "discovery", "req_duplicate")),
|
||||
helpers::kv_line("Already Visited", &helpers::nested_u64(data, "discovery", "req_already_visited")),
|
||||
helpers::kv_line("TTL Exhausted", &helpers::nested_u64(data, "discovery", "req_ttl_exhausted")),
|
||||
helpers::kv_line("Decode Error", &helpers::nested_u64(data, "discovery", "req_decode_error")),
|
||||
helpers::kv_line(
|
||||
"Received",
|
||||
&helpers::nested_u64(data, "discovery", "req_received"),
|
||||
),
|
||||
helpers::kv_line(
|
||||
"Forwarded",
|
||||
&helpers::nested_u64(data, "discovery", "req_forwarded"),
|
||||
),
|
||||
helpers::kv_line(
|
||||
"Initiated",
|
||||
&helpers::nested_u64(data, "discovery", "req_initiated"),
|
||||
),
|
||||
helpers::kv_line(
|
||||
"Deduplicated",
|
||||
&helpers::nested_u64(data, "discovery", "req_deduplicated"),
|
||||
),
|
||||
helpers::kv_line(
|
||||
"Target Is Us",
|
||||
&helpers::nested_u64(data, "discovery", "req_target_is_us"),
|
||||
),
|
||||
helpers::kv_line(
|
||||
"Duplicate",
|
||||
&helpers::nested_u64(data, "discovery", "req_duplicate"),
|
||||
),
|
||||
helpers::kv_line(
|
||||
"Bloom Miss",
|
||||
&helpers::nested_u64(data, "discovery", "req_bloom_miss"),
|
||||
),
|
||||
helpers::kv_line(
|
||||
"Backoff Suppressed",
|
||||
&helpers::nested_u64(data, "discovery", "req_backoff_suppressed"),
|
||||
),
|
||||
helpers::kv_line(
|
||||
"Fwd Rate Limited",
|
||||
&helpers::nested_u64(data, "discovery", "req_forward_rate_limited"),
|
||||
),
|
||||
helpers::kv_line(
|
||||
"TTL Exhausted",
|
||||
&helpers::nested_u64(data, "discovery", "req_ttl_exhausted"),
|
||||
),
|
||||
helpers::kv_line(
|
||||
"Decode Error",
|
||||
&helpers::nested_u64(data, "discovery", "req_decode_error"),
|
||||
),
|
||||
Line::from(""),
|
||||
helpers::section_header("Discovery Responses"),
|
||||
helpers::kv_line("Received", &helpers::nested_u64(data, "discovery", "resp_received")),
|
||||
helpers::kv_line("Accepted", &helpers::nested_u64(data, "discovery", "resp_accepted")),
|
||||
helpers::kv_line("Forwarded", &helpers::nested_u64(data, "discovery", "resp_forwarded")),
|
||||
helpers::kv_line("Timed Out", &helpers::nested_u64(data, "discovery", "resp_timed_out")),
|
||||
helpers::kv_line("Identity Miss", &helpers::nested_u64(data, "discovery", "resp_identity_miss")),
|
||||
helpers::kv_line("Proof Failed", &helpers::nested_u64(data, "discovery", "resp_proof_failed")),
|
||||
helpers::kv_line("Decode Error", &helpers::nested_u64(data, "discovery", "resp_decode_error")),
|
||||
helpers::kv_line(
|
||||
"Received",
|
||||
&helpers::nested_u64(data, "discovery", "resp_received"),
|
||||
),
|
||||
helpers::kv_line(
|
||||
"Accepted",
|
||||
&helpers::nested_u64(data, "discovery", "resp_accepted"),
|
||||
),
|
||||
helpers::kv_line(
|
||||
"Forwarded",
|
||||
&helpers::nested_u64(data, "discovery", "resp_forwarded"),
|
||||
),
|
||||
helpers::kv_line(
|
||||
"Timed Out",
|
||||
&helpers::nested_u64(data, "discovery", "resp_timed_out"),
|
||||
),
|
||||
helpers::kv_line(
|
||||
"Identity Miss",
|
||||
&helpers::nested_u64(data, "discovery", "resp_identity_miss"),
|
||||
),
|
||||
helpers::kv_line(
|
||||
"Proof Failed",
|
||||
&helpers::nested_u64(data, "discovery", "resp_proof_failed"),
|
||||
),
|
||||
helpers::kv_line(
|
||||
"Decode Error",
|
||||
&helpers::nested_u64(data, "discovery", "resp_decode_error"),
|
||||
),
|
||||
];
|
||||
|
||||
// Right column: Error Signals + Congestion
|
||||
let mut right = vec![
|
||||
helpers::section_header("Error Signals"),
|
||||
helpers::kv_line("Coords Required", &helpers::nested_u64(data, "error_signals", "coords_required")),
|
||||
helpers::kv_line("Path Broken", &helpers::nested_u64(data, "error_signals", "path_broken")),
|
||||
helpers::kv_line("MTU Exceeded", &helpers::nested_u64(data, "error_signals", "mtu_exceeded")),
|
||||
helpers::kv_line(
|
||||
"Coords Required",
|
||||
&helpers::nested_u64(data, "error_signals", "coords_required"),
|
||||
),
|
||||
helpers::kv_line(
|
||||
"Path Broken",
|
||||
&helpers::nested_u64(data, "error_signals", "path_broken"),
|
||||
),
|
||||
helpers::kv_line(
|
||||
"MTU Exceeded",
|
||||
&helpers::nested_u64(data, "error_signals", "mtu_exceeded"),
|
||||
),
|
||||
Line::from(""),
|
||||
helpers::section_header("Congestion"),
|
||||
helpers::kv_line("CE Forwarded", &helpers::nested_u64(data, "congestion", "ce_forwarded")),
|
||||
helpers::kv_line("CE Received", &helpers::nested_u64(data, "congestion", "ce_received")),
|
||||
helpers::kv_line("Congestion Detected", &helpers::nested_u64(data, "congestion", "congestion_detected")),
|
||||
helpers::kv_line("Kernel Drops", &helpers::nested_u64(data, "congestion", "kernel_drop_events")),
|
||||
helpers::kv_line(
|
||||
"CE Forwarded",
|
||||
&helpers::nested_u64(data, "congestion", "ce_forwarded"),
|
||||
),
|
||||
helpers::kv_line(
|
||||
"CE Received",
|
||||
&helpers::nested_u64(data, "congestion", "ce_received"),
|
||||
),
|
||||
helpers::kv_line(
|
||||
"Congestion Detected",
|
||||
&helpers::nested_u64(data, "congestion", "congestion_detected"),
|
||||
),
|
||||
helpers::kv_line(
|
||||
"Kernel Drops",
|
||||
&helpers::nested_u64(data, "congestion", "kernel_drop_events"),
|
||||
),
|
||||
];
|
||||
|
||||
let max_lines = cols[0].height as usize;
|
||||
|
||||
@@ -15,11 +15,8 @@ pub fn draw(frame: &mut Frame, app: &mut App, area: Rect) {
|
||||
let row_count = sessions.len();
|
||||
|
||||
if app.detail_view.is_some() {
|
||||
let chunks = Layout::horizontal([
|
||||
Constraint::Percentage(40),
|
||||
Constraint::Percentage(60),
|
||||
])
|
||||
.split(area);
|
||||
let chunks = Layout::horizontal([Constraint::Percentage(40), Constraint::Percentage(60)])
|
||||
.split(area);
|
||||
|
||||
draw_table(frame, app, chunks[0], &sessions, row_count);
|
||||
draw_detail(frame, app, chunks[1], &sessions);
|
||||
@@ -223,10 +220,7 @@ fn draw_detail(frame: &mut Frame, app: &App, area: Rect, sessions: &[serde_json:
|
||||
));
|
||||
lines.push(helpers::kv_line(
|
||||
"SRTT",
|
||||
&format!(
|
||||
"{}ms",
|
||||
helpers::nested_f64(session, "mmp", "srtt_ms", 1)
|
||||
),
|
||||
&format!("{}ms", helpers::nested_f64(session, "mmp", "srtt_ms", 1)),
|
||||
));
|
||||
lines.push(helpers::kv_line(
|
||||
"Loss Rate",
|
||||
@@ -271,4 +265,3 @@ fn state_styled(state: &str) -> Span<'static> {
|
||||
};
|
||||
Span::styled(state.to_string(), Style::default().fg(color))
|
||||
}
|
||||
|
||||
|
||||
@@ -33,11 +33,8 @@ pub fn draw(frame: &mut Frame, app: &mut App, area: Rect) {
|
||||
update_selected_tree_item(app, &tree_rows);
|
||||
|
||||
if app.detail_view.is_some() {
|
||||
let chunks = Layout::horizontal([
|
||||
Constraint::Percentage(40),
|
||||
Constraint::Percentage(60),
|
||||
])
|
||||
.split(area);
|
||||
let chunks = Layout::horizontal([Constraint::Percentage(40), Constraint::Percentage(60)])
|
||||
.split(area);
|
||||
|
||||
draw_table(frame, app, chunks[0], &transports, &links, &tree_rows);
|
||||
draw_detail(frame, app, chunks[1], &transports, &links, &tree_rows);
|
||||
@@ -110,9 +107,7 @@ fn update_selected_tree_item(app: &mut App, tree_rows: &[TreeRow]) {
|
||||
.unwrap_or(0);
|
||||
|
||||
app.selected_tree_item = match tree_rows.get(selected) {
|
||||
Some(TreeRow::Transport { transport_id, .. }) => {
|
||||
SelectedTreeItem::Transport(*transport_id)
|
||||
}
|
||||
Some(TreeRow::Transport { transport_id, .. }) => SelectedTreeItem::Transport(*transport_id),
|
||||
Some(TreeRow::Link { .. }) => SelectedTreeItem::Link,
|
||||
None => SelectedTreeItem::None,
|
||||
};
|
||||
@@ -160,6 +155,20 @@ fn draw_table(
|
||||
let addr = t.get("local_addr").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let label = if !name.is_empty() {
|
||||
format!("{indicator}{typ} {name}")
|
||||
} else if typ == "tor" {
|
||||
let mode = t
|
||||
.get("tor_mode")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("socks5");
|
||||
let onion_hint = t
|
||||
.get("onion_address")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|a| {
|
||||
let short = if a.len() > 16 { &a[..16] } else { a };
|
||||
format!(" {short}..")
|
||||
})
|
||||
.unwrap_or_default();
|
||||
format!("{indicator}tor({mode}){onion_hint}")
|
||||
} else if !addr.is_empty() {
|
||||
format!("{indicator}{typ} {addr}")
|
||||
} else {
|
||||
@@ -191,7 +200,11 @@ fn draw_table(
|
||||
}
|
||||
TreeRow::Link { index, is_last } => {
|
||||
let link = &links[*index];
|
||||
let tree_char = if *is_last { "\u{2514}\u{2500}" } else { "\u{251C}\u{2500}" }; // └─ or ├─
|
||||
let tree_char = if *is_last {
|
||||
"\u{2514}\u{2500}"
|
||||
} else {
|
||||
"\u{251C}\u{2500}"
|
||||
}; // └─ or ├─
|
||||
let dir = helpers::str_field(link, "direction");
|
||||
let dir_short = match dir {
|
||||
"Outbound" => "Out",
|
||||
@@ -285,13 +298,10 @@ fn draw_detail(
|
||||
.unwrap_or(0);
|
||||
|
||||
let Some(tree_row) = tree_rows.get(selected) else {
|
||||
let block = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.title(" Detail ");
|
||||
let block = Block::default().borders(Borders::ALL).title(" Detail ");
|
||||
let inner = block.inner(area);
|
||||
frame.render_widget(block, area);
|
||||
let msg = Paragraph::new(" No item selected")
|
||||
.style(Style::default().fg(Color::DarkGray));
|
||||
let msg = Paragraph::new(" No item selected").style(Style::default().fg(Color::DarkGray));
|
||||
frame.render_widget(msg, inner);
|
||||
return;
|
||||
};
|
||||
@@ -328,6 +338,14 @@ fn draw_transport_detail(frame: &mut Frame, app: &App, area: Rect, t: &serde_jso
|
||||
lines.push(helpers::kv_line("Local Addr", addr));
|
||||
}
|
||||
|
||||
// Tor-specific info
|
||||
if let Some(mode) = t.get("tor_mode").and_then(|v| v.as_str()) {
|
||||
lines.push(helpers::kv_line("Tor Mode", mode));
|
||||
}
|
||||
if let Some(onion) = t.get("onion_address").and_then(|v| v.as_str()) {
|
||||
lines.push(helpers::kv_line("Onion Address", onion));
|
||||
}
|
||||
|
||||
// Transport stats
|
||||
if let Some(stats) = t.get("stats") {
|
||||
let typ = helpers::str_field(t, "type");
|
||||
@@ -424,6 +442,42 @@ fn draw_transport_detail(frame: &mut Frame, app: &App, area: Rect, t: &serde_jso
|
||||
&helpers::nested_u64(t, "stats", "connect_refused"),
|
||||
));
|
||||
}
|
||||
"tor" => {
|
||||
lines.push(helpers::kv_line(
|
||||
"MTU Exceeded",
|
||||
&helpers::nested_u64(t, "stats", "mtu_exceeded"),
|
||||
));
|
||||
lines.push(helpers::kv_line(
|
||||
"SOCKS5 Errors",
|
||||
&helpers::nested_u64(t, "stats", "socks5_errors"),
|
||||
));
|
||||
lines.push(helpers::kv_line(
|
||||
"Control Errors",
|
||||
&helpers::nested_u64(t, "stats", "control_errors"),
|
||||
));
|
||||
lines.push(Line::from(""));
|
||||
lines.push(helpers::section_header("Connections"));
|
||||
lines.push(helpers::kv_line(
|
||||
"Established",
|
||||
&helpers::nested_u64(t, "stats", "connections_established"),
|
||||
));
|
||||
lines.push(helpers::kv_line(
|
||||
"Accepted",
|
||||
&helpers::nested_u64(t, "stats", "connections_accepted"),
|
||||
));
|
||||
lines.push(helpers::kv_line(
|
||||
"Rejected",
|
||||
&helpers::nested_u64(t, "stats", "connections_rejected"),
|
||||
));
|
||||
lines.push(helpers::kv_line(
|
||||
"Timeouts",
|
||||
&helpers::nested_u64(t, "stats", "connect_timeouts"),
|
||||
));
|
||||
lines.push(helpers::kv_line(
|
||||
"Refused",
|
||||
&helpers::nested_u64(t, "stats", "connect_refused"),
|
||||
));
|
||||
}
|
||||
"ethernet" => {
|
||||
lines.push(Line::from(""));
|
||||
lines.push(helpers::section_header("Beacons"));
|
||||
@@ -448,6 +502,57 @@ fn draw_transport_detail(frame: &mut Frame, app: &App, area: Rect, t: &serde_jso
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
// Tor daemon monitoring (when control port data is available)
|
||||
if let Some(mon) = t.get("tor_monitoring") {
|
||||
lines.push(Line::from(""));
|
||||
lines.push(helpers::section_header("Tor Daemon"));
|
||||
lines.push(helpers::kv_line(
|
||||
"Bootstrap",
|
||||
&format!("{}%", helpers::nested_u64(t, "tor_monitoring", "bootstrap")),
|
||||
));
|
||||
lines.push(helpers::kv_line(
|
||||
"Circuit",
|
||||
if mon
|
||||
.get("circuit_established")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false)
|
||||
{
|
||||
"established"
|
||||
} else {
|
||||
"none"
|
||||
},
|
||||
));
|
||||
lines.push(helpers::kv_line(
|
||||
"Version",
|
||||
&helpers::nested_str(t, "tor_monitoring", "version"),
|
||||
));
|
||||
lines.push(helpers::kv_line(
|
||||
"Network",
|
||||
&helpers::nested_str(t, "tor_monitoring", "network_liveness"),
|
||||
));
|
||||
lines.push(helpers::kv_line(
|
||||
"Dormant",
|
||||
helpers::bool_field(mon, "dormant"),
|
||||
));
|
||||
|
||||
let tor_read = mon
|
||||
.get("traffic_read")
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(0);
|
||||
let tor_written = mon
|
||||
.get("traffic_written")
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(0);
|
||||
lines.push(helpers::kv_line(
|
||||
"Tor Read",
|
||||
&helpers::format_bytes(tor_read),
|
||||
));
|
||||
lines.push(helpers::kv_line(
|
||||
"Tor Written",
|
||||
&helpers::format_bytes(tor_written),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
let detail_scroll = app.detail_view.as_ref().map(|d| d.scroll).unwrap_or(0);
|
||||
|
||||
@@ -12,8 +12,8 @@ pub fn draw(frame: &mut Frame, app: &App, area: Rect) {
|
||||
let data = match app.data.get(&Tab::Tree) {
|
||||
Some(d) => d,
|
||||
None => {
|
||||
let msg = Paragraph::new(" Waiting for data...")
|
||||
.style(Style::default().fg(Color::DarkGray));
|
||||
let msg =
|
||||
Paragraph::new(" Waiting for data...").style(Style::default().fg(Color::DarkGray));
|
||||
frame.render_widget(msg, area);
|
||||
return;
|
||||
}
|
||||
@@ -22,7 +22,7 @@ pub fn draw(frame: &mut Frame, app: &App, area: Rect) {
|
||||
let chunks = Layout::vertical([
|
||||
Constraint::Length(10), // Tree Position
|
||||
Constraint::Length(22), // Tree Announce Stats
|
||||
Constraint::Min(3), // Tree Peers
|
||||
Constraint::Min(3), // Tree Peers
|
||||
])
|
||||
.split(area);
|
||||
|
||||
@@ -70,16 +70,12 @@ fn draw_position(frame: &mut Frame, data: &serde_json::Value, area: Rect) {
|
||||
)];
|
||||
|
||||
if coords.is_empty() {
|
||||
path_parts.push(Span::styled(
|
||||
"[root]",
|
||||
Style::default().fg(Color::Yellow),
|
||||
));
|
||||
path_parts.push(Span::styled("[root]", Style::default().fg(Color::Yellow)));
|
||||
} else {
|
||||
// Reverse: root first, self last
|
||||
for (i, entry) in coords.iter().rev().enumerate() {
|
||||
if i > 0 {
|
||||
path_parts
|
||||
.push(Span::styled(" > ", Style::default().fg(Color::DarkGray)));
|
||||
path_parts.push(Span::styled(" > ", Style::default().fg(Color::DarkGray)));
|
||||
}
|
||||
let hex = entry.as_str().unwrap_or("-");
|
||||
let color = if i == 0 {
|
||||
@@ -87,8 +83,10 @@ fn draw_position(frame: &mut Frame, data: &serde_json::Value, area: Rect) {
|
||||
} else {
|
||||
Color::White
|
||||
};
|
||||
path_parts
|
||||
.push(Span::styled(helpers::truncate_hex(hex, 8), Style::default().fg(color)));
|
||||
path_parts.push(Span::styled(
|
||||
helpers::truncate_hex(hex, 8),
|
||||
Style::default().fg(color),
|
||||
));
|
||||
}
|
||||
path_parts.push(Span::styled(" > ", Style::default().fg(Color::DarkGray)));
|
||||
path_parts.push(Span::styled(
|
||||
@@ -121,24 +119,60 @@ fn draw_stats(frame: &mut Frame, data: &serde_json::Value, area: Rect) {
|
||||
helpers::section_header("Inbound"),
|
||||
helpers::kv_line("Received", &helpers::nested_u64(data, "stats", "received")),
|
||||
helpers::kv_line("Accepted", &helpers::nested_u64(data, "stats", "accepted")),
|
||||
helpers::kv_line("Decode Error", &helpers::nested_u64(data, "stats", "decode_error")),
|
||||
helpers::kv_line("Unknown Peer", &helpers::nested_u64(data, "stats", "unknown_peer")),
|
||||
helpers::kv_line("Addr Mismatch", &helpers::nested_u64(data, "stats", "addr_mismatch")),
|
||||
helpers::kv_line("Sig Failed", &helpers::nested_u64(data, "stats", "sig_failed")),
|
||||
helpers::kv_line(
|
||||
"Decode Error",
|
||||
&helpers::nested_u64(data, "stats", "decode_error"),
|
||||
),
|
||||
helpers::kv_line(
|
||||
"Unknown Peer",
|
||||
&helpers::nested_u64(data, "stats", "unknown_peer"),
|
||||
),
|
||||
helpers::kv_line(
|
||||
"Addr Mismatch",
|
||||
&helpers::nested_u64(data, "stats", "addr_mismatch"),
|
||||
),
|
||||
helpers::kv_line(
|
||||
"Sig Failed",
|
||||
&helpers::nested_u64(data, "stats", "sig_failed"),
|
||||
),
|
||||
helpers::kv_line("Stale", &helpers::nested_u64(data, "stats", "stale")),
|
||||
helpers::kv_line("Parent Switched", &helpers::nested_u64(data, "stats", "parent_switched")),
|
||||
helpers::kv_line("Loop Detected", &helpers::nested_u64(data, "stats", "loop_detected")),
|
||||
helpers::kv_line("Ancestry Changed", &helpers::nested_u64(data, "stats", "ancestry_changed")),
|
||||
helpers::kv_line(
|
||||
"Parent Switched",
|
||||
&helpers::nested_u64(data, "stats", "parent_switched"),
|
||||
),
|
||||
helpers::kv_line(
|
||||
"Loop Detected",
|
||||
&helpers::nested_u64(data, "stats", "loop_detected"),
|
||||
),
|
||||
helpers::kv_line(
|
||||
"Ancestry Changed",
|
||||
&helpers::nested_u64(data, "stats", "ancestry_changed"),
|
||||
),
|
||||
Line::from(""),
|
||||
helpers::section_header("Outbound"),
|
||||
helpers::kv_line("Sent", &helpers::nested_u64(data, "stats", "sent")),
|
||||
helpers::kv_line("Rate Limited", &helpers::nested_u64(data, "stats", "rate_limited")),
|
||||
helpers::kv_line("Send Failed", &helpers::nested_u64(data, "stats", "send_failed")),
|
||||
helpers::kv_line(
|
||||
"Rate Limited",
|
||||
&helpers::nested_u64(data, "stats", "rate_limited"),
|
||||
),
|
||||
helpers::kv_line(
|
||||
"Send Failed",
|
||||
&helpers::nested_u64(data, "stats", "send_failed"),
|
||||
),
|
||||
Line::from(""),
|
||||
helpers::section_header("Cumulative"),
|
||||
helpers::kv_line("Parent Switches", &helpers::nested_u64(data, "stats", "parent_switches")),
|
||||
helpers::kv_line("Parent Losses", &helpers::nested_u64(data, "stats", "parent_losses")),
|
||||
helpers::kv_line("Flap Dampened", &helpers::nested_u64(data, "stats", "flap_dampened")),
|
||||
helpers::kv_line(
|
||||
"Parent Switches",
|
||||
&helpers::nested_u64(data, "stats", "parent_switches"),
|
||||
),
|
||||
helpers::kv_line(
|
||||
"Parent Losses",
|
||||
&helpers::nested_u64(data, "stats", "parent_losses"),
|
||||
),
|
||||
helpers::kv_line(
|
||||
"Flap Dampened",
|
||||
&helpers::nested_u64(data, "stats", "flap_dampened"),
|
||||
),
|
||||
];
|
||||
|
||||
// Trim to fit available height
|
||||
@@ -164,8 +198,7 @@ fn draw_peers(frame: &mut Frame, data: &serde_json::Value, area: Rect) {
|
||||
frame.render_widget(block, area);
|
||||
|
||||
if peers.is_empty() {
|
||||
let msg =
|
||||
Paragraph::new(" No peers").style(Style::default().fg(Color::DarkGray));
|
||||
let msg = Paragraph::new(" No peers").style(Style::default().fg(Color::DarkGray));
|
||||
frame.render_widget(msg, inner);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
use std::fmt;
|
||||
|
||||
use tracing::trace;
|
||||
|
||||
use super::{BloomError, DEFAULT_FILTER_SIZE_BITS, DEFAULT_HASH_COUNT};
|
||||
use crate::NodeAddr;
|
||||
|
||||
@@ -143,16 +145,30 @@ impl BloomFilter {
|
||||
///
|
||||
/// Uses the formula: n = -(m/k) * ln(1 - X/m)
|
||||
/// where m = num_bits, k = hash_count, X = count_ones
|
||||
pub fn estimated_count(&self) -> f64 {
|
||||
///
|
||||
/// Returns `None` when the filter's FPR exceeds `max_fpr` (antipoison
|
||||
/// cap) or the filter is saturated (`count_ones() >= num_bits`). Pass
|
||||
/// `f64::INFINITY` for `max_fpr` to disable the cap — useful in
|
||||
/// Debug/log contexts where no policy is in scope. The saturated
|
||||
/// branch is always honored regardless of `max_fpr`, preventing the
|
||||
/// `f64::INFINITY` return that the previous signature produced.
|
||||
pub fn estimated_count(&self, max_fpr: f64) -> Option<f64> {
|
||||
let m = self.num_bits as f64;
|
||||
let k = self.hash_count as f64;
|
||||
let x = self.count_ones() as f64;
|
||||
|
||||
if x >= m {
|
||||
return f64::INFINITY;
|
||||
return None;
|
||||
}
|
||||
|
||||
-(m / k) * (1.0 - x / m).ln()
|
||||
let fill = x / m;
|
||||
let fpr = fill.powi(self.hash_count as i32);
|
||||
if fpr > max_fpr {
|
||||
trace!(fill, fpr, max_fpr, "estimated_count: filter above cap");
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(-(m / k) * (1.0 - fill).ln())
|
||||
}
|
||||
|
||||
/// Check if the filter is empty.
|
||||
@@ -234,7 +250,13 @@ impl fmt::Debug for BloomFilter {
|
||||
.field("bits", &self.num_bits)
|
||||
.field("hash_count", &self.hash_count)
|
||||
.field("fill_ratio", &format!("{:.2}%", self.fill_ratio() * 100.0))
|
||||
.field("est_count", &format!("{:.0}", self.estimated_count()))
|
||||
.field(
|
||||
"est_count",
|
||||
&match self.estimated_count(f64::INFINITY) {
|
||||
Some(n) => format!("{:.0}", n),
|
||||
None => "saturated".to_string(),
|
||||
},
|
||||
)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -149,8 +149,7 @@ fn test_bloom_filter_from_bytes() {
|
||||
let original = BloomFilter::new();
|
||||
let bytes = original.as_bytes().to_vec();
|
||||
|
||||
let restored =
|
||||
BloomFilter::from_bytes(bytes, original.hash_count()).unwrap();
|
||||
let restored = BloomFilter::from_bytes(bytes, original.hash_count()).unwrap();
|
||||
|
||||
assert_eq!(original, restored);
|
||||
}
|
||||
@@ -160,7 +159,7 @@ fn test_bloom_filter_estimated_count() {
|
||||
let mut filter = BloomFilter::new();
|
||||
|
||||
// Empty filter
|
||||
assert_eq!(filter.estimated_count(), 0.0);
|
||||
assert_eq!(filter.estimated_count(f64::INFINITY), Some(0.0));
|
||||
|
||||
// Insert some items
|
||||
for i in 0..50 {
|
||||
@@ -168,7 +167,7 @@ fn test_bloom_filter_estimated_count() {
|
||||
}
|
||||
|
||||
// Estimate should be reasonably close to 50
|
||||
let estimate = filter.estimated_count();
|
||||
let estimate = filter.estimated_count(f64::INFINITY).unwrap();
|
||||
assert!(
|
||||
estimate > 30.0 && estimate < 100.0,
|
||||
"Unexpected estimate: {}",
|
||||
@@ -235,7 +234,42 @@ fn test_bloom_filter_estimated_count_saturated() {
|
||||
let bytes = vec![0xFF; 8]; // all bits set
|
||||
let filter = BloomFilter::from_bytes(bytes, 3).unwrap();
|
||||
|
||||
assert!(filter.estimated_count().is_infinite());
|
||||
// Saturated filter returns None regardless of cap (defense in depth).
|
||||
// Previously returned f64::INFINITY.
|
||||
assert_eq!(filter.estimated_count(f64::INFINITY), None);
|
||||
assert_eq!(filter.estimated_count(0.05), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bloom_filter_estimated_count_fpr_cap_boundary() {
|
||||
// Cap boundary: FPR = fill^k = 0.05 at k=5 ⇒ fill ≈ 0.5493
|
||||
// 1KB filter (8192 bits). 560 bytes of 0xFF = 4480 bits set =
|
||||
// fill 0.5469, FPR ≈ 0.04877 — just below cap.
|
||||
// 564 bytes of 0xFF = 4512 bits set = fill 0.5508, FPR ≈ 0.05060 —
|
||||
// just above cap.
|
||||
|
||||
let mut below = vec![0x00u8; 1024];
|
||||
below[..560].fill(0xFF);
|
||||
let below_filter = BloomFilter::from_bytes(below, DEFAULT_HASH_COUNT).unwrap();
|
||||
assert!(
|
||||
below_filter.estimated_count(0.05).is_some(),
|
||||
"fill 0.5469 (FPR ≈ 0.049) must be accepted by cap 0.05"
|
||||
);
|
||||
|
||||
let mut above = vec![0x00u8; 1024];
|
||||
above[..564].fill(0xFF);
|
||||
let above_filter = BloomFilter::from_bytes(above, DEFAULT_HASH_COUNT).unwrap();
|
||||
assert_eq!(
|
||||
above_filter.estimated_count(0.05),
|
||||
None,
|
||||
"fill 0.5508 (FPR ≈ 0.051) must be rejected by cap 0.05"
|
||||
);
|
||||
|
||||
// Same above-cap filter with a looser cap is accepted.
|
||||
assert!(
|
||||
above_filter.estimated_count(0.10).is_some(),
|
||||
"fill 0.5508 (FPR ≈ 0.051) must be accepted by cap 0.10"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
4
src/cache/coord_cache.rs
vendored
4
src/cache/coord_cache.rs
vendored
@@ -6,10 +6,10 @@
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use super::entry::CacheEntry;
|
||||
use super::CacheStats;
|
||||
use crate::tree::TreeCoordinate;
|
||||
use super::entry::CacheEntry;
|
||||
use crate::NodeAddr;
|
||||
use crate::tree::TreeCoordinate;
|
||||
|
||||
/// Default maximum entries in coordinate cache.
|
||||
pub const DEFAULT_COORD_CACHE_SIZE: usize = 50_000;
|
||||
|
||||
@@ -34,7 +34,10 @@ pub use node::{
|
||||
TreeConfig,
|
||||
};
|
||||
pub use peer::{ConnectPolicy, PeerAddress, PeerConfig};
|
||||
pub use transport::{EthernetConfig, TcpConfig, TransportInstances, TransportsConfig, UdpConfig};
|
||||
pub use transport::{
|
||||
DirectoryServiceConfig, EthernetConfig, TcpConfig, TorConfig, TransportInstances,
|
||||
TransportsConfig, UdpConfig,
|
||||
};
|
||||
|
||||
/// Default config filename.
|
||||
const CONFIG_FILENAME: &str = "fips.yaml";
|
||||
@@ -539,10 +542,7 @@ node:
|
||||
override_config.node.identity.nsec = Some("override_nsec".to_string());
|
||||
|
||||
base.merge(override_config);
|
||||
assert_eq!(
|
||||
base.node.identity.nsec,
|
||||
Some("override_nsec".to_string())
|
||||
);
|
||||
assert_eq!(base.node.identity.nsec, Some("override_nsec".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -559,9 +559,8 @@ node:
|
||||
#[test]
|
||||
fn test_create_identity_from_nsec() {
|
||||
let mut config = Config::new();
|
||||
config.node.identity.nsec = Some(
|
||||
"0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20".to_string(),
|
||||
);
|
||||
config.node.identity.nsec =
|
||||
Some("0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20".to_string());
|
||||
|
||||
let identity = config.create_identity().unwrap();
|
||||
assert!(!identity.npub().is_empty());
|
||||
@@ -660,9 +659,11 @@ node:
|
||||
assert!(paths.iter().any(|p| p.ends_with("fips.yaml")));
|
||||
|
||||
// Should include /etc/fips
|
||||
assert!(paths
|
||||
.iter()
|
||||
.any(|p| p.starts_with("/etc/fips") && p.ends_with("fips.yaml")));
|
||||
assert!(
|
||||
paths
|
||||
.iter()
|
||||
.any(|p| p.starts_with("/etc/fips") && p.ends_with("fips.yaml"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -747,16 +748,21 @@ node:
|
||||
#[test]
|
||||
fn test_key_file_path_derivation() {
|
||||
let config_path = PathBuf::from("/etc/fips/fips.yaml");
|
||||
assert_eq!(key_file_path(&config_path), PathBuf::from("/etc/fips/fips.key"));
|
||||
assert_eq!(pub_file_path(&config_path), PathBuf::from("/etc/fips/fips.pub"));
|
||||
assert_eq!(
|
||||
key_file_path(&config_path),
|
||||
PathBuf::from("/etc/fips/fips.key")
|
||||
);
|
||||
assert_eq!(
|
||||
pub_file_path(&config_path),
|
||||
PathBuf::from("/etc/fips/fips.pub")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_identity_from_config() {
|
||||
let mut config = Config::new();
|
||||
config.node.identity.nsec = Some(
|
||||
"0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20".to_string(),
|
||||
);
|
||||
config.node.identity.nsec =
|
||||
Some("0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20".to_string());
|
||||
|
||||
let resolved = resolve_identity(&config, &[]).unwrap();
|
||||
assert!(matches!(resolved.source, IdentitySource::Config));
|
||||
@@ -803,11 +809,7 @@ node:
|
||||
let config_path = temp_dir.path().join("fips.yaml");
|
||||
let key_path = temp_dir.path().join("fips.key");
|
||||
|
||||
fs::write(
|
||||
&config_path,
|
||||
"node:\n identity:\n persistent: true\n",
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(&config_path, "node:\n identity:\n persistent: true\n").unwrap();
|
||||
|
||||
// Write a key file
|
||||
let identity = crate::Identity::generate();
|
||||
@@ -827,11 +829,7 @@ node:
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let config_path = temp_dir.path().join("fips.yaml");
|
||||
|
||||
fs::write(
|
||||
&config_path,
|
||||
"node:\n identity:\n persistent: true\n",
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(&config_path, "node:\n identity:\n persistent: true\n").unwrap();
|
||||
|
||||
let config = Config::load_file(&config_path).unwrap();
|
||||
let resolved = resolve_identity(&config, std::slice::from_ref(&config_path)).unwrap();
|
||||
@@ -892,8 +890,7 @@ transports:
|
||||
|
||||
assert_eq!(config.transports.udp.len(), 2);
|
||||
|
||||
let instances: std::collections::HashMap<_, _> =
|
||||
config.transports.udp.iter().collect();
|
||||
let instances: std::collections::HashMap<_, _> = config.transports.udp.iter().collect();
|
||||
|
||||
// Named instances have Some(name)
|
||||
assert!(instances.contains_key(&Some("main")));
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::IdentityConfig;
|
||||
use crate::mmp::{MmpConfig, MmpMode, DEFAULT_LOG_INTERVAL_SECS, DEFAULT_OWD_WINDOW_SIZE};
|
||||
use crate::mmp::{DEFAULT_LOG_INTERVAL_SECS, DEFAULT_OWD_WINDOW_SIZE, MmpConfig, MmpMode};
|
||||
|
||||
// ============================================================================
|
||||
// Node Configuration Subsections
|
||||
@@ -42,10 +42,18 @@ impl Default for LimitsConfig {
|
||||
}
|
||||
|
||||
impl LimitsConfig {
|
||||
fn default_max_connections() -> usize { 256 }
|
||||
fn default_max_peers() -> usize { 128 }
|
||||
fn default_max_links() -> usize { 256 }
|
||||
fn default_max_pending_inbound() -> usize { 1000 }
|
||||
fn default_max_connections() -> usize {
|
||||
256
|
||||
}
|
||||
fn default_max_peers() -> usize {
|
||||
128
|
||||
}
|
||||
fn default_max_links() -> usize {
|
||||
256
|
||||
}
|
||||
fn default_max_pending_inbound() -> usize {
|
||||
1000
|
||||
}
|
||||
}
|
||||
|
||||
/// Rate limiting (`node.rate_limit.*`).
|
||||
@@ -86,12 +94,24 @@ impl Default for RateLimitConfig {
|
||||
}
|
||||
|
||||
impl RateLimitConfig {
|
||||
fn default_handshake_burst() -> u32 { 100 }
|
||||
fn default_handshake_rate() -> f64 { 10.0 }
|
||||
fn default_handshake_timeout_secs() -> u64 { 30 }
|
||||
fn default_handshake_resend_interval_ms() -> u64 { 1000 }
|
||||
fn default_handshake_resend_backoff() -> f64 { 2.0 }
|
||||
fn default_handshake_max_resends() -> u32 { 5 }
|
||||
fn default_handshake_burst() -> u32 {
|
||||
100
|
||||
}
|
||||
fn default_handshake_rate() -> f64 {
|
||||
10.0
|
||||
}
|
||||
fn default_handshake_timeout_secs() -> u64 {
|
||||
30
|
||||
}
|
||||
fn default_handshake_resend_interval_ms() -> u64 {
|
||||
1000
|
||||
}
|
||||
fn default_handshake_resend_backoff() -> f64 {
|
||||
2.0
|
||||
}
|
||||
fn default_handshake_max_resends() -> u32 {
|
||||
5
|
||||
}
|
||||
}
|
||||
|
||||
/// Retry/backoff configuration (`node.retry.*`).
|
||||
@@ -119,9 +139,15 @@ impl Default for RetryConfig {
|
||||
}
|
||||
|
||||
impl RetryConfig {
|
||||
fn default_max_retries() -> u32 { 5 }
|
||||
fn default_base_interval_secs() -> u64 { 5 }
|
||||
fn default_max_backoff_secs() -> u64 { 300 }
|
||||
fn default_max_retries() -> u32 {
|
||||
5
|
||||
}
|
||||
fn default_base_interval_secs() -> u64 {
|
||||
5
|
||||
}
|
||||
fn default_max_backoff_secs() -> u64 {
|
||||
300
|
||||
}
|
||||
}
|
||||
|
||||
/// Cache parameters (`node.cache.*`).
|
||||
@@ -149,9 +175,15 @@ impl Default for CacheConfig {
|
||||
}
|
||||
|
||||
impl CacheConfig {
|
||||
fn default_coord_size() -> usize { 50_000 }
|
||||
fn default_coord_ttl_secs() -> u64 { 300 }
|
||||
fn default_identity_size() -> usize { 10_000 }
|
||||
fn default_coord_size() -> usize {
|
||||
50_000
|
||||
}
|
||||
fn default_coord_ttl_secs() -> u64 {
|
||||
300
|
||||
}
|
||||
fn default_identity_size() -> usize {
|
||||
10_000
|
||||
}
|
||||
}
|
||||
|
||||
/// Discovery protocol (`node.discovery.*`).
|
||||
@@ -166,6 +198,27 @@ pub struct DiscoveryConfig {
|
||||
/// Dedup cache expiry in seconds (`node.discovery.recent_expiry_secs`).
|
||||
#[serde(default = "DiscoveryConfig::default_recent_expiry_secs")]
|
||||
pub recent_expiry_secs: u64,
|
||||
/// Base backoff after first lookup failure in seconds (`node.discovery.backoff_base_secs`).
|
||||
/// Doubles per consecutive failure up to `backoff_max_secs`.
|
||||
#[serde(default = "DiscoveryConfig::default_backoff_base_secs")]
|
||||
pub backoff_base_secs: u64,
|
||||
/// Maximum backoff cap in seconds (`node.discovery.backoff_max_secs`).
|
||||
#[serde(default = "DiscoveryConfig::default_backoff_max_secs")]
|
||||
pub backoff_max_secs: u64,
|
||||
/// Minimum interval between forwarded lookups for the same target in seconds
|
||||
/// (`node.discovery.forward_min_interval_secs`).
|
||||
/// Defense-in-depth against misbehaving nodes.
|
||||
#[serde(default = "DiscoveryConfig::default_forward_min_interval_secs")]
|
||||
pub forward_min_interval_secs: u64,
|
||||
/// Retry interval within the timeout window in seconds
|
||||
/// (`node.discovery.retry_interval_secs`).
|
||||
/// After this interval without a response, resend the lookup.
|
||||
#[serde(default = "DiscoveryConfig::default_retry_interval_secs")]
|
||||
pub retry_interval_secs: u64,
|
||||
/// Maximum attempts per lookup (`node.discovery.max_attempts`).
|
||||
/// 1 = no retry, 2 = one retry, etc.
|
||||
#[serde(default = "DiscoveryConfig::default_max_attempts")]
|
||||
pub max_attempts: u8,
|
||||
}
|
||||
|
||||
impl Default for DiscoveryConfig {
|
||||
@@ -174,14 +227,40 @@ impl Default for DiscoveryConfig {
|
||||
ttl: 64,
|
||||
timeout_secs: 10,
|
||||
recent_expiry_secs: 10,
|
||||
backoff_base_secs: 30,
|
||||
backoff_max_secs: 300,
|
||||
forward_min_interval_secs: 2,
|
||||
retry_interval_secs: 5,
|
||||
max_attempts: 2,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl DiscoveryConfig {
|
||||
fn default_ttl() -> u8 { 64 }
|
||||
fn default_timeout_secs() -> u64 { 10 }
|
||||
fn default_recent_expiry_secs() -> u64 { 10 }
|
||||
fn default_ttl() -> u8 {
|
||||
64
|
||||
}
|
||||
fn default_timeout_secs() -> u64 {
|
||||
10
|
||||
}
|
||||
fn default_recent_expiry_secs() -> u64 {
|
||||
10
|
||||
}
|
||||
fn default_backoff_base_secs() -> u64 {
|
||||
30
|
||||
}
|
||||
fn default_backoff_max_secs() -> u64 {
|
||||
300
|
||||
}
|
||||
fn default_forward_min_interval_secs() -> u64 {
|
||||
2
|
||||
}
|
||||
fn default_retry_interval_secs() -> u64 {
|
||||
5
|
||||
}
|
||||
fn default_max_attempts() -> u8 {
|
||||
2
|
||||
}
|
||||
}
|
||||
|
||||
/// Spanning tree (`node.tree.*`).
|
||||
@@ -236,13 +315,27 @@ impl Default for TreeConfig {
|
||||
}
|
||||
|
||||
impl TreeConfig {
|
||||
fn default_announce_min_interval_ms() -> u64 { 500 }
|
||||
fn default_parent_hysteresis() -> f64 { 0.2 }
|
||||
fn default_hold_down_secs() -> u64 { 30 }
|
||||
fn default_reeval_interval_secs() -> u64 { 60 }
|
||||
fn default_flap_threshold() -> u32 { 4 }
|
||||
fn default_flap_window_secs() -> u64 { 60 }
|
||||
fn default_flap_dampening_secs() -> u64 { 120 }
|
||||
fn default_announce_min_interval_ms() -> u64 {
|
||||
500
|
||||
}
|
||||
fn default_parent_hysteresis() -> f64 {
|
||||
0.2
|
||||
}
|
||||
fn default_hold_down_secs() -> u64 {
|
||||
30
|
||||
}
|
||||
fn default_reeval_interval_secs() -> u64 {
|
||||
60
|
||||
}
|
||||
fn default_flap_threshold() -> u32 {
|
||||
4
|
||||
}
|
||||
fn default_flap_window_secs() -> u64 {
|
||||
60
|
||||
}
|
||||
fn default_flap_dampening_secs() -> u64 {
|
||||
120
|
||||
}
|
||||
}
|
||||
|
||||
/// Bloom filter (`node.bloom.*`).
|
||||
@@ -251,16 +344,31 @@ pub struct BloomConfig {
|
||||
/// Debounce interval for filter updates in ms (`node.bloom.update_debounce_ms`).
|
||||
#[serde(default = "BloomConfig::default_update_debounce_ms")]
|
||||
pub update_debounce_ms: u64,
|
||||
/// Antipoison cap: reject inbound FilterAnnounce whose FPR exceeds
|
||||
/// this value (`node.bloom.max_inbound_fpr`). Valid range `(0.0, 1.0)`.
|
||||
/// Default `0.05` ≈ fill 0.549 at k=5 ≈ ~3,200 entries on the 1KB
|
||||
/// filter. Conceptually distinct from future autoscaling hysteresis
|
||||
/// setpoints — same unit, different knobs.
|
||||
#[serde(default = "BloomConfig::default_max_inbound_fpr")]
|
||||
pub max_inbound_fpr: f64,
|
||||
}
|
||||
|
||||
impl Default for BloomConfig {
|
||||
fn default() -> Self {
|
||||
Self { update_debounce_ms: 500 }
|
||||
Self {
|
||||
update_debounce_ms: 500,
|
||||
max_inbound_fpr: 0.05,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl BloomConfig {
|
||||
fn default_update_debounce_ms() -> u64 { 500 }
|
||||
fn default_update_debounce_ms() -> u64 {
|
||||
500
|
||||
}
|
||||
fn default_max_inbound_fpr() -> f64 {
|
||||
0.05
|
||||
}
|
||||
}
|
||||
|
||||
/// Session/data plane (`node.session.*`).
|
||||
@@ -306,12 +414,24 @@ impl Default for SessionConfig {
|
||||
}
|
||||
|
||||
impl SessionConfig {
|
||||
fn default_ttl() -> u8 { 64 }
|
||||
fn default_pending_packets_per_dest() -> usize { 16 }
|
||||
fn default_pending_max_destinations() -> usize { 256 }
|
||||
fn default_idle_timeout_secs() -> u64 { 90 }
|
||||
fn default_coords_warmup_packets() -> u8 { 5 }
|
||||
fn default_coords_response_interval_ms() -> u64 { 2000 }
|
||||
fn default_ttl() -> u8 {
|
||||
64
|
||||
}
|
||||
fn default_pending_packets_per_dest() -> usize {
|
||||
16
|
||||
}
|
||||
fn default_pending_max_destinations() -> usize {
|
||||
256
|
||||
}
|
||||
fn default_idle_timeout_secs() -> u64 {
|
||||
90
|
||||
}
|
||||
fn default_coords_warmup_packets() -> u8 {
|
||||
5
|
||||
}
|
||||
fn default_coords_response_interval_ms() -> u64 {
|
||||
2000
|
||||
}
|
||||
}
|
||||
|
||||
/// Session-layer Metrics Measurement Protocol (`node.session_mmp.*`).
|
||||
@@ -346,8 +466,12 @@ impl Default for SessionMmpConfig {
|
||||
}
|
||||
|
||||
impl SessionMmpConfig {
|
||||
fn default_log_interval_secs() -> u64 { DEFAULT_LOG_INTERVAL_SECS }
|
||||
fn default_owd_window_size() -> usize { DEFAULT_OWD_WINDOW_SIZE }
|
||||
fn default_log_interval_secs() -> u64 {
|
||||
DEFAULT_LOG_INTERVAL_SECS
|
||||
}
|
||||
fn default_owd_window_size() -> usize {
|
||||
DEFAULT_OWD_WINDOW_SIZE
|
||||
}
|
||||
}
|
||||
|
||||
/// Control socket configuration (`node.control.*`).
|
||||
@@ -371,7 +495,9 @@ impl Default for ControlConfig {
|
||||
}
|
||||
|
||||
impl ControlConfig {
|
||||
fn default_enabled() -> bool { true }
|
||||
fn default_enabled() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn default_socket_path() -> String {
|
||||
if let Ok(runtime_dir) = std::env::var("XDG_RUNTIME_DIR") {
|
||||
@@ -409,9 +535,15 @@ impl Default for BuffersConfig {
|
||||
}
|
||||
|
||||
impl BuffersConfig {
|
||||
fn default_packet_channel() -> usize { 1024 }
|
||||
fn default_tun_channel() -> usize { 1024 }
|
||||
fn default_dns_channel() -> usize { 64 }
|
||||
fn default_packet_channel() -> usize {
|
||||
1024
|
||||
}
|
||||
fn default_tun_channel() -> usize {
|
||||
1024
|
||||
}
|
||||
fn default_dns_channel() -> usize {
|
||||
64
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@@ -449,9 +581,15 @@ impl Default for RekeyConfig {
|
||||
}
|
||||
|
||||
impl RekeyConfig {
|
||||
fn default_enabled() -> bool { true }
|
||||
fn default_after_secs() -> u64 { 120 }
|
||||
fn default_after_messages() -> u64 { 1 << 16 }
|
||||
fn default_enabled() -> bool {
|
||||
true
|
||||
}
|
||||
fn default_after_secs() -> u64 {
|
||||
120
|
||||
}
|
||||
fn default_after_messages() -> u64 {
|
||||
1 << 16
|
||||
}
|
||||
}
|
||||
|
||||
/// ECN congestion signaling configuration (`node.ecn.*`).
|
||||
@@ -489,9 +627,15 @@ impl Default for EcnConfig {
|
||||
}
|
||||
|
||||
impl EcnConfig {
|
||||
fn default_enabled() -> bool { true }
|
||||
fn default_loss_threshold() -> f64 { 0.05 }
|
||||
fn default_etx_threshold() -> f64 { 3.0 }
|
||||
fn default_enabled() -> bool {
|
||||
true
|
||||
}
|
||||
fn default_loss_threshold() -> f64 {
|
||||
0.05
|
||||
}
|
||||
fn default_etx_threshold() -> f64 {
|
||||
3.0
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@@ -611,10 +755,18 @@ impl Default for NodeConfig {
|
||||
}
|
||||
|
||||
impl NodeConfig {
|
||||
fn default_tick_interval_secs() -> u64 { 1 }
|
||||
fn default_base_rtt_ms() -> u64 { 100 }
|
||||
fn default_heartbeat_interval_secs() -> u64 { 10 }
|
||||
fn default_link_dead_timeout_secs() -> u64 { 30 }
|
||||
fn default_tick_interval_secs() -> u64 {
|
||||
1
|
||||
}
|
||||
fn default_base_rtt_ms() -> u64 {
|
||||
100
|
||||
}
|
||||
fn default_heartbeat_interval_secs() -> u64 {
|
||||
10
|
||||
}
|
||||
fn default_link_dead_timeout_secs() -> u64 {
|
||||
30
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -35,9 +35,9 @@ pub struct PeerAddress {
|
||||
/// Transport-specific address string.
|
||||
///
|
||||
/// Format depends on transport type:
|
||||
/// - UDP: "host:port" (e.g., "192.168.1.1:2121")
|
||||
/// - Tor: "onion_address:port" (e.g., "xyz...abc.onion:2121")
|
||||
/// - Ethernet: "interface/mac" (future)
|
||||
/// - UDP/TCP: "host:port" — IP address or DNS hostname
|
||||
/// (e.g., "192.168.1.1:2121" or "peer1.example.com:2121")
|
||||
/// - Ethernet: "interface/mac" (e.g., "eth0/aa:bb:cc:dd:ee:ff")
|
||||
pub addr: String,
|
||||
|
||||
/// Priority for address selection (lower = preferred).
|
||||
@@ -66,7 +66,11 @@ impl PeerAddress {
|
||||
}
|
||||
|
||||
/// Create a new peer address with priority.
|
||||
pub fn with_priority(transport: impl Into<String>, addr: impl Into<String>, priority: u8) -> Self {
|
||||
pub fn with_priority(
|
||||
transport: impl Into<String>,
|
||||
addr: impl Into<String>,
|
||||
priority: u8,
|
||||
) -> Self {
|
||||
Self {
|
||||
transport: transport.into(),
|
||||
addr: addr.into(),
|
||||
@@ -118,7 +122,11 @@ impl Default for PeerConfig {
|
||||
|
||||
impl PeerConfig {
|
||||
/// Create a new peer config with a single address.
|
||||
pub fn new(npub: impl Into<String>, transport: impl Into<String>, addr: impl Into<String>) -> Self {
|
||||
pub fn new(
|
||||
npub: impl Into<String>,
|
||||
transport: impl Into<String>,
|
||||
addr: impl Into<String>,
|
||||
) -> Self {
|
||||
Self {
|
||||
npub: npub.into(),
|
||||
alias: None,
|
||||
|
||||
@@ -112,15 +112,12 @@ impl<T> TransportInstances<T> {
|
||||
/// Named instances have `Some(name)`.
|
||||
pub fn iter(&self) -> impl Iterator<Item = (Option<&str>, &T)> {
|
||||
match self {
|
||||
TransportInstances::Single(config) => {
|
||||
vec![(None, config)].into_iter()
|
||||
}
|
||||
TransportInstances::Named(map) => {
|
||||
map.iter()
|
||||
.map(|(k, v)| (Some(k.as_str()), v))
|
||||
.collect::<Vec<_>>()
|
||||
.into_iter()
|
||||
}
|
||||
TransportInstances::Single(config) => vec![(None, config)].into_iter(),
|
||||
TransportInstances::Named(map) => map
|
||||
.iter()
|
||||
.map(|(k, v)| (Some(k.as_str()), v))
|
||||
.collect::<Vec<_>>()
|
||||
.into_iter(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -293,10 +290,6 @@ pub struct TcpConfig {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub send_buf_size: Option<usize>,
|
||||
|
||||
/// SOCKS5 proxy for outbound connections (placeholder; not yet implemented).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub socks5_proxy: Option<String>,
|
||||
|
||||
/// Maximum simultaneous inbound connections. Defaults to 256.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub max_inbound_connections: Option<usize>,
|
||||
@@ -310,7 +303,8 @@ impl TcpConfig {
|
||||
|
||||
/// Get the connect timeout in milliseconds.
|
||||
pub fn connect_timeout_ms(&self) -> u64 {
|
||||
self.connect_timeout_ms.unwrap_or(DEFAULT_TCP_CONNECT_TIMEOUT_MS)
|
||||
self.connect_timeout_ms
|
||||
.unwrap_or(DEFAULT_TCP_CONNECT_TIMEOUT_MS)
|
||||
}
|
||||
|
||||
/// Whether TCP_NODELAY is enabled. Default: true.
|
||||
@@ -335,7 +329,183 @@ impl TcpConfig {
|
||||
|
||||
/// Get the maximum number of inbound connections. Default: 256.
|
||||
pub fn max_inbound_connections(&self) -> usize {
|
||||
self.max_inbound_connections.unwrap_or(DEFAULT_TCP_MAX_INBOUND)
|
||||
self.max_inbound_connections
|
||||
.unwrap_or(DEFAULT_TCP_MAX_INBOUND)
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Tor Transport Configuration
|
||||
// ============================================================================
|
||||
|
||||
/// Default Tor SOCKS5 proxy address.
|
||||
const DEFAULT_TOR_SOCKS5_ADDR: &str = "127.0.0.1:9050";
|
||||
|
||||
/// Default Tor control port address.
|
||||
const DEFAULT_TOR_CONTROL_ADDR: &str = "/run/tor/control";
|
||||
|
||||
/// Default Tor control cookie file path (Debian standard location).
|
||||
const DEFAULT_TOR_COOKIE_PATH: &str = "/var/run/tor/control.authcookie";
|
||||
|
||||
/// Default Tor connect timeout in milliseconds (120s — Tor circuit
|
||||
/// establishment can take 30-60s on first connect, plus SOCKS5 handshake).
|
||||
const DEFAULT_TOR_CONNECT_TIMEOUT_MS: u64 = 120_000;
|
||||
|
||||
/// Default Tor MTU (same as TCP).
|
||||
const DEFAULT_TOR_MTU: u16 = 1400;
|
||||
|
||||
/// Default max inbound connections via onion service.
|
||||
const DEFAULT_TOR_MAX_INBOUND: usize = 64;
|
||||
|
||||
/// Default HiddenServiceDir hostname file path.
|
||||
const DEFAULT_HOSTNAME_FILE: &str = "/var/lib/tor/fips_onion_service/hostname";
|
||||
|
||||
/// Default directory mode bind address.
|
||||
const DEFAULT_DIRECTORY_BIND_ADDR: &str = "127.0.0.1:8443";
|
||||
|
||||
/// Tor transport instance configuration.
|
||||
///
|
||||
/// Supports three modes:
|
||||
/// - `socks5`: Outbound-only connections through a Tor SOCKS5 proxy.
|
||||
/// - `control_port`: Full bidirectional support — outbound via SOCKS5
|
||||
/// plus inbound via Tor onion service managed through the control port.
|
||||
/// - `directory`: Full bidirectional support — outbound via SOCKS5,
|
||||
/// inbound via a Tor-managed `HiddenServiceDir` onion service. No
|
||||
/// control port needed. Enables Tor `Sandbox 1` mode.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct TorConfig {
|
||||
/// Tor access mode: "socks5", "control_port", or "directory".
|
||||
/// Default: "socks5".
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub mode: Option<String>,
|
||||
|
||||
/// SOCKS5 proxy address (host:port). Defaults to "127.0.0.1:9050".
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub socks5_addr: Option<String>,
|
||||
|
||||
/// Outbound connect timeout in milliseconds. Defaults to 120000 (120s).
|
||||
/// Tor circuit establishment can take 30-60s, so this must be generous.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub connect_timeout_ms: Option<u64>,
|
||||
|
||||
/// Default MTU for Tor connections. Defaults to 1400.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub mtu: Option<u16>,
|
||||
|
||||
/// Control port address: a Unix socket path (`/run/tor/control`) or
|
||||
/// TCP address (`host:port`). Unix sockets are preferred for security.
|
||||
/// Defaults to "/run/tor/control".
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub control_addr: Option<String>,
|
||||
|
||||
/// Control port authentication method:
|
||||
/// `"cookie"` (read from default path),
|
||||
/// `"cookie:/path/to/cookie"` (read from specified path), or
|
||||
/// `"password:secret"` (password auth). Default: `"cookie"`.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub control_auth: Option<String>,
|
||||
|
||||
/// Path to the Tor control cookie file. Used when control_auth is "cookie".
|
||||
/// Defaults to "/var/run/tor/control.authcookie".
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub cookie_path: Option<String>,
|
||||
|
||||
/// Maximum number of inbound connections via onion service. Default: 64.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub max_inbound_connections: Option<usize>,
|
||||
|
||||
/// Directory-mode onion service configuration. Only valid in
|
||||
/// "directory" mode. Tor manages the onion service via HiddenServiceDir
|
||||
/// in torrc; fips reads the .onion hostname from a file.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub directory_service: Option<DirectoryServiceConfig>,
|
||||
}
|
||||
|
||||
/// Directory-mode onion service configuration.
|
||||
///
|
||||
/// In `directory` mode, Tor manages the onion service via `HiddenServiceDir`
|
||||
/// in torrc. FIPS reads the `.onion` address from the hostname file and
|
||||
/// binds a local TCP listener for Tor to forward inbound connections to.
|
||||
/// This mode requires no control port and enables Tor's `Sandbox 1`.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct DirectoryServiceConfig {
|
||||
/// Path to the Tor-managed hostname file containing the .onion address.
|
||||
/// Defaults to "/var/lib/tor/fips_onion_service/hostname".
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub hostname_file: Option<String>,
|
||||
|
||||
/// Local bind address for the listener that Tor forwards inbound
|
||||
/// connections to. Must match the target in torrc's `HiddenServicePort`.
|
||||
/// Defaults to "127.0.0.1:8443".
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub bind_addr: Option<String>,
|
||||
}
|
||||
|
||||
impl DirectoryServiceConfig {
|
||||
/// Path to the hostname file. Default: "/var/lib/tor/fips_onion_service/hostname".
|
||||
pub fn hostname_file(&self) -> &str {
|
||||
self.hostname_file
|
||||
.as_deref()
|
||||
.unwrap_or(DEFAULT_HOSTNAME_FILE)
|
||||
}
|
||||
|
||||
/// Local bind address for the listener. Default: "127.0.0.1:8443".
|
||||
pub fn bind_addr(&self) -> &str {
|
||||
self.bind_addr
|
||||
.as_deref()
|
||||
.unwrap_or(DEFAULT_DIRECTORY_BIND_ADDR)
|
||||
}
|
||||
}
|
||||
|
||||
impl TorConfig {
|
||||
/// Get the access mode. Default: "socks5".
|
||||
pub fn mode(&self) -> &str {
|
||||
self.mode.as_deref().unwrap_or("socks5")
|
||||
}
|
||||
|
||||
/// Get the SOCKS5 proxy address. Default: "127.0.0.1:9050".
|
||||
pub fn socks5_addr(&self) -> &str {
|
||||
self.socks5_addr
|
||||
.as_deref()
|
||||
.unwrap_or(DEFAULT_TOR_SOCKS5_ADDR)
|
||||
}
|
||||
|
||||
/// Get the control port address. Default: "/run/tor/control".
|
||||
pub fn control_addr(&self) -> &str {
|
||||
self.control_addr
|
||||
.as_deref()
|
||||
.unwrap_or(DEFAULT_TOR_CONTROL_ADDR)
|
||||
}
|
||||
|
||||
/// Get the control auth string. Default: "cookie".
|
||||
pub fn control_auth(&self) -> &str {
|
||||
self.control_auth.as_deref().unwrap_or("cookie")
|
||||
}
|
||||
|
||||
/// Get the cookie file path. Default: "/var/run/tor/control.authcookie".
|
||||
pub fn cookie_path(&self) -> &str {
|
||||
self.cookie_path
|
||||
.as_deref()
|
||||
.unwrap_or(DEFAULT_TOR_COOKIE_PATH)
|
||||
}
|
||||
|
||||
/// Get the connect timeout in milliseconds. Default: 120000.
|
||||
pub fn connect_timeout_ms(&self) -> u64 {
|
||||
self.connect_timeout_ms
|
||||
.unwrap_or(DEFAULT_TOR_CONNECT_TIMEOUT_MS)
|
||||
}
|
||||
|
||||
/// Get the default MTU. Default: 1400.
|
||||
pub fn mtu(&self) -> u16 {
|
||||
self.mtu.unwrap_or(DEFAULT_TOR_MTU)
|
||||
}
|
||||
|
||||
/// Get the max inbound connections. Default: 64.
|
||||
pub fn max_inbound_connections(&self) -> usize {
|
||||
self.max_inbound_connections
|
||||
.unwrap_or(DEFAULT_TOR_MAX_INBOUND)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -360,6 +530,10 @@ pub struct TransportsConfig {
|
||||
/// TCP transport instances.
|
||||
#[serde(default, skip_serializing_if = "is_transport_empty")]
|
||||
pub tcp: TransportInstances<TcpConfig>,
|
||||
|
||||
/// Tor transport instances.
|
||||
#[serde(default, skip_serializing_if = "is_transport_empty")]
|
||||
pub tor: TransportInstances<TorConfig>,
|
||||
}
|
||||
|
||||
/// Helper for skip_serializing_if on TransportInstances.
|
||||
@@ -370,7 +544,10 @@ fn is_transport_empty<T>(instances: &TransportInstances<T>) -> bool {
|
||||
impl TransportsConfig {
|
||||
/// Check if any transports are configured.
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.udp.is_empty() && self.ethernet.is_empty() && self.tcp.is_empty()
|
||||
self.udp.is_empty()
|
||||
&& self.ethernet.is_empty()
|
||||
&& self.tcp.is_empty()
|
||||
&& self.tor.is_empty()
|
||||
}
|
||||
|
||||
/// Merge another TransportsConfig into this one.
|
||||
@@ -386,5 +563,8 @@ impl TransportsConfig {
|
||||
if !other.tcp.is_empty() {
|
||||
self.tcp = other.tcp;
|
||||
}
|
||||
if !other.tor.is_empty() {
|
||||
self.tor = other.tor;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
68
src/control/commands.rs
Normal file
68
src/control/commands.rs
Normal file
@@ -0,0 +1,68 @@
|
||||
//! Mutating control socket commands.
|
||||
//!
|
||||
//! Commands that modify node state (connect, disconnect) are handled here,
|
||||
//! separate from read-only queries in `queries.rs`.
|
||||
|
||||
use super::protocol::Response;
|
||||
use crate::node::Node;
|
||||
use serde_json::Value;
|
||||
use tracing::info;
|
||||
|
||||
/// Dispatch a mutating command to the appropriate handler.
|
||||
pub async fn dispatch(node: &mut Node, command: &str, params: Option<&Value>) -> Response {
|
||||
match command {
|
||||
"connect" => connect(node, params).await,
|
||||
"disconnect" => disconnect(node, params),
|
||||
_ => Response::error(format!("unknown command: {command}")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Connect to a peer.
|
||||
///
|
||||
/// Params: `{"npub": "npub1...", "address": "host:port", "transport": "udp"}`
|
||||
async fn connect(node: &mut Node, params: Option<&Value>) -> Response {
|
||||
let Some(params) = params else {
|
||||
return Response::error("missing params for connect");
|
||||
};
|
||||
|
||||
let npub = match params.get("npub").and_then(|v| v.as_str()) {
|
||||
Some(v) => v,
|
||||
None => return Response::error("missing 'npub' parameter"),
|
||||
};
|
||||
let address = match params.get("address").and_then(|v| v.as_str()) {
|
||||
Some(v) => v,
|
||||
None => return Response::error("missing 'address' parameter"),
|
||||
};
|
||||
let transport = match params.get("transport").and_then(|v| v.as_str()) {
|
||||
Some(v) => v,
|
||||
None => return Response::error("missing 'transport' parameter"),
|
||||
};
|
||||
|
||||
info!(npub = %npub, address = %address, transport = %transport, "API connect requested");
|
||||
|
||||
match node.api_connect(npub, address, transport).await {
|
||||
Ok(data) => Response::ok(data),
|
||||
Err(msg) => Response::error(msg),
|
||||
}
|
||||
}
|
||||
|
||||
/// Disconnect a peer.
|
||||
///
|
||||
/// Params: `{"npub": "npub1..."}`
|
||||
fn disconnect(node: &mut Node, params: Option<&Value>) -> Response {
|
||||
let Some(params) = params else {
|
||||
return Response::error("missing params for disconnect");
|
||||
};
|
||||
|
||||
let npub = match params.get("npub").and_then(|v| v.as_str()) {
|
||||
Some(v) => v,
|
||||
None => return Response::error("missing 'npub' parameter"),
|
||||
};
|
||||
|
||||
info!(npub = %npub, "API disconnect requested");
|
||||
|
||||
match node.api_disconnect(npub) {
|
||||
Ok(data) => Response::ok(data),
|
||||
Err(msg) => Response::error(msg),
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
//! Control socket for runtime observability.
|
||||
//! Control socket for runtime management and observability.
|
||||
//!
|
||||
//! Provides a Unix domain socket that accepts query commands and returns
|
||||
//! structured JSON data about the node's current state. Read-only queries
|
||||
//! only — no state mutation through this channel.
|
||||
//! Provides a Unix domain socket that accepts commands and returns
|
||||
//! structured JSON responses. Supports both read-only queries (show_*)
|
||||
//! and mutating commands (connect, disconnect).
|
||||
|
||||
pub mod commands;
|
||||
pub mod protocol;
|
||||
pub mod queries;
|
||||
|
||||
@@ -54,6 +55,16 @@ impl ControlSocket {
|
||||
}
|
||||
|
||||
let listener = UnixListener::bind(&socket_path)?;
|
||||
|
||||
// Make the socket and its parent directory group-accessible so
|
||||
// 'fips' group members can use fipsctl/fipstop without root.
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
std::fs::set_permissions(&socket_path, std::fs::Permissions::from_mode(0o770))?;
|
||||
Self::chown_to_fips_group(&socket_path);
|
||||
if let Some(parent) = socket_path.parent() {
|
||||
Self::chown_to_fips_group(parent);
|
||||
}
|
||||
|
||||
info!(path = %socket_path.display(), "Control socket listening");
|
||||
|
||||
Ok(Self {
|
||||
@@ -85,6 +96,37 @@ impl ControlSocket {
|
||||
}
|
||||
}
|
||||
|
||||
/// Set group ownership of a path to the 'fips' group (best-effort).
|
||||
fn chown_to_fips_group(path: &Path) {
|
||||
use std::ffi::CString;
|
||||
use std::os::unix::ffi::OsStrExt;
|
||||
|
||||
// Look up the 'fips' group
|
||||
let group_name = CString::new("fips").unwrap();
|
||||
let grp = unsafe { libc::getgrnam(group_name.as_ptr()) };
|
||||
if grp.is_null() {
|
||||
debug!(
|
||||
"'fips' group not found, skipping chown for {}",
|
||||
path.display()
|
||||
);
|
||||
return;
|
||||
}
|
||||
let gid = unsafe { (*grp).gr_gid };
|
||||
|
||||
let c_path = match CString::new(path.as_os_str().as_bytes()) {
|
||||
Ok(p) => p,
|
||||
Err(_) => return,
|
||||
};
|
||||
let ret = unsafe { libc::chown(c_path.as_ptr(), u32::MAX, gid) };
|
||||
if ret != 0 {
|
||||
warn!(
|
||||
path = %path.display(),
|
||||
error = %std::io::Error::last_os_error(),
|
||||
"Failed to chown control socket to 'fips' group"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Run the accept loop, forwarding requests to the main event loop via mpsc.
|
||||
///
|
||||
/// Each accepted connection is handled in a spawned task:
|
||||
@@ -145,9 +187,7 @@ impl ControlSocket {
|
||||
.await;
|
||||
|
||||
let response = match read_result {
|
||||
Ok(Ok(())) if line.is_empty() => {
|
||||
Response::error("empty request")
|
||||
}
|
||||
Ok(Ok(())) if line.is_empty() => Response::error("empty request"),
|
||||
Ok(Ok(())) => {
|
||||
// Parse the request
|
||||
match serde_json::from_str::<Request>(line.trim()) {
|
||||
|
||||
@@ -8,8 +8,11 @@ use serde::{Deserialize, Serialize};
|
||||
/// A control request from a client.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct Request {
|
||||
/// The command to execute (e.g., "show_status", "show_peers").
|
||||
/// The command to execute (e.g., "show_status", "connect").
|
||||
pub command: String,
|
||||
/// Optional parameters for mutating commands.
|
||||
#[serde(default)]
|
||||
pub params: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
/// A control response to a client.
|
||||
@@ -81,6 +84,24 @@ mod tests {
|
||||
assert_eq!(req.command, "show_peers");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deserialize_request_with_params() {
|
||||
let json = r#"{"command": "connect", "params": {"npub": "npub1abc", "address": "1.2.3.4:2121", "transport": "udp"}}"#;
|
||||
let req: Request = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(req.command, "connect");
|
||||
let params = req.params.unwrap();
|
||||
assert_eq!(params["npub"], "npub1abc");
|
||||
assert_eq!(params["transport"], "udp");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deserialize_request_without_params() {
|
||||
let json = r#"{"command": "show_status"}"#;
|
||||
let req: Request = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(req.command, "show_status");
|
||||
assert!(req.params.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deserialize_malformed_request() {
|
||||
let json = r#"{"not_command": "foo"}"#;
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
use crate::identity::encode_npub;
|
||||
use crate::node::Node;
|
||||
use serde_json::{json, Value};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
/// Helper: get current Unix time in milliseconds.
|
||||
fn now_ms() -> u64 {
|
||||
@@ -70,113 +70,120 @@ pub fn show_peers(node: &Node) -> Value {
|
||||
let parent_id = *tree.my_declaration().parent_id();
|
||||
let is_root = tree.is_root();
|
||||
|
||||
let peers: Vec<Value> = node.peers().map(|peer| {
|
||||
let node_addr = *peer.node_addr();
|
||||
let addr_hex = hex::encode(node_addr.as_bytes());
|
||||
let peers: Vec<Value> = node
|
||||
.peers()
|
||||
.map(|peer| {
|
||||
let node_addr = *peer.node_addr();
|
||||
let addr_hex = hex::encode(node_addr.as_bytes());
|
||||
|
||||
// Determine tree relationship
|
||||
let is_parent = !is_root && node_addr == parent_id;
|
||||
let is_child = tree.peer_declaration(&node_addr)
|
||||
.is_some_and(|decl| *decl.parent_id() == my_addr);
|
||||
// Determine tree relationship
|
||||
let is_parent = !is_root && node_addr == parent_id;
|
||||
let is_child = tree
|
||||
.peer_declaration(&node_addr)
|
||||
.is_some_and(|decl| *decl.parent_id() == my_addr);
|
||||
|
||||
let mut peer_json = json!({
|
||||
"node_addr": addr_hex,
|
||||
"npub": peer.npub(),
|
||||
"display_name": node.peer_display_name(&node_addr),
|
||||
"ipv6_addr": format!("{}", peer.address()),
|
||||
"connectivity": format!("{}", peer.connectivity()),
|
||||
"link_id": peer.link_id().as_u64(),
|
||||
"authenticated_at_ms": peer.authenticated_at(),
|
||||
"last_seen_ms": peer.last_seen(),
|
||||
"has_tree_position": peer.has_tree_position(),
|
||||
"has_bloom_filter": peer.filter_sequence() > 0,
|
||||
"filter_sequence": peer.filter_sequence(),
|
||||
"is_parent": is_parent,
|
||||
"is_child": is_child,
|
||||
});
|
||||
|
||||
// Add transport address if available
|
||||
if let Some(addr) = peer.current_addr() {
|
||||
peer_json["transport_addr"] = json!(format!("{}", addr));
|
||||
}
|
||||
|
||||
// Add link info (direction, transport type)
|
||||
let link_id = peer.link_id();
|
||||
if let Some(link) = node.get_link(&link_id) {
|
||||
peer_json["direction"] = json!(format!("{}", link.direction()));
|
||||
let transport_id = link.transport_id();
|
||||
if let Some(handle) = node.get_transport(&transport_id) {
|
||||
peer_json["transport_type"] = json!(handle.transport_type().name);
|
||||
}
|
||||
}
|
||||
|
||||
// Add tree depth if available
|
||||
if let Some(coords) = peer.coords() {
|
||||
peer_json["tree_depth"] = json!(coords.depth());
|
||||
}
|
||||
|
||||
// Add link stats
|
||||
let stats = peer.link_stats();
|
||||
peer_json["stats"] = json!({
|
||||
"packets_sent": stats.packets_sent,
|
||||
"packets_recv": stats.packets_recv,
|
||||
"bytes_sent": stats.bytes_sent,
|
||||
"bytes_recv": stats.bytes_recv,
|
||||
});
|
||||
|
||||
// Add MMP metrics if available
|
||||
if let Some(mmp) = peer.mmp() {
|
||||
let mut mmp_json = json!({
|
||||
"mode": format!("{}", mmp.mode()),
|
||||
let mut peer_json = json!({
|
||||
"node_addr": addr_hex,
|
||||
"npub": peer.npub(),
|
||||
"display_name": node.peer_display_name(&node_addr),
|
||||
"ipv6_addr": format!("{}", peer.address()),
|
||||
"connectivity": format!("{}", peer.connectivity()),
|
||||
"link_id": peer.link_id().as_u64(),
|
||||
"authenticated_at_ms": peer.authenticated_at(),
|
||||
"last_seen_ms": peer.last_seen(),
|
||||
"has_tree_position": peer.has_tree_position(),
|
||||
"has_bloom_filter": peer.filter_sequence() > 0,
|
||||
"filter_sequence": peer.filter_sequence(),
|
||||
"is_parent": is_parent,
|
||||
"is_child": is_child,
|
||||
});
|
||||
if let Some(srtt) = mmp.metrics.srtt_ms() {
|
||||
mmp_json["srtt_ms"] = json!(srtt);
|
||||
}
|
||||
mmp_json["loss_rate"] = json!(mmp.metrics.loss_rate());
|
||||
mmp_json["etx"] = json!(mmp.metrics.etx);
|
||||
mmp_json["goodput_bps"] = json!(mmp.metrics.goodput_bps);
|
||||
mmp_json["delivery_ratio_forward"] = json!(mmp.metrics.delivery_ratio_forward);
|
||||
mmp_json["delivery_ratio_reverse"] = json!(mmp.metrics.delivery_ratio_reverse);
|
||||
if let Some(smoothed_loss) = mmp.metrics.smoothed_loss() {
|
||||
mmp_json["smoothed_loss"] = json!(smoothed_loss);
|
||||
}
|
||||
if let Some(smoothed_etx) = mmp.metrics.smoothed_etx() {
|
||||
mmp_json["smoothed_etx"] = json!(smoothed_etx);
|
||||
}
|
||||
if let Some(srtt) = mmp.metrics.srtt_ms()
|
||||
&& let Some(setx) = mmp.metrics.smoothed_etx()
|
||||
{
|
||||
mmp_json["lqi"] = json!(setx * (1.0 + srtt / 100.0));
|
||||
}
|
||||
peer_json["mmp"] = mmp_json;
|
||||
}
|
||||
|
||||
peer_json
|
||||
}).collect();
|
||||
// Add transport address if available
|
||||
if let Some(addr) = peer.current_addr() {
|
||||
peer_json["transport_addr"] = json!(format!("{}", addr));
|
||||
}
|
||||
|
||||
// Add link info (direction, transport type)
|
||||
let link_id = peer.link_id();
|
||||
if let Some(link) = node.get_link(&link_id) {
|
||||
peer_json["direction"] = json!(format!("{}", link.direction()));
|
||||
let transport_id = link.transport_id();
|
||||
if let Some(handle) = node.get_transport(&transport_id) {
|
||||
peer_json["transport_type"] = json!(handle.transport_type().name);
|
||||
}
|
||||
}
|
||||
|
||||
// Add tree depth if available
|
||||
if let Some(coords) = peer.coords() {
|
||||
peer_json["tree_depth"] = json!(coords.depth());
|
||||
}
|
||||
|
||||
// Add link stats
|
||||
let stats = peer.link_stats();
|
||||
peer_json["stats"] = json!({
|
||||
"packets_sent": stats.packets_sent,
|
||||
"packets_recv": stats.packets_recv,
|
||||
"bytes_sent": stats.bytes_sent,
|
||||
"bytes_recv": stats.bytes_recv,
|
||||
});
|
||||
|
||||
// Add MMP metrics if available
|
||||
if let Some(mmp) = peer.mmp() {
|
||||
let mut mmp_json = json!({
|
||||
"mode": format!("{}", mmp.mode()),
|
||||
});
|
||||
if let Some(srtt) = mmp.metrics.srtt_ms() {
|
||||
mmp_json["srtt_ms"] = json!(srtt);
|
||||
}
|
||||
mmp_json["loss_rate"] = json!(mmp.metrics.loss_rate());
|
||||
mmp_json["etx"] = json!(mmp.metrics.etx);
|
||||
mmp_json["goodput_bps"] = json!(mmp.metrics.goodput_bps);
|
||||
mmp_json["delivery_ratio_forward"] = json!(mmp.metrics.delivery_ratio_forward);
|
||||
mmp_json["delivery_ratio_reverse"] = json!(mmp.metrics.delivery_ratio_reverse);
|
||||
if let Some(smoothed_loss) = mmp.metrics.smoothed_loss() {
|
||||
mmp_json["smoothed_loss"] = json!(smoothed_loss);
|
||||
}
|
||||
if let Some(smoothed_etx) = mmp.metrics.smoothed_etx() {
|
||||
mmp_json["smoothed_etx"] = json!(smoothed_etx);
|
||||
}
|
||||
if let Some(srtt) = mmp.metrics.srtt_ms()
|
||||
&& let Some(setx) = mmp.metrics.smoothed_etx()
|
||||
{
|
||||
mmp_json["lqi"] = json!(setx * (1.0 + srtt / 100.0));
|
||||
}
|
||||
peer_json["mmp"] = mmp_json;
|
||||
}
|
||||
|
||||
peer_json
|
||||
})
|
||||
.collect();
|
||||
|
||||
json!({ "peers": peers })
|
||||
}
|
||||
|
||||
/// `show_links` — Active links.
|
||||
pub fn show_links(node: &Node) -> Value {
|
||||
let links: Vec<Value> = node.links().map(|link| {
|
||||
let stats = link.stats();
|
||||
json!({
|
||||
"link_id": link.link_id().as_u64(),
|
||||
"transport_id": link.transport_id().as_u32(),
|
||||
"remote_addr": format!("{}", link.remote_addr()),
|
||||
"direction": format!("{}", link.direction()),
|
||||
"state": format!("{}", link.state()),
|
||||
"created_at_ms": link.created_at(),
|
||||
"stats": {
|
||||
"packets_sent": stats.packets_sent,
|
||||
"packets_recv": stats.packets_recv,
|
||||
"bytes_sent": stats.bytes_sent,
|
||||
"bytes_recv": stats.bytes_recv,
|
||||
"last_recv_ms": stats.last_recv_ms,
|
||||
},
|
||||
let links: Vec<Value> = node
|
||||
.links()
|
||||
.map(|link| {
|
||||
let stats = link.stats();
|
||||
json!({
|
||||
"link_id": link.link_id().as_u64(),
|
||||
"transport_id": link.transport_id().as_u32(),
|
||||
"remote_addr": format!("{}", link.remote_addr()),
|
||||
"direction": format!("{}", link.direction()),
|
||||
"state": format!("{}", link.state()),
|
||||
"created_at_ms": link.created_at(),
|
||||
"stats": {
|
||||
"packets_sent": stats.packets_sent,
|
||||
"packets_recv": stats.packets_recv,
|
||||
"bytes_sent": stats.bytes_sent,
|
||||
"bytes_recv": stats.bytes_recv,
|
||||
"last_recv_ms": stats.last_recv_ms,
|
||||
},
|
||||
})
|
||||
})
|
||||
}).collect();
|
||||
.collect();
|
||||
|
||||
json!({ "links": links })
|
||||
}
|
||||
@@ -188,29 +195,34 @@ pub fn show_tree(node: &Node) -> Value {
|
||||
let decl = tree.my_declaration();
|
||||
|
||||
// Build coords array as hex strings
|
||||
let coords: Vec<String> = my_coords.entries()
|
||||
let coords: Vec<String> = my_coords
|
||||
.entries()
|
||||
.iter()
|
||||
.map(|e| hex::encode(e.node_addr.as_bytes()))
|
||||
.collect();
|
||||
|
||||
// Build peer tree data
|
||||
let peers: Vec<Value> = tree.peer_ids().map(|peer_id| {
|
||||
let mut peer_json = json!({
|
||||
"node_addr": hex::encode(peer_id.as_bytes()),
|
||||
"display_name": node.peer_display_name(peer_id),
|
||||
});
|
||||
if let Some(coords) = tree.peer_coords(peer_id) {
|
||||
let coord_path: Vec<String> = coords.entries()
|
||||
.iter()
|
||||
.map(|e| hex::encode(e.node_addr.as_bytes()))
|
||||
.collect();
|
||||
peer_json["depth"] = json!(coords.depth());
|
||||
peer_json["root"] = json!(hex::encode(coords.root_id().as_bytes()));
|
||||
peer_json["coords"] = json!(coord_path);
|
||||
peer_json["distance_to_us"] = json!(my_coords.distance_to(coords));
|
||||
}
|
||||
peer_json
|
||||
}).collect();
|
||||
let peers: Vec<Value> = tree
|
||||
.peer_ids()
|
||||
.map(|peer_id| {
|
||||
let mut peer_json = json!({
|
||||
"node_addr": hex::encode(peer_id.as_bytes()),
|
||||
"display_name": node.peer_display_name(peer_id),
|
||||
});
|
||||
if let Some(coords) = tree.peer_coords(peer_id) {
|
||||
let coord_path: Vec<String> = coords
|
||||
.entries()
|
||||
.iter()
|
||||
.map(|e| hex::encode(e.node_addr.as_bytes()))
|
||||
.collect();
|
||||
peer_json["depth"] = json!(coords.depth());
|
||||
peer_json["root"] = json!(hex::encode(coords.root_id().as_bytes()));
|
||||
peer_json["coords"] = json!(coord_path);
|
||||
peer_json["distance_to_us"] = json!(my_coords.distance_to(coords));
|
||||
}
|
||||
peer_json
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Determine parent display name
|
||||
let parent_addr = my_coords.parent_id();
|
||||
@@ -237,68 +249,71 @@ pub fn show_tree(node: &Node) -> Value {
|
||||
|
||||
/// `show_sessions` — End-to-end sessions.
|
||||
pub fn show_sessions(node: &Node) -> Value {
|
||||
let sessions: Vec<Value> = node.session_entries().map(|(addr, entry)| {
|
||||
let state_str = if entry.is_established() {
|
||||
"established"
|
||||
} else if entry.is_initiating() {
|
||||
"initiating"
|
||||
} else if entry.is_awaiting_msg3() {
|
||||
"awaiting_msg3"
|
||||
} else {
|
||||
"unknown"
|
||||
};
|
||||
let sessions: Vec<Value> = node
|
||||
.session_entries()
|
||||
.map(|(addr, entry)| {
|
||||
let state_str = if entry.is_established() {
|
||||
"established"
|
||||
} else if entry.is_initiating() {
|
||||
"initiating"
|
||||
} else if entry.is_awaiting_msg3() {
|
||||
"awaiting_msg3"
|
||||
} else {
|
||||
"unknown"
|
||||
};
|
||||
|
||||
let mut session_json = json!({
|
||||
"remote_addr": hex::encode(addr.as_bytes()),
|
||||
"display_name": node.peer_display_name(addr),
|
||||
"state": state_str,
|
||||
"is_initiator": entry.is_initiator(),
|
||||
"last_activity_ms": entry.last_activity(),
|
||||
});
|
||||
|
||||
// Derive npub from session's remote public key
|
||||
let (xonly, _parity) = entry.remote_pubkey().x_only_public_key();
|
||||
session_json["npub"] = json!(encode_npub(&xonly));
|
||||
|
||||
// Traffic counters
|
||||
let (pkts_tx, pkts_rx, bytes_tx, bytes_rx) = entry.traffic_counters();
|
||||
session_json["stats"] = json!({
|
||||
"packets_sent": pkts_tx,
|
||||
"packets_recv": pkts_rx,
|
||||
"bytes_sent": bytes_tx,
|
||||
"bytes_recv": bytes_rx,
|
||||
});
|
||||
|
||||
// Add session MMP if available
|
||||
if let Some(mmp) = entry.mmp() {
|
||||
let mut mmp_json = json!({
|
||||
"mode": format!("{}", mmp.mode()),
|
||||
"loss_rate": mmp.metrics.loss_rate(),
|
||||
"etx": mmp.metrics.etx,
|
||||
"goodput_bps": mmp.metrics.goodput_bps,
|
||||
"delivery_ratio_forward": mmp.metrics.delivery_ratio_forward,
|
||||
"delivery_ratio_reverse": mmp.metrics.delivery_ratio_reverse,
|
||||
"path_mtu": mmp.path_mtu.current_mtu(),
|
||||
let mut session_json = json!({
|
||||
"remote_addr": hex::encode(addr.as_bytes()),
|
||||
"display_name": node.peer_display_name(addr),
|
||||
"state": state_str,
|
||||
"is_initiator": entry.is_initiator(),
|
||||
"last_activity_ms": entry.last_activity(),
|
||||
});
|
||||
if let Some(srtt) = mmp.metrics.srtt_ms() {
|
||||
mmp_json["srtt_ms"] = json!(srtt);
|
||||
}
|
||||
if let Some(smoothed_loss) = mmp.metrics.smoothed_loss() {
|
||||
mmp_json["smoothed_loss"] = json!(smoothed_loss);
|
||||
}
|
||||
if let Some(smoothed_etx) = mmp.metrics.smoothed_etx() {
|
||||
mmp_json["smoothed_etx"] = json!(smoothed_etx);
|
||||
}
|
||||
if let Some(srtt) = mmp.metrics.srtt_ms()
|
||||
&& let Some(setx) = mmp.metrics.smoothed_etx()
|
||||
{
|
||||
mmp_json["sqi"] = json!(setx * (1.0 + srtt / 100.0));
|
||||
}
|
||||
session_json["mmp"] = mmp_json;
|
||||
}
|
||||
|
||||
session_json
|
||||
}).collect();
|
||||
// Derive npub from session's remote public key
|
||||
let (xonly, _parity) = entry.remote_pubkey().x_only_public_key();
|
||||
session_json["npub"] = json!(encode_npub(&xonly));
|
||||
|
||||
// Traffic counters
|
||||
let (pkts_tx, pkts_rx, bytes_tx, bytes_rx) = entry.traffic_counters();
|
||||
session_json["stats"] = json!({
|
||||
"packets_sent": pkts_tx,
|
||||
"packets_recv": pkts_rx,
|
||||
"bytes_sent": bytes_tx,
|
||||
"bytes_recv": bytes_rx,
|
||||
});
|
||||
|
||||
// Add session MMP if available
|
||||
if let Some(mmp) = entry.mmp() {
|
||||
let mut mmp_json = json!({
|
||||
"mode": format!("{}", mmp.mode()),
|
||||
"loss_rate": mmp.metrics.loss_rate(),
|
||||
"etx": mmp.metrics.etx,
|
||||
"goodput_bps": mmp.metrics.goodput_bps,
|
||||
"delivery_ratio_forward": mmp.metrics.delivery_ratio_forward,
|
||||
"delivery_ratio_reverse": mmp.metrics.delivery_ratio_reverse,
|
||||
"path_mtu": mmp.path_mtu.current_mtu(),
|
||||
});
|
||||
if let Some(srtt) = mmp.metrics.srtt_ms() {
|
||||
mmp_json["srtt_ms"] = json!(srtt);
|
||||
}
|
||||
if let Some(smoothed_loss) = mmp.metrics.smoothed_loss() {
|
||||
mmp_json["smoothed_loss"] = json!(smoothed_loss);
|
||||
}
|
||||
if let Some(smoothed_etx) = mmp.metrics.smoothed_etx() {
|
||||
mmp_json["smoothed_etx"] = json!(smoothed_etx);
|
||||
}
|
||||
if let Some(srtt) = mmp.metrics.srtt_ms()
|
||||
&& let Some(setx) = mmp.metrics.smoothed_etx()
|
||||
{
|
||||
mmp_json["sqi"] = json!(setx * (1.0 + srtt / 100.0));
|
||||
}
|
||||
session_json["mmp"] = mmp_json;
|
||||
}
|
||||
|
||||
session_json
|
||||
})
|
||||
.collect();
|
||||
|
||||
json!({ "sessions": sessions })
|
||||
}
|
||||
@@ -307,27 +322,32 @@ pub fn show_sessions(node: &Node) -> Value {
|
||||
pub fn show_bloom(node: &Node) -> Value {
|
||||
let bloom = node.bloom_state();
|
||||
|
||||
let leaf_deps: Vec<String> = bloom.leaf_dependents()
|
||||
let leaf_deps: Vec<String> = bloom
|
||||
.leaf_dependents()
|
||||
.iter()
|
||||
.map(|addr| hex::encode(addr.as_bytes()))
|
||||
.collect();
|
||||
|
||||
// Build per-peer filter info
|
||||
let peer_filters: Vec<Value> = node.peers().map(|peer| {
|
||||
let addr = *peer.node_addr();
|
||||
let mut pf = json!({
|
||||
"peer": hex::encode(addr.as_bytes()),
|
||||
"display_name": node.peer_display_name(&addr),
|
||||
"has_filter": peer.filter_sequence() > 0,
|
||||
"filter_sequence": peer.filter_sequence(),
|
||||
});
|
||||
if let Some(filter) = peer.inbound_filter() {
|
||||
pf["estimated_count"] = json!(filter.estimated_count());
|
||||
pf["set_bits"] = json!(filter.count_ones());
|
||||
pf["fill_ratio"] = json!(filter.fill_ratio());
|
||||
}
|
||||
pf
|
||||
}).collect();
|
||||
let peer_filters: Vec<Value> = node
|
||||
.peers()
|
||||
.map(|peer| {
|
||||
let addr = *peer.node_addr();
|
||||
let mut pf = json!({
|
||||
"peer": hex::encode(addr.as_bytes()),
|
||||
"display_name": node.peer_display_name(&addr),
|
||||
"has_filter": peer.filter_sequence() > 0,
|
||||
"filter_sequence": peer.filter_sequence(),
|
||||
});
|
||||
if let Some(filter) = peer.inbound_filter() {
|
||||
let max_fpr = node.config().node.bloom.max_inbound_fpr;
|
||||
pf["estimated_count"] = json!(filter.estimated_count(max_fpr));
|
||||
pf["set_bits"] = json!(filter.count_ones());
|
||||
pf["fill_ratio"] = json!(filter.fill_ratio());
|
||||
}
|
||||
pf
|
||||
})
|
||||
.collect();
|
||||
|
||||
let bloom_stats = node.stats().snapshot().bloom;
|
||||
|
||||
@@ -397,36 +417,39 @@ pub fn show_mmp(node: &Node) -> Value {
|
||||
}).collect();
|
||||
|
||||
// Session-layer MMP
|
||||
let sessions: Vec<Value> = node.session_entries().filter_map(|(addr, entry)| {
|
||||
let mmp = entry.mmp()?;
|
||||
let metrics = &mmp.metrics;
|
||||
let sessions: Vec<Value> = node
|
||||
.session_entries()
|
||||
.filter_map(|(addr, entry)| {
|
||||
let mmp = entry.mmp()?;
|
||||
let metrics = &mmp.metrics;
|
||||
|
||||
let mut session_layer = json!({
|
||||
"loss_rate": metrics.loss_rate(),
|
||||
"etx": metrics.etx,
|
||||
"path_mtu": mmp.path_mtu.current_mtu(),
|
||||
});
|
||||
let mut session_layer = json!({
|
||||
"loss_rate": metrics.loss_rate(),
|
||||
"etx": metrics.etx,
|
||||
"path_mtu": mmp.path_mtu.current_mtu(),
|
||||
});
|
||||
|
||||
if let Some(smoothed_loss) = metrics.smoothed_loss() {
|
||||
session_layer["smoothed_loss"] = json!(smoothed_loss);
|
||||
}
|
||||
if let Some(smoothed_etx) = metrics.smoothed_etx() {
|
||||
session_layer["smoothed_etx"] = json!(smoothed_etx);
|
||||
}
|
||||
if let Some(srtt) = metrics.srtt_ms() {
|
||||
session_layer["srtt_ms"] = json!(srtt);
|
||||
if let Some(setx) = metrics.smoothed_etx() {
|
||||
session_layer["sqi"] = json!(setx * (1.0 + srtt / 100.0));
|
||||
if let Some(smoothed_loss) = metrics.smoothed_loss() {
|
||||
session_layer["smoothed_loss"] = json!(smoothed_loss);
|
||||
}
|
||||
if let Some(smoothed_etx) = metrics.smoothed_etx() {
|
||||
session_layer["smoothed_etx"] = json!(smoothed_etx);
|
||||
}
|
||||
if let Some(srtt) = metrics.srtt_ms() {
|
||||
session_layer["srtt_ms"] = json!(srtt);
|
||||
if let Some(setx) = metrics.smoothed_etx() {
|
||||
session_layer["sqi"] = json!(setx * (1.0 + srtt / 100.0));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Some(json!({
|
||||
"remote": hex::encode(addr.as_bytes()),
|
||||
"display_name": node.peer_display_name(addr),
|
||||
"mode": format!("{}", mmp.mode()),
|
||||
"session_layer": session_layer,
|
||||
}))
|
||||
}).collect();
|
||||
Some(json!({
|
||||
"remote": hex::encode(addr.as_bytes()),
|
||||
"display_name": node.peer_display_name(addr),
|
||||
"mode": format!("{}", mmp.mode()),
|
||||
"session_layer": session_layer,
|
||||
}))
|
||||
})
|
||||
.collect();
|
||||
|
||||
json!({
|
||||
"peers": peers,
|
||||
@@ -452,48 +475,65 @@ pub fn show_cache(node: &Node) -> Value {
|
||||
/// `show_connections` — Pending handshakes.
|
||||
pub fn show_connections(node: &Node) -> Value {
|
||||
let now = now_ms();
|
||||
let connections: Vec<Value> = node.connections().map(|conn| {
|
||||
let mut conn_json = json!({
|
||||
"link_id": conn.link_id().as_u64(),
|
||||
"direction": format!("{}", conn.direction()),
|
||||
"handshake_state": format!("{}", conn.handshake_state()),
|
||||
"started_at_ms": conn.started_at(),
|
||||
"idle_ms": now.saturating_sub(conn.last_activity()),
|
||||
"resend_count": conn.resend_count(),
|
||||
});
|
||||
let connections: Vec<Value> = node
|
||||
.connections()
|
||||
.map(|conn| {
|
||||
let mut conn_json = json!({
|
||||
"link_id": conn.link_id().as_u64(),
|
||||
"direction": format!("{}", conn.direction()),
|
||||
"handshake_state": format!("{}", conn.handshake_state()),
|
||||
"started_at_ms": conn.started_at(),
|
||||
"idle_ms": now.saturating_sub(conn.last_activity()),
|
||||
"resend_count": conn.resend_count(),
|
||||
});
|
||||
|
||||
if let Some(identity) = conn.expected_identity() {
|
||||
conn_json["expected_peer"] = json!(identity.npub());
|
||||
}
|
||||
if let Some(identity) = conn.expected_identity() {
|
||||
conn_json["expected_peer"] = json!(identity.npub());
|
||||
}
|
||||
|
||||
conn_json
|
||||
}).collect();
|
||||
conn_json
|
||||
})
|
||||
.collect();
|
||||
|
||||
json!({ "connections": connections })
|
||||
}
|
||||
|
||||
/// `show_transports` — Transport instances.
|
||||
pub fn show_transports(node: &Node) -> Value {
|
||||
let transports: Vec<Value> = node.transport_ids().map(|id| {
|
||||
let handle = node.get_transport(id).unwrap();
|
||||
let mut t_json = json!({
|
||||
"transport_id": id.as_u32(),
|
||||
"type": handle.transport_type().name,
|
||||
"state": format!("{}", handle.state()),
|
||||
"mtu": handle.mtu(),
|
||||
});
|
||||
let transports: Vec<Value> = node
|
||||
.transport_ids()
|
||||
.map(|id| {
|
||||
let handle = node.get_transport(id).unwrap();
|
||||
let mut t_json = json!({
|
||||
"transport_id": id.as_u32(),
|
||||
"type": handle.transport_type().name,
|
||||
"state": format!("{}", handle.state()),
|
||||
"mtu": handle.mtu(),
|
||||
});
|
||||
|
||||
if let Some(name) = handle.name() {
|
||||
t_json["name"] = json!(name);
|
||||
}
|
||||
if let Some(addr) = handle.local_addr() {
|
||||
t_json["local_addr"] = json!(format!("{}", addr));
|
||||
}
|
||||
if let Some(name) = handle.name() {
|
||||
t_json["name"] = json!(name);
|
||||
}
|
||||
if let Some(addr) = handle.local_addr() {
|
||||
t_json["local_addr"] = json!(format!("{}", addr));
|
||||
}
|
||||
|
||||
t_json["stats"] = handle.transport_stats();
|
||||
// Tor-specific fields
|
||||
if let Some(mode) = handle.tor_mode() {
|
||||
t_json["tor_mode"] = json!(mode);
|
||||
}
|
||||
if let Some(onion) = handle.onion_address() {
|
||||
t_json["onion_address"] = json!(onion);
|
||||
}
|
||||
if let Some(monitoring) = handle.tor_monitoring() {
|
||||
t_json["tor_monitoring"] = serde_json::to_value(&monitoring).unwrap_or_default();
|
||||
}
|
||||
|
||||
t_json
|
||||
}).collect();
|
||||
t_json["stats"] = handle.transport_stats();
|
||||
|
||||
t_json
|
||||
})
|
||||
.collect();
|
||||
|
||||
json!({ "transports": transports })
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
use std::fmt;
|
||||
use std::net::Ipv6Addr;
|
||||
|
||||
use super::{IdentityError, NodeAddr, FIPS_ADDRESS_PREFIX};
|
||||
use super::{FIPS_ADDRESS_PREFIX, IdentityError, NodeAddr};
|
||||
|
||||
/// 128-bit FIPS address with IPv6-compatible format.
|
||||
///
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
use secp256k1::{Keypair, PublicKey, Secp256k1, SecretKey, XOnlyPublicKey};
|
||||
use std::fmt;
|
||||
|
||||
use super::auth::{auth_challenge_digest, AuthResponse};
|
||||
use super::auth::{AuthResponse, auth_challenge_digest};
|
||||
use super::encoding::{decode_secret, encode_npub};
|
||||
use super::{sha256, FipsAddress, IdentityError, NodeAddr};
|
||||
use super::{FipsAddress, IdentityError, NodeAddr, sha256};
|
||||
|
||||
/// A FIPS node identity consisting of a keypair and derived identifiers.
|
||||
///
|
||||
@@ -22,8 +22,8 @@ impl Identity {
|
||||
pub fn generate() -> Self {
|
||||
let mut secret_bytes = [0u8; 32];
|
||||
rand::Rng::fill_bytes(&mut rand::rng(), &mut secret_bytes);
|
||||
let secret_key = SecretKey::from_slice(&secret_bytes)
|
||||
.expect("32 random bytes is a valid secret key");
|
||||
let secret_key =
|
||||
SecretKey::from_slice(&secret_bytes).expect("32 random bytes is a valid secret key");
|
||||
Self::from_secret_key(secret_key)
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ use secp256k1::XOnlyPublicKey;
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::fmt;
|
||||
|
||||
use super::{hex_encode, IdentityError};
|
||||
use super::{IdentityError, hex_encode};
|
||||
|
||||
/// 16-byte node identifier derived from truncated SHA-256(pubkey).
|
||||
///
|
||||
|
||||
@@ -4,7 +4,7 @@ use secp256k1::{Parity, PublicKey, Secp256k1, XOnlyPublicKey};
|
||||
use std::fmt;
|
||||
|
||||
use super::encoding::{decode_npub, encode_npub};
|
||||
use super::{sha256, FipsAddress, IdentityError, NodeAddr};
|
||||
use super::{FipsAddress, IdentityError, NodeAddr, sha256};
|
||||
|
||||
/// A known peer's identity (public key only, no signing capability).
|
||||
///
|
||||
@@ -99,7 +99,8 @@ impl PeerIdentity {
|
||||
pub fn verify(&self, data: &[u8], signature: &secp256k1::schnorr::Signature) -> bool {
|
||||
let secp = Secp256k1::new();
|
||||
let digest = sha256(data);
|
||||
secp.verify_schnorr(signature, &digest, &self.pubkey).is_ok()
|
||||
secp.verify_schnorr(signature, &digest, &self.pubkey)
|
||||
.is_ok()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -110,9 +110,9 @@ fn test_node_addr_ordering() {
|
||||
fn test_identity_from_secret_bytes() {
|
||||
// A known secret key (32 bytes)
|
||||
let secret_bytes: [u8; 32] = [
|
||||
0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e,
|
||||
0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b, 0x1c,
|
||||
0x1d, 0x1e, 0x1f, 0x20,
|
||||
0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f,
|
||||
0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e,
|
||||
0x1f, 0x20,
|
||||
];
|
||||
|
||||
let identity1 = Identity::from_secret_bytes(&secret_bytes).unwrap();
|
||||
@@ -163,9 +163,10 @@ fn test_identity_sign() {
|
||||
// Verify the signature manually
|
||||
let secp = secp256k1::Secp256k1::new();
|
||||
let digest = super::sha256(data);
|
||||
assert!(secp
|
||||
.verify_schnorr(&sig, &digest, &identity.pubkey())
|
||||
.is_ok());
|
||||
assert!(
|
||||
secp.verify_schnorr(&sig, &digest, &identity.pubkey())
|
||||
.is_ok()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -193,9 +194,9 @@ fn test_npub_roundtrip() {
|
||||
fn test_npub_known_vector() {
|
||||
// Test against a known npub (from NIP-19 test vectors or generated externally)
|
||||
let secret_bytes: [u8; 32] = [
|
||||
0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e,
|
||||
0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b, 0x1c,
|
||||
0x1d, 0x1e, 0x1f, 0x20,
|
||||
0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f,
|
||||
0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e,
|
||||
0x1f, 0x20,
|
||||
];
|
||||
|
||||
let identity = Identity::from_secret_bytes(&secret_bytes).unwrap();
|
||||
@@ -274,9 +275,9 @@ fn test_peer_identity_display() {
|
||||
#[test]
|
||||
fn test_nsec_roundtrip() {
|
||||
let secret_bytes: [u8; 32] = [
|
||||
0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e,
|
||||
0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b, 0x1c,
|
||||
0x1d, 0x1e, 0x1f, 0x20,
|
||||
0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f,
|
||||
0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e,
|
||||
0x1f, 0x20,
|
||||
];
|
||||
|
||||
let secret_key = SecretKey::from_slice(&secret_bytes).unwrap();
|
||||
@@ -301,9 +302,9 @@ fn test_decode_nsec_invalid_prefix() {
|
||||
#[test]
|
||||
fn test_decode_secret_nsec() {
|
||||
let secret_bytes: [u8; 32] = [
|
||||
0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e,
|
||||
0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b, 0x1c,
|
||||
0x1d, 0x1e, 0x1f, 0x20,
|
||||
0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f,
|
||||
0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e,
|
||||
0x1f, 0x20,
|
||||
];
|
||||
|
||||
let secret_key = SecretKey::from_slice(&secret_bytes).unwrap();
|
||||
@@ -319,9 +320,9 @@ fn test_decode_secret_hex() {
|
||||
let decoded = decode_secret(hex_str).unwrap();
|
||||
|
||||
let expected: [u8; 32] = [
|
||||
0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e,
|
||||
0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b, 0x1c,
|
||||
0x1d, 0x1e, 0x1f, 0x20,
|
||||
0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f,
|
||||
0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e,
|
||||
0x1f, 0x20,
|
||||
];
|
||||
assert_eq!(decoded.secret_bytes(), expected);
|
||||
}
|
||||
@@ -329,9 +330,9 @@ fn test_decode_secret_hex() {
|
||||
#[test]
|
||||
fn test_identity_from_secret_str_nsec() {
|
||||
let secret_bytes: [u8; 32] = [
|
||||
0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e,
|
||||
0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b, 0x1c,
|
||||
0x1d, 0x1e, 0x1f, 0x20,
|
||||
0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f,
|
||||
0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e,
|
||||
0x1f, 0x20,
|
||||
];
|
||||
|
||||
let secret_key = SecretKey::from_slice(&secret_bytes).unwrap();
|
||||
@@ -347,9 +348,9 @@ fn test_identity_from_secret_str_nsec() {
|
||||
fn test_identity_from_secret_str_hex() {
|
||||
let hex_str = "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20";
|
||||
let secret_bytes: [u8; 32] = [
|
||||
0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e,
|
||||
0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b, 0x1c,
|
||||
0x1d, 0x1e, 0x1f, 0x20,
|
||||
0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f,
|
||||
0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e,
|
||||
0x1f, 0x20,
|
||||
];
|
||||
|
||||
let identity = Identity::from_secret_str(hex_str).unwrap();
|
||||
@@ -379,11 +380,8 @@ fn test_hex_conversion_case2() {
|
||||
#[test]
|
||||
fn test_decode_npub_invalid_length() {
|
||||
// Encode 16 bytes (too short) as bech32 with npub prefix
|
||||
let short = bech32::encode::<bech32::Bech32>(
|
||||
bech32::Hrp::parse_unchecked("npub"),
|
||||
&[0u8; 16],
|
||||
)
|
||||
.unwrap();
|
||||
let short =
|
||||
bech32::encode::<bech32::Bech32>(bech32::Hrp::parse_unchecked("npub"), &[0u8; 16]).unwrap();
|
||||
let result = decode_npub(&short);
|
||||
assert!(matches!(result, Err(IdentityError::InvalidNpubLength(16))));
|
||||
}
|
||||
@@ -391,11 +389,8 @@ fn test_decode_npub_invalid_length() {
|
||||
#[test]
|
||||
fn test_decode_nsec_invalid_length() {
|
||||
// Encode 16 bytes (too short) as bech32 with nsec prefix
|
||||
let short = bech32::encode::<bech32::Bech32>(
|
||||
bech32::Hrp::parse_unchecked("nsec"),
|
||||
&[0u8; 16],
|
||||
)
|
||||
.unwrap();
|
||||
let short =
|
||||
bech32::encode::<bech32::Bech32>(bech32::Hrp::parse_unchecked("nsec"), &[0u8; 16]).unwrap();
|
||||
let result = decode_nsec(&short);
|
||||
assert!(matches!(result, Err(IdentityError::InvalidNsecLength(16))));
|
||||
}
|
||||
@@ -418,8 +413,8 @@ fn test_decode_secret_hex_invalid_chars() {
|
||||
#[test]
|
||||
fn test_node_addr_debug() {
|
||||
let bytes = [
|
||||
0x01, 0x23, 0x45, 0x67, 0x89, 0xab, 0xcd, 0xef, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
0x00, 0x00,
|
||||
0x01, 0x23, 0x45, 0x67, 0x89, 0xab, 0xcd, 0xef, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
0x00,
|
||||
];
|
||||
let node_addr = NodeAddr::from_bytes(bytes);
|
||||
let debug = format!("{:?}", node_addr);
|
||||
@@ -429,8 +424,8 @@ fn test_node_addr_debug() {
|
||||
#[test]
|
||||
fn test_node_addr_display() {
|
||||
let bytes = [
|
||||
0x01, 0x23, 0x45, 0x67, 0x89, 0xab, 0xcd, 0xef, 0xfe, 0xdc, 0xba, 0x98, 0x76, 0x54,
|
||||
0x32, 0x10,
|
||||
0x01, 0x23, 0x45, 0x67, 0x89, 0xab, 0xcd, 0xef, 0xfe, 0xdc, 0xba, 0x98, 0x76, 0x54, 0x32,
|
||||
0x10,
|
||||
];
|
||||
let node_addr = NodeAddr::from_bytes(bytes);
|
||||
let display = format!("{}", node_addr);
|
||||
@@ -587,9 +582,9 @@ fn test_peer_identity_pubkey_full_preserved_parity() {
|
||||
// Create two identities and find one with odd parity to make this test meaningful
|
||||
let secp = Secp256k1::new();
|
||||
let secret_bytes: [u8; 32] = [
|
||||
0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e,
|
||||
0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b, 0x1c,
|
||||
0x1d, 0x1e, 0x1f, 0x20,
|
||||
0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f,
|
||||
0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e,
|
||||
0x1f, 0x20,
|
||||
];
|
||||
let keypair = Keypair::from_seckey_slice(&secp, &secret_bytes).unwrap();
|
||||
let full_pubkey = keypair.public_key();
|
||||
|
||||
33
src/lib.rs
33
src/lib.rs
@@ -3,30 +3,30 @@
|
||||
//! A distributed, decentralized network routing protocol for mesh nodes
|
||||
//! connecting over arbitrary transports.
|
||||
|
||||
pub mod version;
|
||||
pub mod bloom;
|
||||
pub mod cache;
|
||||
pub mod config;
|
||||
pub mod control;
|
||||
pub mod identity;
|
||||
pub mod mmp;
|
||||
pub mod noise;
|
||||
pub mod utils;
|
||||
pub mod node;
|
||||
pub mod noise;
|
||||
pub mod peer;
|
||||
pub mod protocol;
|
||||
pub mod transport;
|
||||
pub mod tree;
|
||||
pub mod upper;
|
||||
pub mod utils;
|
||||
pub mod version;
|
||||
|
||||
// Re-export identity types
|
||||
pub use identity::{
|
||||
decode_npub, decode_nsec, decode_secret, encode_npub, encode_nsec, AuthChallenge, AuthResponse,
|
||||
FipsAddress, Identity, IdentityError, NodeAddr, PeerIdentity,
|
||||
AuthChallenge, AuthResponse, FipsAddress, Identity, IdentityError, NodeAddr, PeerIdentity,
|
||||
decode_npub, decode_nsec, decode_secret, encode_npub, encode_nsec,
|
||||
};
|
||||
|
||||
// Re-export config types
|
||||
pub use config::{Config, ConfigError, IdentityConfig, UdpConfig};
|
||||
pub use config::{Config, ConfigError, IdentityConfig, TorConfig, UdpConfig};
|
||||
pub use upper::config::{DnsConfig, TunConfig};
|
||||
|
||||
// Re-export tree types
|
||||
@@ -36,18 +36,18 @@ pub use tree::{CoordEntry, ParentDeclaration, TreeCoordinate, TreeError, TreeSta
|
||||
pub use bloom::{BloomError, BloomFilter, BloomState};
|
||||
|
||||
// Re-export transport types
|
||||
pub use transport::{
|
||||
packet_channel, DiscoveredPeer, Link, LinkDirection, LinkId, LinkState, LinkStats, PacketRx,
|
||||
PacketTx, ReceivedPacket, Transport, TransportAddr, TransportError, TransportHandle,
|
||||
TransportId, TransportState, TransportType,
|
||||
};
|
||||
pub use transport::udp::UdpTransport;
|
||||
pub use transport::{
|
||||
DiscoveredPeer, Link, LinkDirection, LinkId, LinkState, LinkStats, PacketRx, PacketTx,
|
||||
ReceivedPacket, Transport, TransportAddr, TransportError, TransportHandle, TransportId,
|
||||
TransportState, TransportType, packet_channel,
|
||||
};
|
||||
|
||||
// Re-export protocol types
|
||||
pub use protocol::{
|
||||
CoordsRequired, FilterAnnounce, HandshakeMessageType, LinkMessageType,
|
||||
LookupRequest, LookupResponse, PathBroken, ProtocolError, SessionAck, SessionDatagram,
|
||||
SessionFlags, SessionMessageType, SessionSetup, TreeAnnounce,
|
||||
CoordsRequired, FilterAnnounce, HandshakeMessageType, LinkMessageType, LookupRequest,
|
||||
LookupResponse, PathBroken, ProtocolError, SessionAck, SessionDatagram, SessionFlags,
|
||||
SessionMessageType, SessionSetup, TreeAnnounce,
|
||||
};
|
||||
|
||||
// Re-export cache types
|
||||
@@ -55,10 +55,9 @@ pub use cache::{CacheEntry, CacheError, CacheStats, CoordCache};
|
||||
|
||||
// Re-export peer types
|
||||
pub use peer::{
|
||||
cross_connection_winner, ActivePeer, ConnectivityState, HandshakeState, PeerConnection,
|
||||
PeerError, PeerSlot, PromotionResult,
|
||||
ActivePeer, ConnectivityState, HandshakeState, PeerConnection, PeerError, PeerSlot,
|
||||
PromotionResult, cross_connection_winner,
|
||||
};
|
||||
|
||||
// Re-export node types
|
||||
pub use node::{Node, NodeError, NodeState};
|
||||
|
||||
|
||||
@@ -192,6 +192,11 @@ impl OwdTrendDetector {
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear all samples, keeping the same capacity.
|
||||
pub fn clear(&mut self) {
|
||||
self.samples.clear();
|
||||
}
|
||||
|
||||
/// Add an OWD sample.
|
||||
///
|
||||
/// `seq` is a monotonic sequence number (e.g., truncated frame counter).
|
||||
@@ -366,7 +371,10 @@ mod tests {
|
||||
}
|
||||
// Should converge near 1000µs
|
||||
let jitter = j.jitter_us();
|
||||
assert!(jitter > 900 && jitter < 1100, "jitter={jitter}, expected ~1000");
|
||||
assert!(
|
||||
jitter > 900 && jitter < 1100,
|
||||
"jitter={jitter}, expected ~1000"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -386,10 +394,7 @@ mod tests {
|
||||
s.update(50_000);
|
||||
}
|
||||
let srtt = s.srtt_us();
|
||||
assert!(
|
||||
(srtt - 50_000).abs() < 1000,
|
||||
"srtt={srtt}, expected ~50000"
|
||||
);
|
||||
assert!((srtt - 50_000).abs() < 1000, "srtt={srtt}, expected ~50000");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -412,7 +417,12 @@ mod tests {
|
||||
e.update(100.0);
|
||||
}
|
||||
// Short should be closer to 100 than long
|
||||
assert!(e.short() > e.long(), "short={} long={}", e.short(), e.long());
|
||||
assert!(
|
||||
e.short() > e.long(),
|
||||
"short={} long={}",
|
||||
e.short(),
|
||||
e.long()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -432,7 +442,10 @@ mod tests {
|
||||
d.push(i, 5000 + (i as i64) * 100); // increasing by 100µs per packet
|
||||
}
|
||||
let trend = d.trend_us_per_sec();
|
||||
assert!(trend > 0, "increasing OWD should have positive trend, got {trend}");
|
||||
assert!(
|
||||
trend > 0,
|
||||
"increasing OWD should have positive trend, got {trend}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -45,9 +45,39 @@ pub struct MmpMetrics {
|
||||
prev_rr_reorder: u32,
|
||||
/// Time of previous ReceiverReport (for goodput rate computation).
|
||||
prev_rr_time: Option<Instant>,
|
||||
/// Whether we have a previous ReceiverReport for delta computation.
|
||||
has_prev_rr: bool,
|
||||
|
||||
// --- State for reverse delivery ratio delta computation ---
|
||||
/// Previous reverse-side cumulative packets received (our receiver state).
|
||||
prev_reverse_packets: u64,
|
||||
/// Previous reverse-side highest counter (our receiver state).
|
||||
prev_reverse_highest: u64,
|
||||
/// Whether we have a previous reverse-side snapshot for delta computation.
|
||||
has_prev_reverse: bool,
|
||||
}
|
||||
|
||||
impl MmpMetrics {
|
||||
/// Reset state derived from ReceiverReport counters for rekey cutover.
|
||||
///
|
||||
/// The new session starts with counter 0, so the prev_rr deltas must
|
||||
/// be reset to avoid computing bogus loss/goodput from the counter
|
||||
/// discontinuity. RTT (SRTT) is preserved since it remains valid.
|
||||
pub fn reset_for_rekey(&mut self) {
|
||||
self.prev_rr_cum_packets = 0;
|
||||
self.prev_rr_cum_bytes = 0;
|
||||
self.prev_rr_highest_counter = 0;
|
||||
self.prev_rr_ecn_ce = 0;
|
||||
self.prev_rr_reorder = 0;
|
||||
self.prev_rr_time = None;
|
||||
self.has_prev_rr = false;
|
||||
self.delivery_ratio_forward = 1.0;
|
||||
self.prev_reverse_packets = 0;
|
||||
self.prev_reverse_highest = 0;
|
||||
self.has_prev_reverse = false;
|
||||
// Keep srtt, etx, trends, goodput_bps — they'll refresh from data
|
||||
}
|
||||
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
srtt: SrttEstimator::new(),
|
||||
@@ -66,6 +96,10 @@ impl MmpMetrics {
|
||||
prev_rr_ecn_ce: 0,
|
||||
prev_rr_reorder: 0,
|
||||
prev_rr_time: None,
|
||||
has_prev_rr: false,
|
||||
prev_reverse_packets: 0,
|
||||
prev_reverse_highest: 0,
|
||||
has_prev_reverse: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,7 +107,17 @@ impl MmpMetrics {
|
||||
///
|
||||
/// `our_timestamp_ms` is the current session-relative time in ms (for RTT).
|
||||
/// `now` is the current monotonic time (for goodput rate computation).
|
||||
pub fn process_receiver_report(&mut self, rr: &ReceiverReport, our_timestamp_ms: u32, now: Instant) {
|
||||
///
|
||||
/// Returns `true` if this report produced the first SRTT measurement
|
||||
/// (transition from uninitialized to initialized).
|
||||
pub fn process_receiver_report(
|
||||
&mut self,
|
||||
rr: &ReceiverReport,
|
||||
our_timestamp_ms: u32,
|
||||
now: Instant,
|
||||
) -> bool {
|
||||
let had_srtt = self.srtt.initialized();
|
||||
|
||||
// --- RTT from timestamp echo ---
|
||||
// RTT = now - echoed_timestamp - dwell_time
|
||||
if rr.timestamp_echo > 0 {
|
||||
@@ -98,9 +142,13 @@ impl MmpMetrics {
|
||||
|
||||
// --- Loss rate from cumulative counters ---
|
||||
// Delta: frames the peer should have received vs. actually received
|
||||
if self.prev_rr_highest_counter > 0 {
|
||||
let counter_span = rr.highest_counter.saturating_sub(self.prev_rr_highest_counter);
|
||||
let packets_delta = rr.cumulative_packets_recv.saturating_sub(self.prev_rr_cum_packets);
|
||||
if self.has_prev_rr {
|
||||
let counter_span = rr
|
||||
.highest_counter
|
||||
.saturating_sub(self.prev_rr_highest_counter);
|
||||
let packets_delta = rr
|
||||
.cumulative_packets_recv
|
||||
.saturating_sub(self.prev_rr_cum_packets);
|
||||
|
||||
if counter_span > 0 {
|
||||
let delivery = (packets_delta as f64) / (counter_span as f64);
|
||||
@@ -113,8 +161,10 @@ impl MmpMetrics {
|
||||
}
|
||||
|
||||
// --- Goodput from cumulative bytes + time delta ---
|
||||
if self.prev_rr_cum_bytes > 0 {
|
||||
let bytes_delta = rr.cumulative_bytes_recv.saturating_sub(self.prev_rr_cum_bytes);
|
||||
if self.has_prev_rr {
|
||||
let bytes_delta = rr
|
||||
.cumulative_bytes_recv
|
||||
.saturating_sub(self.prev_rr_cum_bytes);
|
||||
self.goodput_trend.update(bytes_delta as f64);
|
||||
|
||||
// Compute bytes/sec if we have a time reference
|
||||
@@ -143,13 +193,31 @@ impl MmpMetrics {
|
||||
self.prev_rr_ecn_ce = rr.ecn_ce_count;
|
||||
self.prev_rr_reorder = rr.cumulative_reorder_count;
|
||||
self.prev_rr_time = Some(now);
|
||||
self.has_prev_rr = true;
|
||||
|
||||
!had_srtt && self.srtt.initialized()
|
||||
}
|
||||
|
||||
/// Update the reverse delivery ratio (from our own receiver state about the peer's traffic).
|
||||
pub fn set_delivery_ratio_reverse(&mut self, ratio: f64) {
|
||||
self.delivery_ratio_reverse = ratio.clamp(0.0, 1.0);
|
||||
self.etx = compute_etx(self.delivery_ratio_forward, self.delivery_ratio_reverse);
|
||||
self.etx_trend.update(self.etx);
|
||||
/// Update the reverse delivery ratio from our own receiver state.
|
||||
///
|
||||
/// Computes a per-interval delta (same as forward ratio) rather than
|
||||
/// a lifetime cumulative ratio, so ETX responds to recent conditions.
|
||||
pub fn update_reverse_delivery(&mut self, our_recv_packets: u64, peer_highest: u64) {
|
||||
if self.has_prev_reverse {
|
||||
let counter_span = peer_highest.saturating_sub(self.prev_reverse_highest);
|
||||
let packets_delta = our_recv_packets.saturating_sub(self.prev_reverse_packets);
|
||||
|
||||
if counter_span > 0 {
|
||||
let delivery = (packets_delta as f64) / (counter_span as f64);
|
||||
self.delivery_ratio_reverse = delivery.clamp(0.0, 1.0);
|
||||
self.etx = compute_etx(self.delivery_ratio_forward, self.delivery_ratio_reverse);
|
||||
self.etx_trend.update(self.etx);
|
||||
}
|
||||
}
|
||||
|
||||
self.prev_reverse_packets = our_recv_packets;
|
||||
self.prev_reverse_highest = peer_highest;
|
||||
self.has_prev_reverse = true;
|
||||
}
|
||||
|
||||
/// Current smoothed RTT in milliseconds, or `None` if not yet measured.
|
||||
@@ -272,9 +340,15 @@ mod tests {
|
||||
let mut m = MmpMetrics::new();
|
||||
assert_eq!(m.etx, 1.0); // initial: perfect
|
||||
|
||||
// Simulate some loss
|
||||
// Simulate some loss via forward ratio
|
||||
m.delivery_ratio_forward = 0.9;
|
||||
m.set_delivery_ratio_reverse(0.95);
|
||||
|
||||
// First call establishes the baseline (no ETX update yet)
|
||||
m.update_reverse_delivery(100, 100);
|
||||
assert_eq!(m.etx, 1.0); // still perfect — baseline only
|
||||
|
||||
// Second call: 190 of 200 frames received (5% loss)
|
||||
m.update_reverse_delivery(290, 300);
|
||||
assert!(m.etx > 1.0);
|
||||
assert!(m.etx < 2.0);
|
||||
}
|
||||
@@ -316,7 +390,64 @@ mod tests {
|
||||
// Second report 1s later: 150KB total (100KB delta in 1s = 100KB/s)
|
||||
let rr2 = make_rr(300, 290, 150_000, 0, 0, 0);
|
||||
m.process_receiver_report(&rr2, 0, t0 + Duration::from_secs(1));
|
||||
assert!(m.goodput_bps() > 90_000.0, "goodput={}, expected ~100000", m.goodput_bps());
|
||||
assert!(m.goodput_bps() < 110_000.0, "goodput={}, expected ~100000", m.goodput_bps());
|
||||
assert!(
|
||||
m.goodput_bps() > 90_000.0,
|
||||
"goodput={}, expected ~100000",
|
||||
m.goodput_bps()
|
||||
);
|
||||
assert!(
|
||||
m.goodput_bps() < 110_000.0,
|
||||
"goodput={}, expected ~100000",
|
||||
m.goodput_bps()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reverse_delivery_delta() {
|
||||
let mut m = MmpMetrics::new();
|
||||
|
||||
// First call: baseline only, no ratio update
|
||||
m.update_reverse_delivery(100, 100);
|
||||
assert_eq!(m.delivery_ratio_reverse, 1.0); // unchanged from default
|
||||
|
||||
// Second call: perfect delivery (200 new frames, all received)
|
||||
m.update_reverse_delivery(300, 300);
|
||||
assert!((m.delivery_ratio_reverse - 1.0).abs() < 0.001);
|
||||
|
||||
// Third call: 50% loss (100 frames sent, 50 received)
|
||||
m.update_reverse_delivery(350, 400);
|
||||
assert!(
|
||||
(m.delivery_ratio_reverse - 0.5).abs() < 0.001,
|
||||
"reverse={}, expected 0.5",
|
||||
m.delivery_ratio_reverse
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reverse_delivery_rekey_reset() {
|
||||
let mut m = MmpMetrics::new();
|
||||
|
||||
// Establish baseline and one measurement
|
||||
m.update_reverse_delivery(100, 100);
|
||||
m.update_reverse_delivery(300, 300);
|
||||
assert!((m.delivery_ratio_reverse - 1.0).abs() < 0.001);
|
||||
|
||||
// Rekey resets reverse state
|
||||
m.reset_for_rekey();
|
||||
|
||||
// First call after rekey: baseline only
|
||||
m.update_reverse_delivery(50, 50);
|
||||
// delivery_ratio_reverse was reset to 1.0 by reset_for_rekey's
|
||||
// clearing of delivery_ratio_forward; reverse is not explicitly
|
||||
// reset — but the delta state is, so next call computes fresh.
|
||||
assert_eq!(m.delivery_ratio_reverse, 1.0);
|
||||
|
||||
// Second call after rekey: 80% delivery
|
||||
m.update_reverse_delivery(90, 100);
|
||||
assert!(
|
||||
(m.delivery_ratio_reverse - 0.8).abs() < 0.001,
|
||||
"reverse={}, expected 0.8",
|
||||
m.delivery_ratio_reverse
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -197,6 +197,12 @@ impl MmpPeerState {
|
||||
}
|
||||
}
|
||||
|
||||
/// Reset counter-dependent state for rekey cutover.
|
||||
pub fn reset_for_rekey(&mut self, now: Instant) {
|
||||
self.receiver.reset_for_rekey(now);
|
||||
self.metrics.reset_for_rekey();
|
||||
}
|
||||
|
||||
/// Current operating mode.
|
||||
pub fn mode(&self) -> MmpMode {
|
||||
self.mode
|
||||
@@ -256,6 +262,12 @@ impl MmpSessionState {
|
||||
}
|
||||
}
|
||||
|
||||
/// Reset counter-dependent state for rekey cutover.
|
||||
pub fn reset_for_rekey(&mut self, now: Instant) {
|
||||
self.receiver.reset_for_rekey(now);
|
||||
self.metrics.reset_for_rekey();
|
||||
}
|
||||
|
||||
/// Current operating mode.
|
||||
pub fn mode(&self) -> MmpMode {
|
||||
self.mode
|
||||
|
||||
@@ -7,8 +7,18 @@ use std::time::{Duration, Instant};
|
||||
|
||||
use crate::mmp::algorithms::{JitterEstimator, OwdTrendDetector};
|
||||
use crate::mmp::report::ReceiverReport;
|
||||
use crate::mmp::{DEFAULT_COLD_START_INTERVAL_MS, DEFAULT_OWD_WINDOW_SIZE,
|
||||
MAX_REPORT_INTERVAL_MS, MIN_REPORT_INTERVAL_MS};
|
||||
use crate::mmp::{
|
||||
DEFAULT_COLD_START_INTERVAL_MS, DEFAULT_OWD_WINDOW_SIZE, MAX_REPORT_INTERVAL_MS,
|
||||
MIN_REPORT_INTERVAL_MS,
|
||||
};
|
||||
|
||||
/// Grace period after rekey before resuming jitter calculation.
|
||||
///
|
||||
/// During rekey cutover, frames from the old session may still arrive via the
|
||||
/// drain window (DRAIN_WINDOW_SECS = 10s). These carry large sender timestamps
|
||||
/// from the old session, producing enormous transit deltas that spike the EWMA
|
||||
/// jitter estimator. We suppress jitter updates for drain window + 5s margin.
|
||||
const REKEY_JITTER_GRACE_SECS: u64 = 15;
|
||||
|
||||
// ============================================================================
|
||||
// Gap Tracker (burst loss detection)
|
||||
@@ -161,6 +171,11 @@ pub struct ReceiverState {
|
||||
/// Local time when the most recent frame was received (for dwell computation).
|
||||
last_recv_time: Option<Instant>,
|
||||
|
||||
// --- Rekey grace ---
|
||||
/// When set, jitter updates are suppressed until this instant passes.
|
||||
/// Prevents drain-window frames from spiking the jitter estimator.
|
||||
rekey_jitter_grace_until: Option<Instant>,
|
||||
|
||||
// --- Report timing ---
|
||||
last_report_time: Option<Instant>,
|
||||
report_interval: Duration,
|
||||
@@ -192,12 +207,36 @@ impl ReceiverState {
|
||||
ecn_ce_count: 0,
|
||||
last_sender_timestamp: 0,
|
||||
last_recv_time: None,
|
||||
rekey_jitter_grace_until: None,
|
||||
last_report_time: None,
|
||||
report_interval: Duration::from_millis(cold_start_ms),
|
||||
interval_has_data: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Reset counter-dependent state for rekey cutover.
|
||||
///
|
||||
/// After cutover, the new session starts with counter 0 and reset
|
||||
/// timestamps. Without resetting, the old `highest_counter` and
|
||||
/// `GapTracker.expected_next` cause false reorder/loss detection.
|
||||
pub fn reset_for_rekey(&mut self, now: Instant) {
|
||||
self.highest_counter = 0;
|
||||
self.cumulative_reorder_count = 0;
|
||||
self.gap_tracker = GapTracker::new();
|
||||
self.interval_packets_recv = 0;
|
||||
self.interval_bytes_recv = 0;
|
||||
self.jitter = JitterEstimator::new();
|
||||
self.owd_trend.clear();
|
||||
self.owd_seq = 0;
|
||||
self.last_sender_timestamp = 0;
|
||||
self.last_recv_time = None;
|
||||
self.rekey_jitter_grace_until = Some(now + Duration::from_secs(REKEY_JITTER_GRACE_SECS));
|
||||
self.ecn_ce_count = 0;
|
||||
self.interval_has_data = false;
|
||||
// Keep cumulative_packets_recv, cumulative_bytes_recv (lifetime stats)
|
||||
// Keep last_report_time, report_interval (report scheduling)
|
||||
}
|
||||
|
||||
/// Record a received frame from this peer.
|
||||
///
|
||||
/// Called on the RX path after AEAD decryption, before message dispatch.
|
||||
@@ -242,11 +281,18 @@ impl ReceiverState {
|
||||
let sender_us = (sender_timestamp_ms as i64) * 1000;
|
||||
// We can't get absolute µs from Instant, but we can compute the delta
|
||||
// between consecutive transits using relative Instant differences.
|
||||
if let Some(prev_recv) = self.last_recv_time {
|
||||
let recv_delta_us = now.duration_since(prev_recv).as_micros() as i64;
|
||||
let send_delta_us = sender_us - (self.last_sender_timestamp as i64 * 1000);
|
||||
let transit_delta = (recv_delta_us - send_delta_us) as i32;
|
||||
self.jitter.update(transit_delta);
|
||||
// Skip during post-rekey grace period to avoid drain-window spikes.
|
||||
let in_grace = self
|
||||
.rekey_jitter_grace_until
|
||||
.is_some_and(|deadline| now < deadline);
|
||||
if !in_grace {
|
||||
self.rekey_jitter_grace_until = None; // clear expired grace
|
||||
if let Some(prev_recv) = self.last_recv_time {
|
||||
let recv_delta_us = now.duration_since(prev_recv).as_micros() as i64;
|
||||
let send_delta_us = sender_us - (self.last_sender_timestamp as i64 * 1000);
|
||||
let transit_delta = (recv_delta_us - send_delta_us) as i32;
|
||||
self.jitter.update(transit_delta);
|
||||
}
|
||||
}
|
||||
|
||||
// OWD trend: use sender timestamp as a proxy for send time
|
||||
@@ -273,7 +319,8 @@ impl ReceiverState {
|
||||
}
|
||||
|
||||
// Dwell time: ms between last frame reception and report generation
|
||||
let dwell_time = self.last_recv_time
|
||||
let dwell_time = self
|
||||
.last_recv_time
|
||||
.map(|t| now.duration_since(t).as_millis() as u16)
|
||||
.unwrap_or(0);
|
||||
|
||||
@@ -320,7 +367,11 @@ impl ReceiverState {
|
||||
///
|
||||
/// Receiver reports at 1× SRTT, clamped to [MIN, MAX].
|
||||
pub fn update_report_interval_from_srtt(&mut self, srtt_us: i64) {
|
||||
self.update_report_interval_with_bounds(srtt_us, MIN_REPORT_INTERVAL_MS, MAX_REPORT_INTERVAL_MS);
|
||||
self.update_report_interval_with_bounds(
|
||||
srtt_us,
|
||||
MIN_REPORT_INTERVAL_MS,
|
||||
MAX_REPORT_INTERVAL_MS,
|
||||
);
|
||||
}
|
||||
|
||||
/// Update the report interval based on SRTT with custom bounds.
|
||||
@@ -531,4 +582,36 @@ mod tests {
|
||||
r.update_report_interval_from_srtt(500_000);
|
||||
assert_eq!(r.report_interval(), Duration::from_millis(500));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rekey_jitter_grace_suppresses_spikes() {
|
||||
let mut r = ReceiverState::new(32);
|
||||
let t0 = Instant::now();
|
||||
|
||||
// Establish baseline with two frames so jitter starts updating
|
||||
r.record_recv(1, 1000, 100, false, t0);
|
||||
r.record_recv(2, 2000, 100, false, t0 + Duration::from_secs(1));
|
||||
assert_eq!(r.jitter_us(), 0); // perfect 1s spacing → 0 jitter
|
||||
|
||||
// Simulate rekey: reset, then send a frame with a large old-session
|
||||
// timestamp followed by a new-session timestamp near zero.
|
||||
// Without grace, this would produce a huge jitter spike.
|
||||
r.reset_for_rekey(t0 + Duration::from_secs(2));
|
||||
|
||||
// Frame arrives during grace period with old-session timestamp
|
||||
r.record_recv(0, 120_000, 100, false, t0 + Duration::from_secs(3));
|
||||
// Next frame with new-session timestamp near zero
|
||||
r.record_recv(1, 100, 100, false, t0 + Duration::from_secs(4));
|
||||
// Jitter should still be zero — updates suppressed during grace
|
||||
assert_eq!(r.jitter_us(), 0);
|
||||
|
||||
// After grace expires, jitter updates resume
|
||||
let after_grace =
|
||||
t0 + Duration::from_secs(2) + Duration::from_secs(REKEY_JITTER_GRACE_SECS + 1);
|
||||
r.record_recv(2, 200, 100, false, after_grace);
|
||||
r.record_recv(3, 300, 100, false, after_grace + Duration::from_millis(100));
|
||||
// Now jitter should be updating (non-zero or zero depending on timing)
|
||||
// The key assertion is that it's not a multi-second spike
|
||||
assert!(r.jitter_us() < 1_000_000); // less than 1 second
|
||||
}
|
||||
}
|
||||
|
||||
@@ -117,7 +117,9 @@ impl SenderState {
|
||||
match self.last_report_time {
|
||||
None => true, // Never sent a report — send immediately
|
||||
Some(last) => {
|
||||
let effective = self.report_interval.mul_f64(self.send_failure_backoff_multiplier());
|
||||
let effective = self
|
||||
.report_interval
|
||||
.mul_f64(self.send_failure_backoff_multiplier());
|
||||
now.duration_since(last) >= effective
|
||||
}
|
||||
}
|
||||
@@ -152,7 +154,11 @@ impl SenderState {
|
||||
///
|
||||
/// Sender reports at 2× SRTT clamped to [MIN, MAX].
|
||||
pub fn update_report_interval_from_srtt(&mut self, srtt_us: i64) {
|
||||
self.update_report_interval_with_bounds(srtt_us, MIN_REPORT_INTERVAL_MS, MAX_REPORT_INTERVAL_MS);
|
||||
self.update_report_interval_with_bounds(
|
||||
srtt_us,
|
||||
MIN_REPORT_INTERVAL_MS,
|
||||
MAX_REPORT_INTERVAL_MS,
|
||||
);
|
||||
}
|
||||
|
||||
/// Update the report interval based on SRTT with custom bounds.
|
||||
@@ -301,7 +307,10 @@ mod tests {
|
||||
|
||||
// 2s RTT → 4s, clamped to max 2s
|
||||
s.update_report_interval_from_srtt(2_000_000);
|
||||
assert_eq!(s.report_interval(), Duration::from_millis(MAX_REPORT_INTERVAL_MS));
|
||||
assert_eq!(
|
||||
s.report_interval(),
|
||||
Duration::from_millis(MAX_REPORT_INTERVAL_MS)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -3,13 +3,13 @@
|
||||
//! Handles building, sending, and receiving FilterAnnounce messages,
|
||||
//! including debounced propagation to peers.
|
||||
|
||||
use crate::NodeAddr;
|
||||
use crate::bloom::BloomFilter;
|
||||
use crate::protocol::FilterAnnounce;
|
||||
use crate::NodeAddr;
|
||||
|
||||
use super::{Node, NodeError};
|
||||
use std::collections::HashMap;
|
||||
use tracing::debug;
|
||||
use tracing::{debug, warn};
|
||||
|
||||
impl Node {
|
||||
/// Collect inbound filters from all peers for outgoing filter computation.
|
||||
@@ -78,11 +78,41 @@ impl Node {
|
||||
|
||||
self.stats_mut().bloom.sent += 1;
|
||||
|
||||
// Self-plausibility check: WARN if our own outgoing filter is
|
||||
// above the antipoison cap. Independent detection signal if
|
||||
// aggregation drift or an ingress-check bypass pushes us over
|
||||
// despite M1. Rate-limited to once per 60s globally — outgoing
|
||||
// cadence can be per-tick during churn, and we want the
|
||||
// operator to see one clear message, not spam.
|
||||
let max_fpr = self.config.node.bloom.max_inbound_fpr;
|
||||
let out_fill = sent_filter.fill_ratio();
|
||||
let out_fpr = out_fill.powi(sent_filter.hash_count() as i32);
|
||||
if out_fpr > max_fpr {
|
||||
let now = std::time::Instant::now();
|
||||
let should_warn = self
|
||||
.last_self_warn
|
||||
.map(|t| now.duration_since(t) >= std::time::Duration::from_secs(60))
|
||||
.unwrap_or(true);
|
||||
if should_warn {
|
||||
self.last_self_warn = Some(now);
|
||||
warn!(
|
||||
to = %self.peer_display_name(peer_addr),
|
||||
fill = format_args!("{:.3}", out_fill),
|
||||
fpr = format_args!("{:.4}", out_fpr),
|
||||
cap = format_args!("{:.4}", max_fpr),
|
||||
"Outgoing filter above FPR cap — aggregation drift or missed ingress?"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Record send and store the filter for change detection
|
||||
debug!(
|
||||
peer = %self.peer_display_name(peer_addr),
|
||||
seq = announce.sequence,
|
||||
est_entries = format_args!("{:.0}", sent_filter.estimated_count()),
|
||||
est_entries = match sent_filter.estimated_count(max_fpr) {
|
||||
Some(n) => format!("{:.0}", n),
|
||||
None => "—".to_string(),
|
||||
},
|
||||
set_bits = sent_filter.count_ones(),
|
||||
fill = format_args!("{:.1}%", sent_filter.fill_ratio() * 100.0),
|
||||
tree_peer = self.is_tree_peer(peer_addr),
|
||||
@@ -174,6 +204,28 @@ impl Node {
|
||||
return;
|
||||
}
|
||||
|
||||
// Antipoison FPR cap. Reject announces whose FPR exceeds
|
||||
// node.bloom.max_inbound_fpr. Silent on the wire (no NACK) —
|
||||
// the peer's prior accepted filter and filter_sequence stay
|
||||
// untouched so the peer is not permanently silenced and an
|
||||
// on-path attacker cannot weaponize a single corrupted frame
|
||||
// to wipe a victim's contribution to aggregation.
|
||||
let max_fpr = self.config.node.bloom.max_inbound_fpr;
|
||||
let fill = announce.filter.fill_ratio();
|
||||
let fpr = fill.powi(announce.filter.hash_count() as i32);
|
||||
if fpr > max_fpr {
|
||||
self.stats_mut().bloom.fill_exceeded += 1;
|
||||
warn!(
|
||||
from = %self.peer_display_name(from),
|
||||
seq = announce.sequence,
|
||||
fill = format_args!("{:.3}", fill),
|
||||
fpr = format_args!("{:.4}", fpr),
|
||||
cap = format_args!("{:.4}", max_fpr),
|
||||
"FilterAnnounce above FPR cap — rejected"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
self.stats_mut().bloom.accepted += 1;
|
||||
|
||||
let now_ms = std::time::SystemTime::now()
|
||||
@@ -184,7 +236,10 @@ impl Node {
|
||||
debug!(
|
||||
from = %self.peer_display_name(from),
|
||||
seq = announce.sequence,
|
||||
est_entries = format_args!("{:.0}", announce.filter.estimated_count()),
|
||||
est_entries = match announce.filter.estimated_count(max_fpr) {
|
||||
Some(n) => format!("{:.0}", n),
|
||||
None => "—".to_string(),
|
||||
},
|
||||
set_bits = announce.filter.count_ones(),
|
||||
fill = format_args!("{:.1}%", announce.filter.fill_ratio() * 100.0),
|
||||
tree_peer = self.is_tree_peer(from),
|
||||
|
||||
373
src/node/discovery_rate_limit.rs
Normal file
373
src/node/discovery_rate_limit.rs
Normal file
@@ -0,0 +1,373 @@
|
||||
//! Discovery protocol rate limiting and backoff.
|
||||
//!
|
||||
//! Two complementary mechanisms:
|
||||
//!
|
||||
//! - **`DiscoveryBackoff`** (originator-side): Exponential backoff for failed
|
||||
//! lookups. After a lookup times out, suppresses re-initiation with
|
||||
//! increasing delays (30s → 60s → 300s cap). Reset on topology changes
|
||||
//! (parent change, new peer, first RTT, reconnection).
|
||||
//!
|
||||
//! - **`DiscoveryForwardRateLimiter`** (transit-side): Per-target minimum
|
||||
//! interval for forwarded requests. Defense-in-depth against misbehaving
|
||||
//! nodes generating fresh request_ids at high rate.
|
||||
|
||||
use crate::NodeAddr;
|
||||
use std::collections::HashMap;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
// ============================================================================
|
||||
// Originator-side: Discovery Backoff
|
||||
// ============================================================================
|
||||
|
||||
/// Default base backoff after first lookup failure.
|
||||
const DEFAULT_BACKOFF_BASE_SECS: u64 = 30;
|
||||
|
||||
/// Default maximum backoff cap.
|
||||
const DEFAULT_BACKOFF_MAX_SECS: u64 = 300;
|
||||
|
||||
/// Backoff multiplier per consecutive failure.
|
||||
const BACKOFF_MULTIPLIER: u64 = 2;
|
||||
|
||||
/// Exponential backoff for failed discovery lookups.
|
||||
///
|
||||
/// Tracks targets whose lookups have timed out and suppresses
|
||||
/// re-initiation with increasing delays. Cleared on topology changes.
|
||||
pub struct DiscoveryBackoff {
|
||||
/// Maps target → (suppress_until, consecutive_failures).
|
||||
entries: HashMap<NodeAddr, BackoffEntry>,
|
||||
/// Base backoff duration (first failure).
|
||||
base: Duration,
|
||||
/// Maximum backoff cap.
|
||||
max: Duration,
|
||||
}
|
||||
|
||||
struct BackoffEntry {
|
||||
/// Don't re-initiate until this instant.
|
||||
suppress_until: Instant,
|
||||
/// Consecutive failures (drives exponential backoff).
|
||||
failures: u32,
|
||||
}
|
||||
|
||||
impl DiscoveryBackoff {
|
||||
/// Create with default parameters (30s base, 300s cap).
|
||||
pub fn new() -> Self {
|
||||
Self::with_params(DEFAULT_BACKOFF_BASE_SECS, DEFAULT_BACKOFF_MAX_SECS)
|
||||
}
|
||||
|
||||
/// Create with custom base and max backoff in seconds.
|
||||
pub fn with_params(base_secs: u64, max_secs: u64) -> Self {
|
||||
Self {
|
||||
entries: HashMap::new(),
|
||||
base: Duration::from_secs(base_secs),
|
||||
max: Duration::from_secs(max_secs),
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if a lookup for this target is suppressed.
|
||||
///
|
||||
/// Returns true if the target is in backoff and should not be
|
||||
/// looked up yet.
|
||||
pub fn is_suppressed(&self, target: &NodeAddr) -> bool {
|
||||
if let Some(entry) = self.entries.get(target) {
|
||||
Instant::now() < entry.suppress_until
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Record a lookup failure (timeout) for a target.
|
||||
///
|
||||
/// Increments the failure count and sets the next suppression
|
||||
/// window using exponential backoff.
|
||||
pub fn record_failure(&mut self, target: &NodeAddr) {
|
||||
let now = Instant::now();
|
||||
let failures = self.entries.get(target).map_or(0, |e| e.failures) + 1;
|
||||
|
||||
let backoff_secs = self
|
||||
.base
|
||||
.as_secs()
|
||||
.saturating_mul(BACKOFF_MULTIPLIER.saturating_pow(failures.saturating_sub(1)));
|
||||
let backoff = Duration::from_secs(backoff_secs.min(self.max.as_secs()));
|
||||
|
||||
self.entries.insert(
|
||||
*target,
|
||||
BackoffEntry {
|
||||
suppress_until: now + backoff,
|
||||
failures,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Record a successful lookup — remove backoff for this target.
|
||||
pub fn record_success(&mut self, target: &NodeAddr) {
|
||||
self.entries.remove(target);
|
||||
}
|
||||
|
||||
/// Clear all backoff entries.
|
||||
///
|
||||
/// Called on topology changes that might make previously-unreachable
|
||||
/// targets reachable (parent change, new peer, first RTT, reconnection).
|
||||
pub fn reset_all(&mut self) {
|
||||
self.entries.clear();
|
||||
}
|
||||
|
||||
/// Whether any entries exist.
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.entries.is_empty()
|
||||
}
|
||||
|
||||
/// Current number of entries.
|
||||
pub fn entry_count(&self) -> usize {
|
||||
self.entries.len()
|
||||
}
|
||||
|
||||
/// Get the failure count for a target (for logging).
|
||||
pub fn failure_count(&self, target: &NodeAddr) -> u32 {
|
||||
self.entries.get(target).map_or(0, |e| e.failures)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn len(&self) -> usize {
|
||||
self.entries.len()
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for DiscoveryBackoff {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Transit-side: Discovery Forward Rate Limiter
|
||||
// ============================================================================
|
||||
|
||||
/// Default minimum interval between forwarded lookups for the same target.
|
||||
const DEFAULT_FORWARD_MIN_INTERVAL: Duration = Duration::from_secs(2);
|
||||
|
||||
/// Maximum age of entries before cleanup.
|
||||
const FORWARD_MAX_AGE: Duration = Duration::from_secs(60);
|
||||
|
||||
/// Rate limiter for forwarded discovery requests.
|
||||
///
|
||||
/// Tracks the last time a LookupRequest was forwarded for each target
|
||||
/// and enforces a minimum interval to prevent floods from misbehaving
|
||||
/// nodes generating fresh request_ids.
|
||||
pub struct DiscoveryForwardRateLimiter {
|
||||
last_forwarded: HashMap<NodeAddr, Instant>,
|
||||
min_interval: Duration,
|
||||
max_age: Duration,
|
||||
}
|
||||
|
||||
impl DiscoveryForwardRateLimiter {
|
||||
/// Create with default parameters (2s interval).
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
last_forwarded: HashMap::new(),
|
||||
min_interval: DEFAULT_FORWARD_MIN_INTERVAL,
|
||||
max_age: FORWARD_MAX_AGE,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create with a custom minimum interval.
|
||||
pub fn with_interval(min_interval: Duration) -> Self {
|
||||
Self {
|
||||
last_forwarded: HashMap::new(),
|
||||
min_interval,
|
||||
max_age: FORWARD_MAX_AGE,
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if we should forward a lookup for this target.
|
||||
///
|
||||
/// Returns true if enough time has passed since the last forward
|
||||
/// for this target. Updates internal state when returning true.
|
||||
pub fn should_forward(&mut self, target: &NodeAddr) -> bool {
|
||||
let now = Instant::now();
|
||||
|
||||
if let Some(&last) = self.last_forwarded.get(target)
|
||||
&& now.duration_since(last) < self.min_interval
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
self.last_forwarded.insert(*target, now);
|
||||
self.cleanup(now);
|
||||
true
|
||||
}
|
||||
|
||||
/// Replace the minimum interval (e.g., set to zero to disable).
|
||||
#[cfg(test)]
|
||||
pub fn set_interval(&mut self, interval: Duration) {
|
||||
self.min_interval = interval;
|
||||
}
|
||||
|
||||
/// Remove entries older than max_age.
|
||||
fn cleanup(&mut self, now: Instant) {
|
||||
self.last_forwarded
|
||||
.retain(|_, &mut last| now.duration_since(last) < self.max_age);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn len(&self) -> usize {
|
||||
self.last_forwarded.len()
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for DiscoveryForwardRateLimiter {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Tests
|
||||
// ============================================================================
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::thread;
|
||||
|
||||
fn addr(val: u8) -> NodeAddr {
|
||||
let mut bytes = [0u8; 16];
|
||||
bytes[0] = val;
|
||||
NodeAddr::from_bytes(bytes)
|
||||
}
|
||||
|
||||
// --- DiscoveryBackoff tests ---
|
||||
|
||||
#[test]
|
||||
fn test_backoff_not_suppressed_initially() {
|
||||
let backoff = DiscoveryBackoff::new();
|
||||
assert!(!backoff.is_suppressed(&addr(1)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_backoff_suppressed_after_failure() {
|
||||
let mut backoff = DiscoveryBackoff::new();
|
||||
backoff.record_failure(&addr(1));
|
||||
assert!(backoff.is_suppressed(&addr(1)));
|
||||
// Different target not affected
|
||||
assert!(!backoff.is_suppressed(&addr(2)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_backoff_cleared_on_success() {
|
||||
let mut backoff = DiscoveryBackoff::new();
|
||||
backoff.record_failure(&addr(1));
|
||||
assert!(backoff.is_suppressed(&addr(1)));
|
||||
|
||||
backoff.record_success(&addr(1));
|
||||
assert!(!backoff.is_suppressed(&addr(1)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_backoff_reset_all() {
|
||||
let mut backoff = DiscoveryBackoff::new();
|
||||
backoff.record_failure(&addr(1));
|
||||
backoff.record_failure(&addr(2));
|
||||
assert_eq!(backoff.len(), 2);
|
||||
|
||||
backoff.reset_all();
|
||||
assert_eq!(backoff.len(), 0);
|
||||
assert!(!backoff.is_suppressed(&addr(1)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_backoff_exponential() {
|
||||
let mut backoff = DiscoveryBackoff::with_params(1, 300);
|
||||
|
||||
// First failure: 1s backoff
|
||||
backoff.record_failure(&addr(1));
|
||||
assert_eq!(backoff.failure_count(&addr(1)), 1);
|
||||
|
||||
// Second failure: 2s backoff
|
||||
backoff.record_failure(&addr(1));
|
||||
assert_eq!(backoff.failure_count(&addr(1)), 2);
|
||||
|
||||
// Third failure: 4s backoff
|
||||
backoff.record_failure(&addr(1));
|
||||
assert_eq!(backoff.failure_count(&addr(1)), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_backoff_expires() {
|
||||
let mut backoff = DiscoveryBackoff::with_params(0, 0);
|
||||
backoff.record_failure(&addr(1));
|
||||
// With 0s backoff, should not be suppressed
|
||||
assert!(!backoff.is_suppressed(&addr(1)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_backoff_capped() {
|
||||
let mut backoff = DiscoveryBackoff::with_params(1, 10);
|
||||
|
||||
// Record many failures
|
||||
for _ in 0..20 {
|
||||
backoff.record_failure(&addr(1));
|
||||
}
|
||||
|
||||
// Backoff should be capped at max (10s), not overflow
|
||||
let entry = backoff.entries.get(&addr(1)).unwrap();
|
||||
let remaining = entry.suppress_until.duration_since(Instant::now());
|
||||
assert!(remaining <= Duration::from_secs(11));
|
||||
}
|
||||
|
||||
// --- DiscoveryForwardRateLimiter tests ---
|
||||
|
||||
#[test]
|
||||
fn test_forward_first_allowed() {
|
||||
let mut limiter = DiscoveryForwardRateLimiter::new();
|
||||
assert!(limiter.should_forward(&addr(1)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_forward_rapid_rate_limited() {
|
||||
let mut limiter = DiscoveryForwardRateLimiter::new();
|
||||
assert!(limiter.should_forward(&addr(1)));
|
||||
assert!(!limiter.should_forward(&addr(1)));
|
||||
assert!(!limiter.should_forward(&addr(1)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_forward_different_targets_independent() {
|
||||
let mut limiter = DiscoveryForwardRateLimiter::new();
|
||||
assert!(limiter.should_forward(&addr(1)));
|
||||
assert!(limiter.should_forward(&addr(2)));
|
||||
assert!(!limiter.should_forward(&addr(1)));
|
||||
assert!(!limiter.should_forward(&addr(2)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_forward_allowed_after_interval() {
|
||||
let mut limiter = DiscoveryForwardRateLimiter::with_interval(Duration::from_millis(100));
|
||||
assert!(limiter.should_forward(&addr(1)));
|
||||
|
||||
thread::sleep(Duration::from_millis(110));
|
||||
|
||||
assert!(limiter.should_forward(&addr(1)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_forward_cleanup_removes_old() {
|
||||
let mut limiter = DiscoveryForwardRateLimiter::new();
|
||||
assert!(limiter.should_forward(&addr(1)));
|
||||
assert!(limiter.should_forward(&addr(2)));
|
||||
assert_eq!(limiter.len(), 2);
|
||||
|
||||
let future = Instant::now() + Duration::from_secs(61);
|
||||
limiter.cleanup(future);
|
||||
assert_eq!(limiter.len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_forward_cleanup_preserves_recent() {
|
||||
let mut limiter = DiscoveryForwardRateLimiter::new();
|
||||
assert!(limiter.should_forward(&addr(1)));
|
||||
assert_eq!(limiter.len(), 1);
|
||||
|
||||
limiter.cleanup(Instant::now());
|
||||
assert_eq!(limiter.len(), 1);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user