mirror of
https://github.com/jmcorgan/fips.git
synced 2026-07-22 07:48:26 +00:00
Compare commits
286 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
80c956a6fd | ||
|
|
75d7077880 | ||
|
|
a47ddbd5a5 | ||
|
|
15db6471db | ||
|
|
146d19a8d8 | ||
|
|
bda327b5f5 | ||
|
|
ea74cd7e58 | ||
|
|
78377208af | ||
|
|
37adb13d5b | ||
|
|
26a579b1c9 | ||
|
|
93a5b71728 | ||
|
|
3ebb14eda4 | ||
|
|
4ff7de4d81 | ||
|
|
e4a854f6b0 | ||
|
|
0c3d9a0b73 | ||
|
|
281ed132f1 | ||
|
|
c5492f4572 | ||
|
|
7a97599921 | ||
|
|
7fe1d75637 | ||
|
|
e21e09d7e6 | ||
|
|
e7537929ba | ||
|
|
347cbe60bd | ||
|
|
11ec16777c | ||
|
|
5b09e22956 | ||
|
|
a382b17931 | ||
|
|
a90049d3a1 | ||
|
|
791b35c221 | ||
|
|
6a80790742 | ||
|
|
60b8acf716 | ||
|
|
4fc295d90a | ||
|
|
b3f2018fce | ||
|
|
56bbc81a40 | ||
|
|
b38f8c6ffb | ||
|
|
cf62cff5f4 | ||
|
|
7fe3388f2f | ||
|
|
252d16fab9 | ||
|
|
94d7b91244 | ||
|
|
7790eb86bd | ||
|
|
cf1c957336 | ||
|
|
5ccd95cf3f | ||
|
|
119b85d28e | ||
|
|
74245e80ac | ||
|
|
e42598a86e | ||
|
|
054d17aac5 | ||
|
|
fbb4fb8879 | ||
|
|
87399795f8 | ||
|
|
765819f52b | ||
|
|
5021197f5c | ||
|
|
31f5a8c1b7 | ||
|
|
3e7ca90212 | ||
|
|
9588c50063 | ||
|
|
f698da50b6 | ||
|
|
1f765cfd8f | ||
|
|
3b99a416ad | ||
|
|
e064c96df3 | ||
|
|
a70c725e48 | ||
|
|
c8077967cd | ||
|
|
5dfa571908 | ||
|
|
6bebca88ac | ||
|
|
5d5da69a5b | ||
|
|
e05b868cf8 | ||
|
|
0ebd1b44c0 | ||
|
|
800cfb23e3 | ||
|
|
e9112cc1bb | ||
|
|
c80a7fdea5 | ||
|
|
0bf031dd32 | ||
|
|
4a0584a5e9 | ||
|
|
59155df4e3 | ||
|
|
fcaee74ec0 | ||
|
|
56e3d56c25 | ||
|
|
7b0590f70e | ||
|
|
b93a127623 | ||
|
|
85a4983dbe | ||
|
|
5090ab7851 | ||
|
|
03ced618ce | ||
|
|
bf81f422ea | ||
|
|
a0cf593580 | ||
|
|
5d08d27d3c | ||
|
|
b676c9d83a | ||
|
|
a45eefb58a | ||
|
|
d61d189572 | ||
|
|
d6ca632251 | ||
|
|
6c5fd3f4b0 | ||
|
|
434b9726aa | ||
|
|
26d70ebb59 | ||
|
|
567e6a535e | ||
|
|
6011d233c1 | ||
|
|
cb5a32693e | ||
|
|
9b46b6fa85 | ||
|
|
cbc089b820 | ||
|
|
4c95be0000 | ||
|
|
e362ab67a6 | ||
|
|
6c9f55ea80 | ||
|
|
89a31fd555 | ||
|
|
ab0a46f2c0 | ||
|
|
e839aead7a | ||
|
|
6d6889d0f6 | ||
|
|
196d9492da | ||
|
|
0f2e91b479 | ||
|
|
32475d859e | ||
|
|
f2e6b8befb | ||
|
|
1aacdfa086 | ||
|
|
a7dfe47663 | ||
|
|
8aab71af86 | ||
|
|
2cffc10520 | ||
|
|
81e4207631 | ||
|
|
1c1ed0d939 | ||
|
|
1c41f73931 | ||
|
|
39ad4d2e67 | ||
|
|
4d2504f59d | ||
|
|
1208f6a5c2 | ||
|
|
3f80530cc5 | ||
|
|
3b401a0cbd | ||
|
|
0b2212e1e8 | ||
|
|
b2ce7cd3c8 | ||
|
|
e3e03f6a5d | ||
|
|
2b009196b5 | ||
|
|
5d13090d8f | ||
|
|
6538731176 | ||
|
|
9697026c81 | ||
|
|
a2400d823f | ||
|
|
dc9334e725 | ||
|
|
309a91d293 | ||
|
|
c1ddbf053c | ||
|
|
b53db662c3 | ||
|
|
4ad5940114 | ||
|
|
4ed674ea8b | ||
|
|
a67801099d | ||
|
|
50a595a0ed | ||
|
|
4802792e38 | ||
|
|
9ea57b483a | ||
|
|
e03b206f62 | ||
|
|
1dbfefc9d0 | ||
|
|
1d277e67c7 | ||
|
|
793f844448 | ||
|
|
2491091868 | ||
|
|
243bd7985a | ||
|
|
965de26239 | ||
|
|
ab915d0479 | ||
|
|
30c5808e09 | ||
|
|
8f30924fc7 | ||
|
|
3c9a629ad4 | ||
|
|
d5ee526f0e | ||
|
|
22a5b3e5c6 | ||
|
|
3ea7ca1fd1 | ||
|
|
262d98a8eb | ||
|
|
274b09d4ff | ||
|
|
0f1fd18c25 | ||
|
|
225fab29ab | ||
|
|
effd69bd53 | ||
|
|
3749853716 | ||
|
|
289e5f8571 | ||
|
|
3733349d33 | ||
|
|
9a9e90a32c | ||
|
|
759f199518 | ||
|
|
d3cf1d6f25 | ||
|
|
e03a1ac50b | ||
|
|
507086e39d | ||
|
|
3e0d9f5726 | ||
|
|
4e3890a780 | ||
|
|
a308e71ca1 | ||
|
|
3d771c6688 | ||
|
|
4e43cb81e9 | ||
|
|
fb8bb4fb97 | ||
|
|
5eac3a98f3 | ||
|
|
a4802ccf9e | ||
|
|
7a74fa8ca2 | ||
|
|
f5f4ebe76f | ||
|
|
fd30ab0994 | ||
|
|
5fc2359432 | ||
|
|
81cd10d5db | ||
|
|
063c3a194a | ||
|
|
c77e564462 | ||
|
|
1f457d84f9 | ||
|
|
bdf571a2b2 | ||
|
|
2eea20a216 | ||
|
|
ea9c7f2d8d | ||
|
|
e09d9f8412 | ||
|
|
f3eb5bf4c2 | ||
|
|
44f7451828 | ||
|
|
c2fb12d997 | ||
|
|
bca981b79f | ||
|
|
03f7511a0e | ||
|
|
d364933ca5 | ||
|
|
42011a9a2f | ||
|
|
1b7528ce89 | ||
|
|
d548add18d | ||
|
|
87bf17dd4d | ||
|
|
79b945b93d | ||
|
|
974e146bb9 | ||
|
|
180950badf | ||
|
|
e5372cbe0f | ||
|
|
9dcc421f6f | ||
|
|
86c043cc94 | ||
|
|
555d00cfa6 | ||
|
|
dd4074249c | ||
|
|
43ad2ae946 | ||
|
|
8fd515e81f | ||
|
|
bf4e0df8c5 | ||
|
|
c7218d8486 | ||
|
|
3bc8e5611c | ||
|
|
0ce9bb5b99 | ||
|
|
e7349202b5 | ||
|
|
f29c2e65fa | ||
|
|
de327e4527 | ||
|
|
0b7daeb380 | ||
|
|
4af3730be6 | ||
|
|
36c830edfd | ||
|
|
22a41cb1a0 | ||
|
|
25fe87ff60 | ||
|
|
d9a4a7807c | ||
|
|
08b8b3908e | ||
|
|
2d0e8de8c8 | ||
|
|
5987b54730 | ||
|
|
53c6c78721 | ||
|
|
3c5d9fd4f2 | ||
|
|
7d7b551ca1 | ||
|
|
da0d9d39a0 | ||
|
|
d672ed865f | ||
|
|
0bb9ce09c6 | ||
|
|
66732e89c1 | ||
|
|
8d94c0f29c | ||
|
|
e6e2a06879 | ||
|
|
6dee6dfe27 | ||
|
|
2809f0351e | ||
|
|
f6429c19d2 | ||
|
|
d575c1f986 | ||
|
|
5b229c03bf | ||
|
|
6991a152e6 | ||
|
|
d4687e5d30 | ||
|
|
df43ac79b9 | ||
|
|
0cfc85c154 | ||
|
|
18f5c12ab9 | ||
|
|
c4c3fdd94b | ||
|
|
ffd78440a8 | ||
|
|
00bd849ee1 | ||
|
|
18297283ad | ||
|
|
4d5380604a | ||
|
|
5dfbd05fe8 | ||
|
|
f396d71826 | ||
|
|
cc7f967128 | ||
|
|
dae33d4fd1 | ||
|
|
ce0eb71722 | ||
|
|
de78c94d58 | ||
|
|
050483f3bf | ||
|
|
9c0dcd0f59 | ||
|
|
7e424f34bc | ||
|
|
3fc0178192 | ||
|
|
6e5cb8965f | ||
|
|
13c9bdacac | ||
|
|
66020bc318 | ||
|
|
7a1365fb9e | ||
|
|
57a089f6c3 | ||
|
|
0a5c367edc | ||
|
|
6e7e44c8ff | ||
|
|
d418106034 | ||
|
|
79ae430725 | ||
|
|
c0ccedb491 | ||
|
|
a83342cce8 | ||
|
|
647b8155af | ||
|
|
2bc9dd557a | ||
|
|
6bd40640bf | ||
|
|
306e455513 | ||
|
|
f51dde647f | ||
|
|
49bd210480 | ||
|
|
b1af151aef | ||
|
|
59225ccfe1 | ||
|
|
b05c80e5f5 | ||
|
|
09eb5ad6bf | ||
|
|
87d1af0269 | ||
|
|
d9ab58a285 | ||
|
|
ab1e248ff4 | ||
|
|
7f518731c8 | ||
|
|
80fb086071 | ||
|
|
e9dd3167f2 | ||
|
|
4f3d2f8471 | ||
|
|
2e54edb920 | ||
|
|
7bd8d3b7a0 | ||
|
|
538ce077df | ||
|
|
6533276eda | ||
|
|
32a3b58d1f | ||
|
|
aa8f276069 | ||
|
|
9bf9701d92 | ||
|
|
212432a9c6 | ||
|
|
32697a16f0 | ||
|
|
627fd3627b |
@@ -1,2 +1,24 @@
|
||||
[profile.ci]
|
||||
junit = { path = "junit.xml" }
|
||||
junit = { path = "junit.xml" }
|
||||
# Synthetic node tests build 250-edge meshes with one-shot UDP
|
||||
# handshakes; on shared CI runners the localhost stack still drops the
|
||||
# occasional msg1 under burst load even with the per-edge repair loop.
|
||||
# Allow a retry rather than failing the whole CI run on a single
|
||||
# dropped packet.
|
||||
retries = 2
|
||||
|
||||
[test-groups]
|
||||
node-synthetic = { max-threads = 1 }
|
||||
|
||||
# nextest runs each test in a separate process, so in-process Tokio mutexes
|
||||
# can't serialize the synthetic localhost UDP node tests on CI. Those tests
|
||||
# send one-shot handshakes without production reconnect timers; under runner
|
||||
# load even small topologies drop the lone msg1. Group all node tests so
|
||||
# they run mutually exclusive — slower CI, reliable assertions.
|
||||
[[profile.default.overrides]]
|
||||
filter = 'test(node::tests::)'
|
||||
test-group = 'node-synthetic'
|
||||
|
||||
[[profile.ci.overrides]]
|
||||
filter = 'test(node::tests::)'
|
||||
test-group = 'node-synthetic'
|
||||
|
||||
57
.github/workflows/aur-publish-git.yml
vendored
Normal file
57
.github/workflows/aur-publish-git.yml
vendored
Normal file
@@ -0,0 +1,57 @@
|
||||
name: AUR Publish (fips-git)
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
paths:
|
||||
- 'packaging/aur/PKGBUILD-git'
|
||||
- 'packaging/aur/fips.sysusers'
|
||||
- 'packaging/aur/fips.tmpfiles'
|
||||
- 'packaging/aur/fips.install'
|
||||
|
||||
jobs:
|
||||
aur-publish-fips-git:
|
||||
name: Publish fips-git to AUR
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Patch PKGBUILD-git b2sums for local assets
|
||||
run: |
|
||||
set -euo pipefail
|
||||
SYSUSERS_SUM=$(b2sum packaging/aur/fips.sysusers | awk '{print $1}')
|
||||
TMPFILES_SUM=$(b2sum packaging/aur/fips.tmpfiles | awk '{print $1}')
|
||||
if [ -z "$SYSUSERS_SUM" ] || [ -z "$TMPFILES_SUM" ]; then
|
||||
echo "Failed to compute asset b2sums"; exit 1
|
||||
fi
|
||||
awk -v s1="$SYSUSERS_SUM" -v s2="$TMPFILES_SUM" '
|
||||
/^b2sums=\(/ { in_block=1; count=0 }
|
||||
in_block {
|
||||
count++
|
||||
if (count == 2) sub(/[a-f0-9]{128}/, s1)
|
||||
if (count == 3) sub(/[a-f0-9]{128}/, s2)
|
||||
if ($0 ~ /\)/) in_block=0
|
||||
}
|
||||
{ print }
|
||||
' packaging/aur/PKGBUILD-git > packaging/aur/PKGBUILD-git.new
|
||||
mv packaging/aur/PKGBUILD-git.new packaging/aur/PKGBUILD-git
|
||||
echo "Patched PKGBUILD-git b2sums:"
|
||||
awk '/^b2sums=\(/,/\)$/' packaging/aur/PKGBUILD-git
|
||||
|
||||
- name: Publish to AUR
|
||||
uses: KSXGitHub/github-actions-deploy-aur@v4.1.2
|
||||
with:
|
||||
pkgname: fips-git
|
||||
pkgbuild: packaging/aur/PKGBUILD-git
|
||||
updpkgsums: false
|
||||
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 PKGBUILD-git (${{ github.sha }})"
|
||||
190
.github/workflows/aur-publish.yml
vendored
190
.github/workflows/aur-publish.yml
vendored
@@ -1,32 +1,198 @@
|
||||
name: AUR Publish
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
- maint
|
||||
- next
|
||||
tags:
|
||||
- 'v*'
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: 'Release tag to publish (e.g. v0.4.0). Defaults to the tag the workflow was dispatched from.'
|
||||
required: false
|
||||
default: ''
|
||||
pkgrel:
|
||||
description: 'AUR pkgrel to publish. Use 2+ for packaging-only republishes of an existing tag.'
|
||||
required: false
|
||||
default: '1'
|
||||
|
||||
jobs:
|
||||
aur-publish-fips:
|
||||
name: Publish fips to AUR
|
||||
# ───────────────────────────────────────────────────────────────────────────
|
||||
# Build + lint the AUR package on every trigger, matching the coverage the
|
||||
# other package workflows (linux/macos/windows/openwrt) give their artifacts:
|
||||
# branch pushes, pull requests, tags, and manual dispatch. Uses makepkg +
|
||||
# namcap in an Arch container (neither tool exists on ubuntu-latest) and builds
|
||||
# the *checked-out tree* from a local git-archive tarball, so it works for
|
||||
# branch/PR builds and unreleased rc tags whose GitHub source archive does not
|
||||
# exist yet. This job never publishes.
|
||||
# ───────────────────────────────────────────────────────────────────────────
|
||||
aur-build:
|
||||
name: Build and lint fips AUR package
|
||||
runs-on: ubuntu-latest
|
||||
continue-on-error: true
|
||||
if: "!contains(github.ref_name, '-')"
|
||||
container: archlinux:base-devel
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Update pkgver in PKGBUILD
|
||||
- name: Install build and lint tooling
|
||||
run: |
|
||||
VERSION="${GITHUB_REF_NAME#v}"
|
||||
sed -i "s/^pkgver=.*/pkgver=${VERSION}/" packaging/aur/PKGBUILD
|
||||
set -euo pipefail
|
||||
pacman -Sy --noconfirm --needed base-devel namcap git curl
|
||||
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Resolve package version
|
||||
id: ver
|
||||
env:
|
||||
INPUT_TAG: ${{ inputs.tag }}
|
||||
INPUT_PKGREL: ${{ inputs.pkgrel }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -n "${INPUT_TAG:-}" ]; then
|
||||
RAW="${INPUT_TAG#v}"
|
||||
elif [ "${GITHUB_REF_TYPE:-}" = "tag" ]; then
|
||||
RAW="${GITHUB_REF_NAME#v}"
|
||||
else
|
||||
# Branch push / PR: derive the version from the crate manifest.
|
||||
RAW=$(grep -m1 '^version' Cargo.toml | sed -E 's/.*"([^"]+)".*/\1/')
|
||||
fi
|
||||
# makepkg forbids '-' in pkgver; map e.g. 0.4.0-rc1 -> 0.4.0rc1,
|
||||
# 0.4.0-dev -> 0.4.0dev. The build only needs an internally consistent
|
||||
# pkgver (it matches the git-archive prefix below); this is not the
|
||||
# value the real publish uses.
|
||||
VERSION="${RAW//-/}"
|
||||
PKGREL="${INPUT_PKGREL:-1}"
|
||||
case "$PKGREL" in
|
||||
''|*[!0-9]*|0) echo "pkgrel '$PKGREL' must be a positive integer"; exit 1 ;;
|
||||
esac
|
||||
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
|
||||
echo "pkgrel=${PKGREL}" >> "$GITHUB_OUTPUT"
|
||||
echo "Resolved AUR pkgver=${VERSION} pkgrel=${PKGREL}"
|
||||
|
||||
- name: Create non-root build user and fix ownership
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# makepkg refuses to run as root; create an unprivileged build user
|
||||
# with passwordless sudo (needed for pacman dep installs during -s).
|
||||
useradd -m -s /bin/bash builder
|
||||
echo 'builder ALL=(ALL) NOPASSWD: ALL' > /etc/sudoers.d/builder
|
||||
chmod 0440 /etc/sudoers.d/builder
|
||||
# The checkout is owned by root; hand it to the build user.
|
||||
chown -R builder:builder "$GITHUB_WORKSPACE"
|
||||
|
||||
- name: Build a local source tarball of the checkout
|
||||
env:
|
||||
VERSION: ${{ steps.ver.outputs.version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# The PKGBUILD source= points at GitHub archive/<tag>.tar.gz, which does
|
||||
# not exist for a branch push, a PR, or an unreleased rc tag and would
|
||||
# 404. Instead build the checked-out tree by packing it into a local
|
||||
# tarball whose top-level directory matches what the PKGBUILD expects
|
||||
# ("fips-<pkgver>/"); patch-pkgbuild.sh repoints source= at it.
|
||||
TARBALL="packaging/aur/fips-${VERSION}.tar.gz"
|
||||
git config --global --add safe.directory "$GITHUB_WORKSPACE"
|
||||
git -C "$GITHUB_WORKSPACE" archive --format=tar.gz \
|
||||
--prefix="fips-${VERSION}/" -o "$TARBALL" HEAD
|
||||
chown builder:builder "$TARBALL"
|
||||
ls -l "$TARBALL"
|
||||
|
||||
- name: Patch PKGBUILD
|
||||
env:
|
||||
TAG: v${{ steps.ver.outputs.version }}
|
||||
VERSION: ${{ steps.ver.outputs.version }}
|
||||
PKGREL: ${{ steps.ver.outputs.pkgrel }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
LOCAL_TARBALL="packaging/aur/fips-${VERSION}.tar.gz" \
|
||||
bash packaging/aur/patch-pkgbuild.sh
|
||||
chown builder:builder packaging/aur/PKGBUILD
|
||||
|
||||
- name: makepkg build and namcap lint (as build user)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
sudo -u builder bash -euo pipefail -c '
|
||||
cd packaging/aur
|
||||
echo "::group::namcap PKGBUILD"
|
||||
namcap PKGBUILD
|
||||
echo "::endgroup::"
|
||||
echo "::group::makepkg build"
|
||||
# --nocheck: skip the PKGBUILD check() (cargo test --lib); the test
|
||||
# suite is already covered by ci.yml. This job validates packaging.
|
||||
makepkg -s --noconfirm --nocheck
|
||||
echo "::endgroup::"
|
||||
echo "::group::namcap built package"
|
||||
for pkg in *.pkg.tar.*; do
|
||||
echo "namcap $pkg"
|
||||
namcap "$pkg"
|
||||
done
|
||||
echo "::endgroup::"
|
||||
'
|
||||
|
||||
# ───────────────────────────────────────────────────────────────────────────
|
||||
# Publish to the AUR. Runs only on a real (non-prerelease) release tag push,
|
||||
# or a manual dispatch (packaging-only republish with explicit tag + pkgrel).
|
||||
# Branch pushes and pull requests build+lint above but never reach this job.
|
||||
# Gated on aur-build so a package that fails to build/lint is never published.
|
||||
# ───────────────────────────────────────────────────────────────────────────
|
||||
aur-publish-fips:
|
||||
name: Publish fips to AUR
|
||||
needs: aur-build
|
||||
runs-on: ubuntu-latest
|
||||
if: >-
|
||||
github.event_name == 'workflow_dispatch'
|
||||
|| (github.event_name == 'push'
|
||||
&& startsWith(github.ref, 'refs/tags/v')
|
||||
&& !contains(github.ref_name, '-'))
|
||||
|
||||
steps:
|
||||
- name: Resolve release tag
|
||||
id: tag
|
||||
env:
|
||||
INPUT_TAG: ${{ inputs.tag }}
|
||||
INPUT_PKGREL: ${{ inputs.pkgrel }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TAG="${INPUT_TAG:-$GITHUB_REF_NAME}"
|
||||
PKGREL="${INPUT_PKGREL:-1}"
|
||||
case "$TAG" in
|
||||
v*) ;;
|
||||
*) echo "Tag '$TAG' does not look like a release tag (vX.Y.Z)"; exit 1 ;;
|
||||
esac
|
||||
case "$PKGREL" in
|
||||
''|*[!0-9]*|0) echo "pkgrel '$PKGREL' must be a positive integer"; exit 1 ;;
|
||||
esac
|
||||
case "$TAG" in
|
||||
*-*)
|
||||
if [ "$GITHUB_EVENT_NAME" != "workflow_dispatch" ]; then
|
||||
echo "Pre-release tag '$TAG' — skipping AUR publish"
|
||||
exit 1
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
|
||||
echo "version=${TAG#v}" >> "$GITHUB_OUTPUT"
|
||||
echo "pkgrel=${PKGREL}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ steps.tag.outputs.tag }}
|
||||
|
||||
- name: Patch PKGBUILD with pkgver, pkgrel, conflicts, and b2sums
|
||||
env:
|
||||
TAG: ${{ steps.tag.outputs.tag }}
|
||||
VERSION: ${{ steps.tag.outputs.version }}
|
||||
PKGREL: ${{ steps.tag.outputs.pkgrel }}
|
||||
run: bash packaging/aur/patch-pkgbuild.sh
|
||||
|
||||
- name: Publish to AUR
|
||||
uses: KSXGitHub/github-actions-deploy-aur@v4.1.2
|
||||
with:
|
||||
pkgname: fips
|
||||
pkgbuild: packaging/aur/PKGBUILD
|
||||
updpkgsums: true
|
||||
updpkgsums: false
|
||||
assets: |
|
||||
packaging/aur/fips.sysusers
|
||||
packaging/aur/fips.tmpfiles
|
||||
@@ -34,4 +200,4 @@ jobs:
|
||||
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 }}"
|
||||
commit_message: "Update to ${{ steps.tag.outputs.tag }}"
|
||||
|
||||
174
.github/workflows/ci.yml
vendored
174
.github/workflows/ci.yml
vendored
@@ -11,6 +11,10 @@ on:
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
checks: write
|
||||
contents: read
|
||||
@@ -20,6 +24,28 @@ env:
|
||||
RUST_BACKTRACE: 1
|
||||
SOURCE_DATE_EPOCH: 0 # overridden per-step after checkout
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# CI parity invariant
|
||||
#
|
||||
# This GitHub integration matrix and the local default suite set
|
||||
# (testing/ci-local.sh) MUST run the same integration suites, EXCEPT for the
|
||||
# deliberate local-only entries below. Adding a suite to one runner without
|
||||
# the other means "local green" and "GitHub green" stop being equivalent.
|
||||
# testing/check-ci-parity.sh enforces this and fails on unexpected drift.
|
||||
#
|
||||
# Deliberate local-only (NOT on the GitHub gate), with reason:
|
||||
# tor-socks5 — requires live Tor network; opt-in via --with-tor,
|
||||
# unreliable on GitHub-hosted runners.
|
||||
# tor-directory — same; live Tor dependency.
|
||||
#
|
||||
# Granularity-only differences (same coverage, different matrix shape —
|
||||
# NOT a divergence):
|
||||
# deb-install — split here into per-distro legs (debian12/debian13/
|
||||
# ubuntu22/ubuntu24/ubuntu26) for parallelism; local runs the
|
||||
# same distro set in one suite.
|
||||
# dns-resolver — single leg here; runs all scenarios (same as local).
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Job 1 – Build matrix
|
||||
#
|
||||
@@ -30,24 +56,28 @@ jobs:
|
||||
name: Format check
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions-rust-lang/setup-rust-toolchain@v1
|
||||
with:
|
||||
components: rustfmt
|
||||
cache: false
|
||||
rustflags: ''
|
||||
- run: cargo fmt --check
|
||||
|
||||
clippy:
|
||||
name: Clippy
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
- name: Install system dependencies
|
||||
run: sudo apt-get update && sudo apt-get install -y libdbus-1-dev
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: actions-rust-lang/setup-rust-toolchain@v1
|
||||
with:
|
||||
components: clippy
|
||||
cache: false
|
||||
rustflags: ''
|
||||
- name: Cache Cargo registry + build
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry
|
||||
@@ -58,6 +88,48 @@ jobs:
|
||||
${{ runner.os }}-cargo-
|
||||
- run: cargo clippy --all-targets --all-features -- -D warnings
|
||||
|
||||
# ───────────────────────────────────────────────────────────────────────────
|
||||
# Android cross-check
|
||||
#
|
||||
# FIPS runs on Android as an embedded library — the host app owns the TUN
|
||||
# (an Android VpnService), so there are no daemon binaries to package, unlike
|
||||
# the desktop targets. This job only cross-compiles the library for the
|
||||
# android target to guard the android-only cfg paths (and the `not(android)`
|
||||
# exclusions) from silently bit-rotting; nothing else in CI compiles them.
|
||||
# cargo-ndk wires the NDK toolchain, which is required even for a check
|
||||
# because `ring` compiles C at build time.
|
||||
# ───────────────────────────────────────────────────────────────────────────
|
||||
android-check:
|
||||
name: Android cross-check (aarch64)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- name: Install Rust toolchain (+ Android target)
|
||||
uses: actions-rust-lang/setup-rust-toolchain@v1
|
||||
with:
|
||||
target: aarch64-linux-android
|
||||
components: clippy
|
||||
cache: false
|
||||
rustflags: ''
|
||||
- name: Cache Cargo registry + build
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry
|
||||
~/.cargo/git
|
||||
target
|
||||
key: ${{ runner.os }}-cargo-android-${{ hashFiles('**/Cargo.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-cargo-
|
||||
- name: Install cargo-ndk
|
||||
uses: taiki-e/install-action@v2
|
||||
with:
|
||||
tool: cargo-ndk
|
||||
- name: Clippy the library for Android
|
||||
run: |
|
||||
export ANDROID_NDK_HOME="${ANDROID_NDK_HOME:-$ANDROID_NDK_LATEST_HOME}"
|
||||
cargo ndk -t arm64-v8a clippy --lib -- -D warnings
|
||||
|
||||
build:
|
||||
name: Build (${{ matrix.os }})
|
||||
runs-on: ${{ matrix.os }}
|
||||
@@ -72,7 +144,7 @@ jobs:
|
||||
- os: windows-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Set SOURCE_DATE_EPOCH from git (Unix)
|
||||
if: runner.os != 'Windows'
|
||||
@@ -94,10 +166,13 @@ jobs:
|
||||
run: sudo nft -c -f packaging/common/fips.nft
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
uses: actions-rust-lang/setup-rust-toolchain@v1
|
||||
with:
|
||||
cache: false
|
||||
rustflags: ''
|
||||
|
||||
- name: Cache Cargo registry + build
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry
|
||||
@@ -126,7 +201,7 @@ jobs:
|
||||
# Upload the Linux binary so integration jobs can use it without rebuilding
|
||||
- name: Upload Linux binary
|
||||
if: matrix.os == 'ubuntu-latest'
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: fips-linux
|
||||
path: |
|
||||
@@ -147,7 +222,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [build]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Set SOURCE_DATE_EPOCH from git
|
||||
run: echo "SOURCE_DATE_EPOCH=$(git log -1 --format=%ct)" >> "$GITHUB_ENV"
|
||||
@@ -156,10 +231,13 @@ jobs:
|
||||
run: sudo apt-get update && sudo apt-get install -y libdbus-1-dev
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
uses: actions-rust-lang/setup-rust-toolchain@v1
|
||||
with:
|
||||
cache: false
|
||||
rustflags: ''
|
||||
|
||||
- name: Cache Cargo registry + build
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry
|
||||
@@ -200,16 +278,19 @@ jobs:
|
||||
runs-on: macos-latest
|
||||
needs: [build]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- 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
|
||||
uses: actions-rust-lang/setup-rust-toolchain@v1
|
||||
with:
|
||||
cache: false
|
||||
rustflags: ''
|
||||
|
||||
- name: Cache Cargo registry + build
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry
|
||||
@@ -232,13 +313,16 @@ jobs:
|
||||
name: Unit tests (Windows)
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
uses: actions-rust-lang/setup-rust-toolchain@v1
|
||||
with:
|
||||
cache: false
|
||||
rustflags: ''
|
||||
|
||||
- name: Cache Cargo registry + build
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry
|
||||
@@ -267,7 +351,7 @@ jobs:
|
||||
name: PowerShell lint (Windows packaging)
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Run PSScriptAnalyzer
|
||||
shell: pwsh
|
||||
@@ -319,6 +403,10 @@ jobs:
|
||||
- suite: rekey-outbound-only
|
||||
type: rekey-outbound-only
|
||||
topology: rekey-outbound-only
|
||||
# ── Inbound max_peers admission-cap test ───────────────────────
|
||||
- suite: admission-cap
|
||||
type: admission-cap
|
||||
topology: mesh
|
||||
- suite: acl-allowlist
|
||||
type: acl-allowlist
|
||||
# ── Firewall baseline (fips0 nftables default-deny) ────────────
|
||||
@@ -408,6 +496,12 @@ jobs:
|
||||
- suite: deb-install-debian12
|
||||
type: deb-install
|
||||
scenario: debian12
|
||||
- suite: deb-install-debian13
|
||||
type: deb-install
|
||||
scenario: debian13
|
||||
- suite: deb-install-ubuntu22
|
||||
type: deb-install
|
||||
scenario: ubuntu22
|
||||
- suite: deb-install-ubuntu24
|
||||
type: deb-install
|
||||
scenario: ubuntu24
|
||||
@@ -426,11 +520,11 @@ jobs:
|
||||
type: dns-resolver
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
# Fetch the pre-built Linux binary from job 1
|
||||
- name: Download Linux binary
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: fips-linux
|
||||
path: _bin
|
||||
@@ -616,7 +710,7 @@ jobs:
|
||||
|
||||
- name: Upload sim results on failure (chaos)
|
||||
if: matrix.type == 'chaos' && failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: sim-results-${{ matrix.scenario }}
|
||||
path: testing/chaos/sim-results/
|
||||
@@ -719,6 +813,42 @@ jobs:
|
||||
docker compose -f testing/static/docker-compose.yml \
|
||||
--profile gateway down --volumes --remove-orphans
|
||||
|
||||
# ── Inbound max_peers admission-cap integration test ────────────────
|
||||
# Lowers node.max_peers on one mesh node and asserts the inbound cap
|
||||
# holds under sustained retry pressure: denied peers keep retrying but
|
||||
# are never promoted to an active session. The admission-cap-test.sh
|
||||
# assertions are tailored per link-layer handshake variant; the leg
|
||||
# itself is uniform. Static-style harness on the shared mesh profile.
|
||||
- name: Generate configs (admission-cap)
|
||||
if: matrix.type == 'admission-cap'
|
||||
run: bash testing/static/scripts/generate-configs.sh mesh
|
||||
|
||||
- name: Inject admission-cap config (admission-cap)
|
||||
if: matrix.type == 'admission-cap'
|
||||
run: bash testing/static/scripts/admission-cap-test.sh inject-config
|
||||
|
||||
- name: Start containers (admission-cap)
|
||||
if: matrix.type == 'admission-cap'
|
||||
run: |
|
||||
docker compose -f testing/static/docker-compose.yml \
|
||||
--profile mesh up -d
|
||||
|
||||
- name: Run admission-cap test
|
||||
if: matrix.type == 'admission-cap'
|
||||
run: bash testing/static/scripts/admission-cap-test.sh
|
||||
|
||||
- name: Collect logs on failure (admission-cap)
|
||||
if: matrix.type == 'admission-cap' && failure()
|
||||
run: |
|
||||
docker compose -f testing/static/docker-compose.yml \
|
||||
--profile mesh logs --no-color | tail -300
|
||||
|
||||
- name: Stop containers (admission-cap)
|
||||
if: matrix.type == 'admission-cap' && always()
|
||||
run: |
|
||||
docker compose -f testing/static/docker-compose.yml \
|
||||
--profile mesh down --volumes --remove-orphans
|
||||
|
||||
# ── Real-deb install integration ────────────────────────────────────
|
||||
# The deb-install harness builds its own .deb from source in a
|
||||
# cargo-deb builder image; the pre-built Linux binary from the
|
||||
|
||||
15
.github/workflows/package-linux.yml
vendored
15
.github/workflows/package-linux.yml
vendored
@@ -19,7 +19,7 @@ jobs:
|
||||
outputs:
|
||||
linux_package_version: ${{ steps.linux_version.outputs.linux_package_version }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
@@ -61,7 +61,7 @@ jobs:
|
||||
deb_arch: arm64
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
@@ -72,11 +72,14 @@ jobs:
|
||||
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
|
||||
uses: actions-rust-lang/setup-rust-toolchain@v1
|
||||
with:
|
||||
cache: false
|
||||
rustflags: ''
|
||||
|
||||
- name: Cache Cargo registry + build
|
||||
if: ${{ env.ACT != 'true' }}
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry
|
||||
@@ -137,7 +140,7 @@ jobs:
|
||||
|
||||
- name: Upload artifact (GitHub only)
|
||||
if: ${{ env.ACT != 'true' }}
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: fips_${{ needs.determine-versioning.outputs.linux_package_version }}_${{ matrix.artifact_arch }}_linux
|
||||
path: |
|
||||
@@ -161,7 +164,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Download Linux artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
path: dist
|
||||
merge-multiple: true
|
||||
|
||||
144
.github/workflows/package-macos.yml
vendored
144
.github/workflows/package-macos.yml
vendored
@@ -19,7 +19,7 @@ jobs:
|
||||
outputs:
|
||||
macos_package_version: ${{ steps.macos_version.outputs.macos_package_version }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
@@ -61,7 +61,7 @@ jobs:
|
||||
target: x86_64-apple-darwin
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
@@ -69,13 +69,14 @@ jobs:
|
||||
run: echo "SOURCE_DATE_EPOCH=$(git log -1 --format=%ct)" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Add cross-compile target
|
||||
run: rustup target add ${{ matrix.target }}
|
||||
uses: actions-rust-lang/setup-rust-toolchain@v1
|
||||
with:
|
||||
target: ${{ matrix.target }}
|
||||
cache: false
|
||||
rustflags: ''
|
||||
|
||||
- name: Cache Cargo registry + build
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry
|
||||
@@ -100,14 +101,22 @@ jobs:
|
||||
shell: bash
|
||||
run: |
|
||||
: ${GITHUB_OUTPUT:=/tmp/github_output}
|
||||
set -euo pipefail
|
||||
|
||||
PKG_FILE=$(find deploy -maxdepth 1 -type f -name "fips-*-macos-*.pkg" | sort | head -n 1)
|
||||
if [[ -z "$PKG_FILE" ]]; then
|
||||
echo "Missing macOS package" >&2
|
||||
# build-pkg.sh names the package from the build target, so each
|
||||
# matrix leg produces a distinctly named, arch-correct asset.
|
||||
# Assert that here: a regression in that naming then fails loudly
|
||||
# at the build stage instead of as a silent collision when the
|
||||
# release job merges both artifacts into one directory.
|
||||
EXPECTED="deploy/fips-${{ needs.determine-versioning.outputs.macos_package_version }}-macos-${{ matrix.arch }}.pkg"
|
||||
if [[ ! -f "$EXPECTED" ]]; then
|
||||
echo "Expected package $EXPECTED was not produced" >&2
|
||||
echo "deploy/ contains:" >&2
|
||||
ls -la deploy >&2 || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "pkg=$PKG_FILE" >> "$GITHUB_OUTPUT"
|
||||
echo "pkg=$EXPECTED" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Verify .pkg structural correctness
|
||||
shell: bash
|
||||
@@ -185,16 +194,27 @@ jobs:
|
||||
fi
|
||||
echo "==> .pkg verification PASSED"
|
||||
|
||||
- name: SHA-256 hash
|
||||
- name: SHA-256 hash and sidecar
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
PKG="${{ steps.macos-assets.outputs.pkg }}"
|
||||
echo "==> macOS release asset:"
|
||||
shasum -a 256 "${{ steps.macos-assets.outputs.pkg }}"
|
||||
# Capture the SHA-256 of the verified .pkg on the macOS runner and
|
||||
# write it to a sidecar file next to the .pkg, in the standard
|
||||
# `<hash> <basename>` shasum format. The verify-handoff and release
|
||||
# jobs re-check the downloaded bytes against this value, so any
|
||||
# corruption introduced after this point is detected before
|
||||
# publication.
|
||||
( cd "$(dirname "$PKG")" && shasum -a 256 "$(basename "$PKG")" | tee "$(basename "$PKG").sha256" )
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: fips_${{ needs.determine-versioning.outputs.macos_package_version }}_${{ matrix.arch }}_macos
|
||||
path: ${{ steps.macos-assets.outputs.pkg }}
|
||||
path: |
|
||||
${{ steps.macos-assets.outputs.pkg }}
|
||||
${{ steps.macos-assets.outputs.pkg }}.sha256
|
||||
retention-days: 30
|
||||
|
||||
- name: Build summary
|
||||
@@ -202,21 +222,111 @@ jobs:
|
||||
echo "Build Summary for macOS/${{ matrix.arch }}:"
|
||||
echo " Package: ${{ steps.macos-assets.outputs.pkg }}"
|
||||
|
||||
verify-handoff:
|
||||
name: Verify macOS package handoff integrity
|
||||
runs-on: ubuntu-latest
|
||||
needs: build
|
||||
|
||||
steps:
|
||||
- name: Download macOS artifacts
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
path: dist
|
||||
merge-multiple: true
|
||||
|
||||
- name: Verify .pkg integrity across the handoff
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cd dist
|
||||
|
||||
pkgs=$(find . -maxdepth 1 -type f -name '*.pkg' | LC_ALL=C sort)
|
||||
if [[ -z "$pkgs" ]]; then
|
||||
echo "FAIL: no .pkg artifacts were downloaded" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
fail=0
|
||||
while IFS= read -r pkg; do
|
||||
base=$(basename "$pkg")
|
||||
sidecar="${pkg}.sha256"
|
||||
if [[ ! -f "$sidecar" ]]; then
|
||||
echo "FAIL: missing SHA-256 sidecar for $base" >&2
|
||||
fail=1
|
||||
continue
|
||||
fi
|
||||
expected=$(awk '{print $1}' "$sidecar")
|
||||
actual=$(sha256sum "$pkg" | awk '{print $1}')
|
||||
if [[ "$expected" != "$actual" ]]; then
|
||||
echo "FAIL: $base SHA-256 mismatch across the artifact handoff" >&2
|
||||
echo " expected (macOS runner): $expected" >&2
|
||||
echo " actual (downloaded): $actual" >&2
|
||||
fail=1
|
||||
continue
|
||||
fi
|
||||
echo "PASS: $base matches the macOS-runner SHA-256 ($actual)"
|
||||
done <<<"$pkgs"
|
||||
|
||||
if [[ "$fail" -ne 0 ]]; then
|
||||
echo "==> macOS package handoff verification FAILED" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "==> macOS package handoff verification PASSED"
|
||||
|
||||
release:
|
||||
name: Publish macOS assets to GitHub Release
|
||||
runs-on: ubuntu-latest
|
||||
needs: build
|
||||
needs: [build, verify-handoff]
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
steps:
|
||||
- name: Download macOS artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
path: dist
|
||||
merge-multiple: true
|
||||
|
||||
- name: Validate .pkg bytes before publishing
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cd dist
|
||||
|
||||
pkgs=$(find . -maxdepth 1 -type f -name '*.pkg' | LC_ALL=C sort)
|
||||
if [[ -z "$pkgs" ]]; then
|
||||
echo "FAIL: no .pkg artifacts were downloaded" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
fail=0
|
||||
while IFS= read -r pkg; do
|
||||
base=$(basename "$pkg")
|
||||
sidecar="${pkg}.sha256"
|
||||
if [[ ! -f "$sidecar" ]]; then
|
||||
echo "FAIL: missing SHA-256 sidecar for $base" >&2
|
||||
fail=1
|
||||
continue
|
||||
fi
|
||||
expected=$(awk '{print $1}' "$sidecar")
|
||||
actual=$(sha256sum "$pkg" | awk '{print $1}')
|
||||
if [[ "$expected" != "$actual" ]]; then
|
||||
echo "FAIL: $base SHA-256 mismatch on the bytes about to be published" >&2
|
||||
echo " expected (macOS runner): $expected" >&2
|
||||
echo " actual (downloaded): $actual" >&2
|
||||
fail=1
|
||||
continue
|
||||
fi
|
||||
echo "PASS: $base matches the macOS-runner SHA-256 ($actual)"
|
||||
done <<<"$pkgs"
|
||||
|
||||
if [[ "$fail" -ne 0 ]]; then
|
||||
echo "==> pre-publish .pkg verification FAILED; not publishing" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "==> pre-publish .pkg verification PASSED"
|
||||
|
||||
- name: Generate macOS release checksums
|
||||
run: |
|
||||
cd dist
|
||||
|
||||
470
.github/workflows/package-openwrt.yml
vendored
470
.github/workflows/package-openwrt.yml
vendored
@@ -24,9 +24,10 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
package_version: ${{ steps.version.outputs.package_version }}
|
||||
apk_version: ${{ steps.version.outputs.apk_version }}
|
||||
release_channel: ${{ steps.channel.outputs.release_channel }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
@@ -35,13 +36,20 @@ jobs:
|
||||
shell: bash
|
||||
run: |
|
||||
: ${GITHUB_OUTPUT:=/tmp/github_output}
|
||||
# package_version is the human-readable label used in artifact
|
||||
# filenames; apk_version is the apk-tools-compatible string embedded
|
||||
# in the .apk metadata. apk_version is built directly from the same
|
||||
# structured inputs (tag, or commit height) — no reparse of the
|
||||
# flattened package_version. See packaging/openwrt-apk/apk-version.sh.
|
||||
if [[ "$GITHUB_REF" == refs/tags/* ]]; then
|
||||
echo "package_version=${GITHUB_REF_NAME}" >> "$GITHUB_OUTPUT"
|
||||
echo "apk_version=$(sh packaging/openwrt-apk/apk-version.sh tag "${GITHUB_REF_NAME}")" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
BRANCH=$(echo "$GITHUB_REF_NAME" | sed 's|/|-|g')
|
||||
HEIGHT=$(git rev-list --count HEAD)
|
||||
HASH=$(git rev-parse --short HEAD)
|
||||
echo "package_version=${BRANCH}.${HEIGHT}.${HASH}" >> "$GITHUB_OUTPUT"
|
||||
echo "apk_version=$(sh packaging/openwrt-apk/apk-version.sh dev "${HEIGHT}")" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Determine release channel
|
||||
@@ -64,8 +72,8 @@ jobs:
|
||||
echo "release_channel=dev" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
build:
|
||||
name: Build .ipk (${{ matrix.openwrt_arch }})
|
||||
compile-binaries:
|
||||
name: Cross-compile (${{ matrix.openwrt_arch }})
|
||||
runs-on: ubuntu-latest
|
||||
needs: determine-versioning
|
||||
|
||||
@@ -96,23 +104,17 @@ jobs:
|
||||
# x86 routers / VMs
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set SOURCE_DATE_EPOCH from git
|
||||
run: echo "SOURCE_DATE_EPOCH=$(git log -1 --format=%ct)" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Initialize
|
||||
run: |
|
||||
PACKAGE_FILENAME=${{ env.PACKAGE_NAME }}_${{ needs.determine-versioning.outputs.package_version }}_${{ matrix.openwrt_arch }}.ipk
|
||||
echo "PACKAGE_FILENAME=$PACKAGE_FILENAME" >> $GITHUB_ENV
|
||||
|
||||
- name: Install Rust toolchain (stable)
|
||||
if: matrix.rust_channel == 'stable'
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
uses: actions-rust-lang/setup-rust-toolchain@v1
|
||||
with:
|
||||
targets: ${{ matrix.rust_target }}
|
||||
target: ${{ matrix.rust_target }}
|
||||
cache: false
|
||||
rustflags: ''
|
||||
|
||||
- name: Install Rust toolchain (nightly, Tier 3)
|
||||
if: matrix.rust_channel == 'nightly'
|
||||
@@ -122,7 +124,7 @@ jobs:
|
||||
|
||||
- name: Cache Cargo registry + build
|
||||
if: ${{ env.ACT != 'true' }}
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry
|
||||
@@ -151,6 +153,64 @@ jobs:
|
||||
- name: Install llvm-strip
|
||||
run: sudo apt-get update && sudo apt-get install -y --no-install-recommends llvm
|
||||
|
||||
# Cross-compile + strip once; both the .ipk and .apk packagers consume
|
||||
# these artifacts via --bin-dir, so the Rust build runs a single time
|
||||
# per architecture instead of once per package format.
|
||||
- name: Cross-compile and strip binaries
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cargo zigbuild --release --target ${{ matrix.rust_target }} \
|
||||
--bin fips --bin fipsctl --bin fipstop --bin fips-gateway
|
||||
RELEASE_DIR="target/${{ matrix.rust_target }}/release"
|
||||
mkdir -p out
|
||||
for b in fips fipsctl fipstop fips-gateway; do
|
||||
llvm-strip "$RELEASE_DIR/$b" 2>/dev/null || true
|
||||
cp "$RELEASE_DIR/$b" "out/$b"
|
||||
done
|
||||
ls -lh out/
|
||||
|
||||
- name: Upload binaries artifact
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: fips-bins-${{ matrix.openwrt_arch }}
|
||||
path: out/
|
||||
retention-days: 1
|
||||
|
||||
build:
|
||||
name: Build .ipk (${{ matrix.openwrt_arch }})
|
||||
runs-on: ubuntu-latest
|
||||
needs: [determine-versioning, compile-binaries]
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
# Must be a subset of compile-binaries' arches (this job consumes those
|
||||
# binary artifacts). Currently both ship aarch64 + x86_64.
|
||||
include:
|
||||
- build_arch: aarch64
|
||||
openwrt_arch: aarch64_cortex-a53
|
||||
- build_arch: x86_64
|
||||
openwrt_arch: x86_64
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set SOURCE_DATE_EPOCH from git
|
||||
run: echo "SOURCE_DATE_EPOCH=$(git log -1 --format=%ct)" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Initialize
|
||||
run: |
|
||||
PACKAGE_FILENAME=${{ env.PACKAGE_NAME }}_${{ needs.determine-versioning.outputs.package_version }}_${{ matrix.openwrt_arch }}.ipk
|
||||
echo "PACKAGE_FILENAME=$PACKAGE_FILENAME" >> $GITHUB_ENV
|
||||
|
||||
- name: Download prebuilt binaries
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: fips-bins-${{ matrix.openwrt_arch }}
|
||||
path: bins
|
||||
|
||||
- name: Install nak
|
||||
shell: bash
|
||||
run: |
|
||||
@@ -199,8 +259,7 @@ jobs:
|
||||
- name: Build .ipk
|
||||
env:
|
||||
PKG_VERSION: ${{ needs.determine-versioning.outputs.package_version }}
|
||||
LLVM_STRIP: llvm-strip
|
||||
run: ./packaging/openwrt-ipk/build-ipk.sh --arch ${{ matrix.build_arch }}
|
||||
run: ./packaging/openwrt-ipk/build-ipk.sh --arch ${{ matrix.build_arch }} --bin-dir "$GITHUB_WORKSPACE/bins"
|
||||
|
||||
- name: Install shellcheck (if missing)
|
||||
shell: bash
|
||||
@@ -226,6 +285,7 @@ jobs:
|
||||
"$FILES_DIR/etc/fips/firewall.sh"
|
||||
"$FILES_DIR/etc/hotplug.d/net/99-fips"
|
||||
"$FILES_DIR/etc/uci-defaults/90-fips-setup"
|
||||
"$FILES_DIR/usr/bin/fips-mesh-setup"
|
||||
)
|
||||
fail=0
|
||||
for f in "${TARGETS[@]}"; do
|
||||
@@ -345,6 +405,7 @@ jobs:
|
||||
./usr/bin/fipsctl
|
||||
./usr/bin/fipstop
|
||||
./usr/bin/fips-gateway
|
||||
./usr/bin/fips-mesh-setup
|
||||
./etc/init.d/fips
|
||||
./etc/init.d/fips-gateway
|
||||
./etc/fips/fips.yaml
|
||||
@@ -387,13 +448,13 @@ jobs:
|
||||
- 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
|
||||
sha256sum bins/fips bins/fipsctl bins/fipstop
|
||||
echo "==> Package:"
|
||||
sha256sum dist/${{ env.PACKAGE_FILENAME }}
|
||||
|
||||
- name: Upload artifact (GitHub only)
|
||||
if: ${{ env.ACT != 'true' }}
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: ${{ env.PACKAGE_FILENAME }}
|
||||
path: dist/${{ env.PACKAGE_FILENAME }}
|
||||
@@ -401,6 +462,7 @@ jobs:
|
||||
|
||||
- name: Upload to Blossom
|
||||
id: blossom_upload
|
||||
continue-on-error: true
|
||||
shell: bash
|
||||
env:
|
||||
BLOSSOM_SERVER: "https://blossom.primal.net"
|
||||
@@ -408,17 +470,30 @@ jobs:
|
||||
run: |
|
||||
: ${GITHUB_OUTPUT:=/tmp/github_output}
|
||||
|
||||
UPLOAD_RESPONSE=$(nak blossom upload \
|
||||
--server "$BLOSSOM_SERVER" \
|
||||
--sec "$NSEC" \
|
||||
"dist/${{ env.PACKAGE_FILENAME }}" < /dev/null)
|
||||
FILE_HASH=""
|
||||
for attempt in 1 2 3; do
|
||||
if UPLOAD_RESPONSE=$(nak blossom upload \
|
||||
--server "$BLOSSOM_SERVER" \
|
||||
--sec "$NSEC" \
|
||||
"dist/${{ env.PACKAGE_FILENAME }}" < /dev/null); then
|
||||
echo "Upload response (attempt $attempt):"
|
||||
echo "$UPLOAD_RESPONSE"
|
||||
FILE_HASH=$(echo "$UPLOAD_RESPONSE" | jq -r '.sha256')
|
||||
if [ -n "$FILE_HASH" ] && [ "$FILE_HASH" != "null" ]; then
|
||||
break
|
||||
fi
|
||||
echo "Upload response had no sha256 (attempt $attempt)"
|
||||
else
|
||||
echo "Blossom upload timed out or failed (attempt $attempt)"
|
||||
fi
|
||||
FILE_HASH=""
|
||||
[ "$attempt" -lt 3 ] && sleep $((attempt * 10))
|
||||
done
|
||||
|
||||
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"
|
||||
echo "Blossom upload did not succeed after 3 attempts; non-fatal."
|
||||
echo "The package still ships as a GitHub release artifact; only the"
|
||||
echo "supplementary Blossom/nostr distribution is skipped this run."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -429,6 +504,7 @@ jobs:
|
||||
|
||||
- name: Publish NIP-94 release event
|
||||
id: publish
|
||||
if: steps.blossom_upload.outcome == 'success'
|
||||
shell: bash
|
||||
env:
|
||||
RELAYS: "wss://relay.damus.io wss://nos.lol wss://nostr.mom wss://offchain.pub"
|
||||
@@ -450,6 +526,7 @@ jobs:
|
||||
--tag A="${{ matrix.openwrt_arch }}" \
|
||||
--tag v="$VERSION" \
|
||||
--tag n="${{ env.PACKAGE_NAME }}" \
|
||||
--tag format="ipk" \
|
||||
--tag compression="none" \
|
||||
> event.json 2> event.err
|
||||
|
||||
@@ -507,23 +584,352 @@ jobs:
|
||||
echo " Release EventId: ${{ steps.publish.outputs.eventId }}"
|
||||
echo " Blossom URL: ${{ steps.blossom_upload.outputs.url }}"
|
||||
|
||||
build-apk:
|
||||
name: Build .apk (${{ matrix.openwrt_arch }})
|
||||
runs-on: ubuntu-latest
|
||||
needs: [determine-versioning, compile-binaries]
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
# Must be a subset of compile-binaries' arches.
|
||||
include:
|
||||
- build_arch: aarch64
|
||||
openwrt_arch: aarch64_cortex-a53
|
||||
# MT3000, MT6000, Flint 2, RPi 3/4/5 on OpenWrt 25+
|
||||
- build_arch: x86_64
|
||||
openwrt_arch: x86_64
|
||||
# x86 routers / VMs on OpenWrt 25+
|
||||
|
||||
env:
|
||||
# apk-tools commit OpenWrt pins for the .apk (ADB) format. Keep in sync
|
||||
# with package/system/apk/Makefile in the targeted OpenWrt release so the
|
||||
# packages we produce are readable by the apk on the device.
|
||||
APK_TOOLS_VERSION: "3.0.5"
|
||||
APK_TOOLS_COMMIT: "b5a31c0d865342ad80be10d68f1bb3d3ad9b0866"
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set SOURCE_DATE_EPOCH from git
|
||||
run: echo "SOURCE_DATE_EPOCH=$(git log -1 --format=%ct)" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Initialize
|
||||
run: |
|
||||
PACKAGE_FILENAME=${{ env.PACKAGE_NAME }}_${{ needs.determine-versioning.outputs.package_version }}_${{ matrix.openwrt_arch }}.apk
|
||||
echo "PACKAGE_FILENAME=$PACKAGE_FILENAME" >> $GITHUB_ENV
|
||||
|
||||
- name: Download prebuilt binaries
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: fips-bins-${{ matrix.openwrt_arch }}
|
||||
path: bins
|
||||
|
||||
- name: Install fakeroot
|
||||
run: sudo apt-get update && sudo apt-get install -y --no-install-recommends fakeroot
|
||||
|
||||
# apk mkpkg lives in apk-tools v3, which is not packaged for Ubuntu, so we
|
||||
# build the pinned release from source. This is the SDK-free equivalent of
|
||||
# how the .ipk path uses plain tar — one small C tool, no OpenWrt SDK.
|
||||
- name: Build apk-tools (${{ env.APK_TOOLS_VERSION }}) from source
|
||||
run: |
|
||||
set -euo pipefail
|
||||
sudo apt-get install -y --no-install-recommends \
|
||||
git ca-certificates build-essential meson ninja-build pkg-config \
|
||||
zlib1g-dev libssl-dev libzstd-dev liblzma-dev lua5.4-dev scdoc
|
||||
git clone --quiet https://gitlab.alpinelinux.org/alpine/apk-tools.git /tmp/apk-tools
|
||||
cd /tmp/apk-tools
|
||||
git checkout --quiet "${APK_TOOLS_COMMIT}"
|
||||
meson setup build
|
||||
ninja -C build src/apk
|
||||
APK_BIN=/tmp/apk-tools/build/src/apk
|
||||
"$APK_BIN" --version 2>/dev/null || "$APK_BIN" version 2>/dev/null || true
|
||||
echo "APK_BIN=$APK_BIN" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Build .apk
|
||||
env:
|
||||
PKG_VERSION: ${{ needs.determine-versioning.outputs.package_version }}
|
||||
APK_VERSION: ${{ needs.determine-versioning.outputs.apk_version }}
|
||||
run: ./packaging/openwrt-apk/build-apk.sh --arch ${{ matrix.build_arch }} --bin-dir "$GITHUB_WORKSPACE/bins"
|
||||
|
||||
- name: Verify apk structural integrity
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
APK="dist/${{ env.PACKAGE_FILENAME }}"
|
||||
if [ ! -s "$APK" ]; then
|
||||
echo "FAIL: produced apk not found or empty at $APK"
|
||||
exit 1
|
||||
fi
|
||||
echo "==> file type:"; file "$APK"
|
||||
|
||||
# apk v3 packages are ADB containers; dump the whole manifest with the
|
||||
# apk-tools we just built. Print it in full so the exact schema is
|
||||
# always visible in the log if an assertion needs adjusting.
|
||||
DUMP=$(mktemp)
|
||||
"$APK_BIN" adbdump "$APK" > "$DUMP" 2>/dev/null || {
|
||||
echo "FAIL: 'apk adbdump' could not read $APK"; exit 1; }
|
||||
echo "==> full adbdump:"; cat "$DUMP"
|
||||
|
||||
fail=0
|
||||
# Package metadata (flat keys under info:).
|
||||
for needle in "name: fips" "version: ${{ needs.determine-versioning.outputs.apk_version }}" "arch: ${{ matrix.openwrt_arch }}"; do
|
||||
if grep -qF "$needle" "$DUMP"; then
|
||||
echo " PASS meta: $needle"
|
||||
else
|
||||
echo " FAIL meta: missing '$needle'"; fail=1
|
||||
fi
|
||||
done
|
||||
|
||||
# installed-size reflects the bundled binaries (4 stripped Rust
|
||||
# binaries, several MB). A payload regression that drops them shows up
|
||||
# here regardless of how the path tree is formatted.
|
||||
SIZE=$(awk '/^[[:space:]]*installed-size:/ {print $2; exit}' "$DUMP")
|
||||
echo " installed-size: ${SIZE:-unknown}"
|
||||
if [ -z "${SIZE:-}" ] || [ "$SIZE" -lt 1000000 ]; then
|
||||
echo " FAIL: installed-size implausibly small (binaries missing?)"; fail=1
|
||||
else
|
||||
echo " PASS: installed-size >= 1MB"
|
||||
fi
|
||||
|
||||
# The adbdump paths: block is hierarchical. Each directory is a
|
||||
# top-level list item "- name: <full relative dir>"; its files are
|
||||
# "- name: <basename>" nested one indent level deeper under "files:".
|
||||
# (There are no "path:" keys.) Reconstruct full file paths by keying
|
||||
# off the indentation of the directory-level list items.
|
||||
RECON=$(awk '
|
||||
/^paths:/ {p=1; diri=-1; next}
|
||||
p && /^[^ #-]/ {p=0} # a new top-level key ends paths:
|
||||
!p {next}
|
||||
match($0, /^ *- /) {
|
||||
ind=RLENGTH; rest=substr($0, RLENGTH+1)
|
||||
if (diri==-1) diri=ind # first list item = directory indent
|
||||
if (ind==diri) { # directory entry (or the root acl: entry)
|
||||
if (rest ~ /^name: /) { dir=rest; sub(/^name: /,"",dir) } else dir=""
|
||||
next
|
||||
}
|
||||
if (rest ~ /^name: /) { # deeper item = a file under files:
|
||||
f=rest; sub(/^name: /,"",f); print (dir==""?f:dir"/"f)
|
||||
}
|
||||
}
|
||||
' "$DUMP")
|
||||
echo "==> reconstructed paths:"; printf '%s\n' "$RECON"
|
||||
|
||||
for path in \
|
||||
usr/bin/fips usr/bin/fipsctl usr/bin/fipstop usr/bin/fips-gateway \
|
||||
usr/bin/fips-mesh-setup \
|
||||
etc/init.d/fips etc/init.d/fips-gateway \
|
||||
etc/fips/fips.yaml etc/fips/firewall.sh etc/dnsmasq.d/fips.conf \
|
||||
etc/sysctl.d/fips-gateway.conf etc/sysctl.d/fips-bridge.conf \
|
||||
etc/hotplug.d/net/99-fips etc/uci-defaults/90-fips-setup \
|
||||
lib/upgrade/keep.d/fips; do
|
||||
if printf '%s\n' "$RECON" | grep -qxF "$path"; then
|
||||
echo " PASS path: $path"
|
||||
else
|
||||
echo " FAIL path: missing $path"; fail=1
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "$fail" -ne 0 ]; then
|
||||
echo "apk structural verification FAILED"
|
||||
exit 1
|
||||
fi
|
||||
echo "apk structural verification PASS"
|
||||
|
||||
- name: SHA-256 hashes
|
||||
run: |
|
||||
echo "==> Binaries:"
|
||||
sha256sum bins/fips bins/fipsctl bins/fipstop
|
||||
echo "==> Package:"
|
||||
sha256sum dist/${{ env.PACKAGE_FILENAME }}
|
||||
|
||||
- name: Upload artifact (GitHub only)
|
||||
if: ${{ env.ACT != 'true' }}
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: ${{ env.PACKAGE_FILENAME }}
|
||||
path: dist/${{ env.PACKAGE_FILENAME }}
|
||||
retention-days: 30
|
||||
|
||||
- name: Install nak
|
||||
shell: bash
|
||||
run: |
|
||||
NAK_VERSION="0.16.2"
|
||||
ARCH=$(uname -m)
|
||||
case "$ARCH" in
|
||||
x86_64|amd64) NAK_ARCH="amd64" ;;
|
||||
aarch64|arm64) NAK_ARCH="arm64" ;;
|
||||
*) echo "Unsupported architecture: $ARCH"; exit 1 ;;
|
||||
esac
|
||||
curl -fsSL "https://github.com/fiatjaf/nak/releases/download/v${NAK_VERSION}/nak-v${NAK_VERSION}-linux-${NAK_ARCH}" \
|
||||
-o /usr/local/bin/nak
|
||||
chmod +x /usr/local/bin/nak
|
||||
nak --version
|
||||
|
||||
- name: Install jq
|
||||
run: |
|
||||
if ! command -v jq &>/dev/null; then
|
||||
sudo apt-get update && sudo apt-get install -y jq
|
||||
fi
|
||||
|
||||
# Priority: HIVE_CI_NSEC from env (loom job) > repo secret > generate ephemeral
|
||||
- name: Resolve signing key
|
||||
id: keys
|
||||
shell: bash
|
||||
env:
|
||||
SECRET_NSEC: ${{ secrets.HIVE_CI_NSEC }}
|
||||
run: |
|
||||
: ${GITHUB_OUTPUT:=/tmp/github_output}
|
||||
if [ -n "${HIVE_CI_NSEC:-}" ]; then
|
||||
echo "Using HIVE_CI_NSEC from loom job environment"
|
||||
NSEC="$HIVE_CI_NSEC"
|
||||
elif [ -n "$SECRET_NSEC" ]; then
|
||||
echo "Using HIVE_CI_NSEC from repository secrets"
|
||||
NSEC="$SECRET_NSEC"
|
||||
else
|
||||
echo "No nsec provided -- generating ephemeral keypair"
|
||||
NSEC=$(nak key generate)
|
||||
fi
|
||||
|
||||
PUBKEY=$(echo "$NSEC" | nak key public)
|
||||
echo "::add-mask::$NSEC"
|
||||
echo "nsec=$NSEC" >> "$GITHUB_OUTPUT"
|
||||
echo "pubkey=$PUBKEY" >> "$GITHUB_OUTPUT"
|
||||
echo "Publisher pubkey (hex): $PUBKEY"
|
||||
|
||||
- name: Upload to Blossom
|
||||
id: blossom_upload
|
||||
continue-on-error: true
|
||||
shell: bash
|
||||
env:
|
||||
BLOSSOM_SERVER: "https://blossom.primal.net"
|
||||
NSEC: ${{ steps.keys.outputs.nsec }}
|
||||
run: |
|
||||
: ${GITHUB_OUTPUT:=/tmp/github_output}
|
||||
|
||||
FILE_HASH=""
|
||||
for attempt in 1 2 3; do
|
||||
if UPLOAD_RESPONSE=$(nak blossom upload \
|
||||
--server "$BLOSSOM_SERVER" \
|
||||
--sec "$NSEC" \
|
||||
"dist/${{ env.PACKAGE_FILENAME }}" < /dev/null); then
|
||||
echo "Upload response (attempt $attempt):"
|
||||
echo "$UPLOAD_RESPONSE"
|
||||
FILE_HASH=$(echo "$UPLOAD_RESPONSE" | jq -r '.sha256')
|
||||
if [ -n "$FILE_HASH" ] && [ "$FILE_HASH" != "null" ]; then
|
||||
break
|
||||
fi
|
||||
echo "Upload response had no sha256 (attempt $attempt)"
|
||||
else
|
||||
echo "Blossom upload timed out or failed (attempt $attempt)"
|
||||
fi
|
||||
FILE_HASH=""
|
||||
[ "$attempt" -lt 3 ] && sleep $((attempt * 10))
|
||||
done
|
||||
|
||||
if [ -z "$FILE_HASH" ] || [ "$FILE_HASH" = "null" ]; then
|
||||
echo "Blossom upload did not succeed after 3 attempts; non-fatal."
|
||||
echo "The package still ships as a GitHub release artifact; only the"
|
||||
echo "supplementary Blossom/nostr distribution is skipped this run."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
BLOSSOM_URL="${BLOSSOM_SERVER}/${FILE_HASH}"
|
||||
echo "url=$BLOSSOM_URL" >> "$GITHUB_OUTPUT"
|
||||
echo "hash=$FILE_HASH" >> "$GITHUB_OUTPUT"
|
||||
echo "Uploaded to Blossom: $BLOSSOM_URL"
|
||||
|
||||
- name: Publish NIP-94 release event
|
||||
id: publish
|
||||
if: steps.blossom_upload.outcome == 'success'
|
||||
shell: bash
|
||||
env:
|
||||
RELAYS: "wss://relay.damus.io wss://nos.lol wss://nostr.mom wss://offchain.pub"
|
||||
NSEC: ${{ steps.keys.outputs.nsec }}
|
||||
run: |
|
||||
: ${GITHUB_OUTPUT:=/tmp/github_output}
|
||||
set -e
|
||||
|
||||
VERSION="${{ needs.determine-versioning.outputs.package_version }}"
|
||||
CHANNEL="${{ needs.determine-versioning.outputs.release_channel }}"
|
||||
|
||||
nak event --sec "$NSEC" -k 1063 \
|
||||
-c "FIPS Package: ${{ env.PACKAGE_NAME }} for ${{ matrix.openwrt_arch }} (apk)" \
|
||||
--tag url="${{ steps.blossom_upload.outputs.url }}" \
|
||||
--tag m="application/octet-stream" \
|
||||
--tag x="${{ steps.blossom_upload.outputs.hash }}" \
|
||||
--tag ox="${{ steps.blossom_upload.outputs.hash }}" \
|
||||
--tag filename="${{ env.PACKAGE_FILENAME }}" \
|
||||
--tag A="${{ matrix.openwrt_arch }}" \
|
||||
--tag v="$VERSION" \
|
||||
--tag n="${{ env.PACKAGE_NAME }}" \
|
||||
--tag format="apk" \
|
||||
--tag compression="none" \
|
||||
> event.json 2> event.err
|
||||
|
||||
if [ ! -s event.json ]; then
|
||||
echo "Failed to create event"
|
||||
cat event.err 2>/dev/null || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "=== Event JSON ==="
|
||||
cat event.json
|
||||
echo "=================="
|
||||
|
||||
EVENT_ID=$(jq -r '.id' event.json)
|
||||
if [ -z "$EVENT_ID" ] || [ "$EVENT_ID" = "null" ]; then
|
||||
echo "Failed to extract event ID"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Publish to relays
|
||||
cat event.json | nak event $RELAYS 2>&1
|
||||
|
||||
echo "eventId=$EVENT_ID" >> "$GITHUB_OUTPUT"
|
||||
echo "Published NIP-94 event: $EVENT_ID"
|
||||
|
||||
- name: Build Summary
|
||||
run: |
|
||||
echo "Build Summary for ${{ matrix.openwrt_arch }} (apk):"
|
||||
echo " Package: ${{ env.PACKAGE_FILENAME }}"
|
||||
echo " apk version: ${{ needs.determine-versioning.outputs.apk_version }}"
|
||||
echo " Release EventId: ${{ steps.publish.outputs.eventId }}"
|
||||
echo " Blossom URL: ${{ steps.blossom_upload.outputs.url }}"
|
||||
|
||||
release:
|
||||
name: Publish GitHub Release (github only)
|
||||
runs-on: ubuntu-latest
|
||||
needs: build
|
||||
needs: [build, build-apk]
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
steps:
|
||||
- name: Download all .ipk artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
- name: Download package artifacts
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
# Only the .ipk/.apk packages (named fips_<ver>_<arch>.*), not the
|
||||
# fips-bins-* raw-binary artifacts shared between the build jobs.
|
||||
pattern: fips_*
|
||||
path: dist
|
||||
merge-multiple: true
|
||||
|
||||
- name: Generate OpenWrt release checksums
|
||||
run: |
|
||||
cd dist
|
||||
find . -maxdepth 1 -type f \( -name '*.ipk' -o -name '*.apk' \) -printf '%P\n' \
|
||||
| LC_ALL=C sort \
|
||||
| xargs sha256sum \
|
||||
> checksums-openwrt.txt
|
||||
|
||||
- name: Create release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
files: dist/*.ipk
|
||||
files: |
|
||||
dist/*.ipk
|
||||
dist/*.apk
|
||||
dist/checksums-openwrt.txt
|
||||
generate_release_notes: true
|
||||
|
||||
15
.github/workflows/package-windows.yml
vendored
15
.github/workflows/package-windows.yml
vendored
@@ -19,7 +19,7 @@ jobs:
|
||||
outputs:
|
||||
package_version: ${{ steps.version.outputs.package_version }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
@@ -52,7 +52,7 @@ jobs:
|
||||
needs: determine-versioning
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
@@ -63,10 +63,13 @@ jobs:
|
||||
echo "SOURCE_DATE_EPOCH=$epoch" >> $env:GITHUB_ENV
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
uses: actions-rust-lang/setup-rust-toolchain@v1
|
||||
with:
|
||||
cache: false
|
||||
rustflags: ''
|
||||
|
||||
- name: Cache Cargo registry + build
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry
|
||||
@@ -143,7 +146,7 @@ jobs:
|
||||
}
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: fips_${{ needs.determine-versioning.outputs.package_version }}_x86_64_windows
|
||||
path: deploy/fips-*-windows-*.zip
|
||||
@@ -167,7 +170,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Download Windows artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
path: dist
|
||||
merge-multiple: true
|
||||
|
||||
876
CHANGELOG.md
876
CHANGELOG.md
@@ -5,6 +5,882 @@ 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]
|
||||
|
||||
### Added
|
||||
|
||||
### Changed
|
||||
|
||||
- The Ethernet transport's per-interface `discovery` flag was renamed to
|
||||
`listen` (`transports.ethernet.*`) to match the symmetric `announce`
|
||||
(transmit) / `listen` (receive) neighbor-beacon vocabulary. The old
|
||||
`discovery:` key is still accepted via a serde alias, so deployed configs
|
||||
continue to load unchanged; `Config::to_yaml()` re-emits it under the
|
||||
canonical `listen:` name. Update your `fips.yaml` to `listen:`.
|
||||
|
||||
- The mesh-lookup control-metrics family is now emitted under the key
|
||||
`lookup` in `fipsctl stats metrics` and `show routing`. The former key
|
||||
`discovery` is still emitted as a deprecated alias carrying identical
|
||||
counters; update dashboards and alerts to read `lookup`.
|
||||
- The overloaded `node.discovery.*` config table was split into
|
||||
`node.lookup.*` (mesh-lookup scalars: `ttl`, `attempt_timeouts_secs`,
|
||||
`recent_expiry_secs`, `backoff_base_secs`, `backoff_max_secs`,
|
||||
`forward_min_interval_secs`) and `node.rendezvous.*` (peer rendezvous:
|
||||
`nostr.*`, `lan.*`). A deployed `node.discovery:` block still loads and is
|
||||
folded into the new tables with a one-time deprecation warning; migrate your
|
||||
`fips.yaml` to the new keys.
|
||||
|
||||
### Deprecated
|
||||
|
||||
- The `discovery` metric-family key (control-socket JSON). It is dual-emitted
|
||||
alongside the new `lookup` key during a migration window and will be removed.
|
||||
Migrate dashboards/alerts from `discovery.*` to `lookup.*`.
|
||||
- The `node.discovery.*` config table. Its keys were split into `node.lookup.*`
|
||||
(mesh-lookup) and `node.rendezvous.*` (peer rendezvous). A legacy
|
||||
`node.discovery:` block still applies for now with a deprecation warning and
|
||||
will be removed; migrate to `node.lookup.*` / `node.rendezvous.*`.
|
||||
- The Ethernet `transports.ethernet.discovery` flag, renamed to
|
||||
`transports.ethernet.listen`. The old key is still accepted via a serde
|
||||
alias and will be removed at the v2 cutover; migrate to `listen`.
|
||||
|
||||
### Fixed
|
||||
|
||||
## [0.4.1] - 2026-07-19
|
||||
|
||||
### Changed
|
||||
|
||||
- `node.bloom.max_inbound_fpr` default raised from `0.10` to `0.20`. The
|
||||
cap rejects inbound `FilterAnnounce` whose FPR (`fill^k`) exceeds it. On
|
||||
the fixed 1 KB / k=5 filter, `0.10` corresponds to fill 0.631 (~1,630
|
||||
reachable entries), and the busiest nodes' aggregates had again begun to
|
||||
reach it as the mesh grew. `0.20` (fill 0.7248, ~2,114 entries) restores
|
||||
headroom without materially weakening the antipoison gate: a saturated or
|
||||
poisoned filter is ~100% FPR and still rejected. This is the second raise
|
||||
of this cap in two releases; the fixed 1 KB filter is the underlying
|
||||
constraint, and the structural remedy is the v2 filter work rather than a
|
||||
further raise. A node running this default accepts announcements that a
|
||||
v0.4.0 node drops, so during a rolling upgrade the two versions can
|
||||
disagree about mesh size.
|
||||
- Bloom filter probing computes its SHA-256 digest once per operation
|
||||
rather than once per hash function. All k indices were already derived
|
||||
from a single digest, but the digest was recomputed inside the
|
||||
per-function loop, so every insert and membership test hashed the same
|
||||
bytes `hash_count` times (5x at the default). Output is bit-for-bit
|
||||
identical; this is the hottest path in packet forwarding and mesh-size
|
||||
estimation.
|
||||
- Identity operations reuse one shared `secp256k1` context instead of
|
||||
constructing a fresh one at every sign, verify, and key-derive site.
|
||||
Each construction allocated a context and ran randomization and blinding
|
||||
table setup. Behavior is unchanged: the same API calls are made, only the
|
||||
context lifetime differs, and the shared context still performs the
|
||||
standard construction-time blinding.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Spanning tree: the coordinate cache is now invalidated when the parent
|
||||
link is lost through peer removal. That path reparents or self-roots the
|
||||
node but omitted the invalidation every other position-change path
|
||||
performs, so cached entries for downstream destinations kept the node's
|
||||
now-stale coordinate prefix. Because routing access refreshes an entry's
|
||||
TTL, an actively routed stale entry never self-expired and was corrected
|
||||
only by a fresh insert.
|
||||
- Discovery: applying a `LookupResponse` now keeps the tighter of the
|
||||
cached and received `path_mtu` rather than overwriting unconditionally.
|
||||
A looser estimate arriving in a later response could clobber a tighter
|
||||
value already learned from a reactive `MtuExceeded` or
|
||||
`PathMtuNotification`, loosening a clamp that had been correctly
|
||||
tightened.
|
||||
|
||||
### Removed
|
||||
|
||||
- The `parent_switched` spanning-tree metric counter. It was incremented on
|
||||
the line immediately before `parent_switches` at every site and never
|
||||
independently, so the two were always identical. `parent_switches`
|
||||
remains as the sole counter. Consumers reading `parent_switched` from the
|
||||
control socket or `fipstop` should use `parent_switches`.
|
||||
|
||||
## [0.4.0] - 2026-06-27
|
||||
|
||||
### Added
|
||||
|
||||
#### Transports (Nym, mDNS LAN discovery)
|
||||
|
||||
- Nym mixnet transport (`transports.nym`) for outbound peer links
|
||||
tunneled through a local `nym-socks5-client` SOCKS5 proxy into the
|
||||
Nym mixnet, as a privacy transport alongside Tor. Outbound-only and
|
||||
not platform-gated, it reuses the existing FMP framing and adds no new
|
||||
crate dependencies. A single-container example
|
||||
(`examples/sidecar-nostr-mixnet-relay/`) demonstrates FIPS peering
|
||||
across the mixnet end to end.
|
||||
- Opt-in mDNS / DNS-SD LAN discovery for sub-second pairing of peers on
|
||||
the same local link, without a relay or NAT-traversal roundtrip.
|
||||
Disabled by default; operators enable it with
|
||||
`node.discovery.lan.enabled: true`. Configurable service type and an
|
||||
optional `node.discovery.lan.scope` that isolates discovery to peers
|
||||
sharing the same private-network scope. The advertised UDP port is
|
||||
chosen from a non-bootstrap operational UDP transport using a stable
|
||||
selector, so it is deterministic across restarts.
|
||||
|
||||
#### Admission / peer-list management
|
||||
|
||||
- `Node::update_peers` for runtime peer-list refresh, returning an
|
||||
`UpdatePeersOutcome` summarizing added, removed, and retained peers.
|
||||
Re-derives active peer connections from a new peer configuration
|
||||
without dropping links to peers that remain in the set.
|
||||
`PeerAddress` gains a `seen_at_ms` recency field (with
|
||||
`with_seen_at_ms`) used to prefer more recently observed addresses.
|
||||
|
||||
#### Data-plane / metrics / observability
|
||||
|
||||
- Typed `RejectReason` classification for receive-path silent-rejection
|
||||
sites across the node. Each rejection-and-return path now passes a
|
||||
typed reason to `NodeStats::record_reject`, which routes it to a
|
||||
per-subsystem counter, so operators can see what is being rejected
|
||||
through stats counters rather than by scraping debug logs. New
|
||||
`HandshakeStats`, `SessionStats`, and `MmpStats` sub-stats join the
|
||||
existing `TreeStats`, `BloomStats`, `DiscoveryStats`, and
|
||||
`ForwardingStats`, and `TreeStats::ancestry_invalid` is now
|
||||
incremented from the `TreeAnnounce::validate_semantics` rejection
|
||||
site that was previously silent. Several handshake, MMP, tree, and
|
||||
discovery rejection paths that had no counter at all are now counted,
|
||||
including the `send_lookup_response` no-route drop
|
||||
(`DiscoveryStats::resp_no_route`).
|
||||
- Internal atomic metric registry (`Arc<MetricsRegistry>`) that shadows
|
||||
the plain-`u64` `NodeStats` counters, written alongside them and
|
||||
validated by a whole-struct debug-build parity check. Covers the
|
||||
forwarding receive counters, the full discovery counter family, and the
|
||||
tree, bloom, congestion, and error-signal counter families, with
|
||||
the hottest counters cache-line padded. Behavior-neutral:
|
||||
`NodeStats` remains the serving path. Groundwork for sampling metrics
|
||||
without contending the receive loop.
|
||||
- `fipsctl stats metrics`, backed by a new counter-only `show_metrics`
|
||||
control query that dumps the atomic metric registry as flat counter
|
||||
name/value pairs. Serves a Prometheus-style scraper that samples node
|
||||
counters without contending the receive loop.
|
||||
- `pool_inbound` and `pool_outbound` counters on the TCP and Tor
|
||||
transport stats (`TcpStats`, `TorStats`). Per-direction accounting
|
||||
is updated at every pool-insert and receive-loop-exit site, plus on
|
||||
transport stop and on send-failure-driven removal. Surfaces through
|
||||
`TcpStatsSnapshot` and `TorStatsSnapshot` for `show_transports`.
|
||||
|
||||
#### Spanning-tree / mesh-size / routing
|
||||
|
||||
- Six route-class transit counters that partition transit-forwarded
|
||||
packets by their tree relationship to the chosen next hop: tree-up
|
||||
(peer is our ancestor), tree-down (peer is our descendant and the
|
||||
destination is within its subtree), tree-down-cross (peer is our
|
||||
descendant but the destination is outside its subtree), cross-link
|
||||
descend (lateral peer, destination within its subtree), cross-link
|
||||
ascend (lateral peer, destination outside its subtree), and
|
||||
direct-peer. The six classes sum to `forwarded_packets` (asserted by a
|
||||
unit test) and are computed from tree coordinates at the transit
|
||||
chokepoint, so error-signal routing callers are excluded. They surface
|
||||
through the forwarding stats snapshot via `show_routing` and
|
||||
`show_status`.
|
||||
- Discovery now counts `LookupRequest`s dropped when the dedup cache is
|
||||
full. A saturated `recent_requests` cache
|
||||
(`MAX_RECENT_DISCOVERY_REQUESTS`) previously dropped requests
|
||||
silently; a new `DiscoveryStats::req_dedup_cache_full` counter (typed
|
||||
reject reason `DiscoveryReject::ReqDedupCacheFull`) makes the drop
|
||||
visible through `show_routing`.
|
||||
|
||||
#### Packaging & deployment
|
||||
|
||||
- OpenWrt `.apk` packaging (`packaging/openwrt-apk/`, `make apk`) for
|
||||
OpenWrt 25+, where apk-tools is the mandatory package manager (the
|
||||
existing `.ipk` continues to cover OpenWrt 24.x and earlier). Built
|
||||
SDK-free: it reuses the `.ipk` cross-compile (`cargo-zigbuild`) and the
|
||||
shared installed-filesystem payload, and assembles the package with
|
||||
`apk mkpkg` from apk-tools 3.0.5 built from source — no OpenWrt SDK
|
||||
image. A `build-apk` CI job (aarch64, x86_64) builds and structurally
|
||||
verifies the package; releases now publish `.apk` artifacts and
|
||||
checksums alongside `.ipk`. Packages are unsigned, installed with
|
||||
`apk add --allow-untrusted`, matching the `.ipk` posture.
|
||||
- Nix flake (`flake.nix` at the project root) for reproducible
|
||||
from-source builds on Nix/NixOS. Builds all four binaries (`fips`,
|
||||
`fipsctl`, `fips-gateway`, `fipstop`), pins the exact toolchain from
|
||||
`rust-toolchain.toml` via fenix, and wires the build-time native
|
||||
dependencies (`libclang` for `bindgen`, plus `dbus` and `pkg-config`),
|
||||
so it needs no host setup beyond Nix with flakes enabled. Flake inputs
|
||||
are lock-pinned (`flake.lock` committed) for reproducibility, and the
|
||||
flake exposes `nix build`, `nix run`, a `nix develop` dev shell with the
|
||||
pinned toolchain, and `nix flake check`. The flake produces binaries
|
||||
(and a NixOS `packages.<system>.fips` output); the systemd/service
|
||||
integration that the `.deb`/tarball installers provide is handled
|
||||
through the NixOS configuration instead.
|
||||
|
||||
#### Docs & contributor tooling
|
||||
|
||||
- [`PR-REVIEW.md`](PR-REVIEW.md) — the 13-criteria PR review checklist
|
||||
the maintainer runs against every incoming PR, published at the
|
||||
repo root so contributors can run the same pass on their own change
|
||||
(directly or by handing the document to a coding agent) before
|
||||
opening. Linked from `CONTRIBUTING.md` under "Submitting pull
|
||||
requests" and "Further reading". Running the checklist before
|
||||
opening surfaces problems that would otherwise come back as review
|
||||
comments, saving a round trip.
|
||||
- [`docs/how-to/tune-file-descriptors.md`](docs/how-to/tune-file-descriptors.md)
|
||||
— an operator how-to for raising `RLIMIT_NOFILE`. A busy node opens
|
||||
roughly three file descriptors per established UDP peer (a
|
||||
`connect()`-ed socket plus a 2-FD drain self-pipe), so the default
|
||||
1024 soft limit is exhausted near 320 peers, after which further
|
||||
admission, handshakes, and discovery fail with `EMFILE`. The guide
|
||||
documents the per-peer FD budget and symptom, the systemd
|
||||
(`LimitNOFILE` drop-in) and OpenWrt (procd `nofile`) procedures to
|
||||
raise the limit, and how to verify the per-peer ratio stays bounded.
|
||||
Linked from the how-to index.
|
||||
|
||||
### Changed
|
||||
|
||||
#### FMP/FSP rekey reliability
|
||||
|
||||
- `complete_rekey_msg2` now returns the remote peer's startup epoch
|
||||
alongside the new Noise session, so the rekey path can detect a peer
|
||||
restart and clear stale session state.
|
||||
|
||||
#### NAT traversal / Nostr discovery
|
||||
|
||||
- Nostr discovery startup is now non-blocking. `Node::start` no
|
||||
longer waits for relay connect, subscribe, or initial advert
|
||||
publish before returning. A slow or unreachable relay no longer
|
||||
holds node startup hostage; local transports come up immediately
|
||||
and the relay path catches up asynchronously in background tasks.
|
||||
Subscribe retries with exponential backoff (2 s base, 60 s cap),
|
||||
publish attempts time out at 10 s, and the new tasks are aborted
|
||||
cleanly on `Node::stop`.
|
||||
|
||||
#### Spanning-tree / mesh-size / routing
|
||||
|
||||
- Active-peer path selection now sorts address candidates by recency
|
||||
(`seen_at_ms`), preferring the most recently observed address when
|
||||
racing concurrent path probes.
|
||||
- Per-tick work budgets bound the connection churn done in a single
|
||||
node tick: `MAX_DISCOVERY_CONNECTS_PER_TICK`,
|
||||
`MAX_RETRY_CONNECTIONS_PER_TICK`, and
|
||||
`MAX_PARALLEL_PATH_CANDIDATES_PER_PEER`. Work beyond a tick's budget
|
||||
is deferred to the next tick rather than discarded.
|
||||
|
||||
#### Admission / peer caps
|
||||
|
||||
- `node.bloom.max_inbound_fpr` default raised from `0.05` to `0.10`. The
|
||||
cap rejects inbound `FilterAnnounce` whose FPR (`fill^k`) exceeds it. On
|
||||
the fixed 1 KB / k=5 filter, `0.05` corresponds to fill 0.549 (~1,300
|
||||
reachable entries) and had begun rejecting the busiest nodes' aggregates
|
||||
as the mesh approached that size. `0.10` (fill 0.631, ~1,630 entries)
|
||||
restores headroom toward the fixed-filter capacity limit without
|
||||
materially weakening the antipoison gate: a saturated or poisoned filter
|
||||
is ~100% FPR and still rejected.
|
||||
- TCP inbound connection cap now honors `node.limits.max_connections`.
|
||||
The per-transport TCP inbound accept ceiling was hardwired to 256 and
|
||||
never read `max_connections`, so raising it was a silent no-op for
|
||||
inbound TCP. The effective cap now resolves with precedence: explicit
|
||||
per-transport `max_inbound_connections`, then node-wide
|
||||
`max_connections`, then the built-in default of 256. Established peers
|
||||
remain bounded node-wide by `add_connection`.
|
||||
|
||||
#### Data-plane / worker-pool / metrics / observability
|
||||
|
||||
- The control-socket read surface is now served off the `rx_loop`.
|
||||
Every pure-read `show_*` query — `show_status`, the `show_stats_*`
|
||||
family, `show_listening_sockets`, the new `show_metrics`,
|
||||
`show_tree`/`show_bloom`/`show_cache`/`show_routing`/
|
||||
`show_identity_cache`, `show_peers`/`show_sessions`/`show_links`/
|
||||
`show_connections`/`show_transports`/`show_mmp`, and `show_acl` — now
|
||||
renders in the control accept task from ArcSwap-published read
|
||||
snapshots instead of round-tripping the data-plane receive loop; only
|
||||
the mutating `connect`/`disconnect` commands still reach the loop.
|
||||
This removes the head-of-line coupling where a busy or slow `rx_loop`
|
||||
could time out `fipsctl` and `fipstop` observability (the five-second
|
||||
query pattern operators saw on loaded nodes). Per-entity snapshots
|
||||
reuse unchanged rows by pointer, so per-tick publish cost stays
|
||||
bounded as peer/session count grows. New daemon-resolved fields
|
||||
surface through the snapshots: effective persistence, root/is-root,
|
||||
and a per-transport-type peer-count map in `show_status`; per-peer
|
||||
`effective_depth` in `show_peers`; `root_npub` in `show_tree`; and the
|
||||
last-sent uptree filter fill ratio and subtree estimate in
|
||||
`show_bloom`.
|
||||
- `fipstop` TUI overhaul: reworked rendering, navigation model, and the
|
||||
control read surface it draws from, surfacing the new daemon-resolved
|
||||
snapshot fields above. Built on a ratatui `TestBackend`
|
||||
render-snapshot harness that asserts the text grid and per-cell
|
||||
style of every `ui::draw_*` against canned `show_*` JSON.
|
||||
- Steady-state log noise reduced on saturated public-mesh nodes.
|
||||
Routine per-peer connection-lifecycle and capacity-cap events are
|
||||
demoted from info/warn to debug — FMP K-bit cutover promotion,
|
||||
connection-promoted-to-active-peer (a redundant duplicate
|
||||
promotion line removed), peer-restart-detected, peer-removed-and-
|
||||
cleaned-up, the TCP `max_inbound_connections`-reached rejection, and
|
||||
the congestion-CE-flag line — so genuinely notable info/warn lines
|
||||
are no longer drowned out. An exhausted FMP-msg1 / FSP-msg3 rekey
|
||||
retransmission-budget abort (an expected, self-limiting outcome on
|
||||
lossy or high-latency links) is likewise demoted from warn to debug.
|
||||
- macOS UDP receive path now batches up to 32 datagrams per kernel
|
||||
wakeup via `recvmsg_x(2)`, matching the Linux `recvmmsg(2)`
|
||||
amortization shape introduced in v0.3.0. Previously macOS fell
|
||||
through to single-packet `recv_from`, capping inbound rate on
|
||||
Apple builds with the same per-syscall + per-task-wakeup overhead
|
||||
Linux had already eliminated. `recvmsg_x` is an xnu-private syscall
|
||||
declared via `unsafe extern "C"` against a local repr(C)
|
||||
`msghdr_x`; same approach used by `quinn-udp`. Same
|
||||
`(count, kernel_drops)` contract as the Linux path, with
|
||||
`kernel_drops` always 0 on macOS (no `SO_RXQ_OVFL` equivalent).
|
||||
Bench numbers on aarch64-apple-darwin (100B payloads, 3 s
|
||||
windows): 1 sender 1.09x, 2 senders 1.72x, 4 senders 1.56x,
|
||||
8 senders 1.46x.
|
||||
- Receive hot path: removed two per-packet copies. New borrowed
|
||||
`SessionDatagramRef` decoder is used in the forwarding handler so
|
||||
local delivery and coordinate-cache warming no longer allocate or
|
||||
copy the session payload; the owned `SessionDatagram` is materialized
|
||||
only when re-encoding for the next hop. Owned `SessionDatagram::
|
||||
decode` is reimplemented as `Ref::decode + into_owned`, so the two
|
||||
decoders cannot drift. On Linux + macOS the `recvmmsg` / `recvmsg_x`
|
||||
receive loop now moves each filled slot buffer into `ReceivedPacket`
|
||||
via `mem::replace` instead of cloning it, and `TransportAddr` is
|
||||
formatted directly from the `SocketAddr` without an intermediate
|
||||
`String`. Focused decode bench: ref 1.6 ns/op vs owned 34.7 ns/op
|
||||
(21.4x).
|
||||
- Quieted non-Linux test-build warnings from intentionally
|
||||
platform-specific code: the nftables firewall parser
|
||||
(`#[allow(dead_code)]` now gated to non-Linux targets where the
|
||||
parser is compiled but unused), the macOS `utun` address-family
|
||||
helper and the long TUN reader entry point (narrow allowances),
|
||||
and a macOS Ethernet test module's clippy struct-layout lint
|
||||
(rewritten MAC-copy loop, explicit layout annotation). No
|
||||
behavioral change; the goal is to keep `cargo test` and
|
||||
`cargo clippy` clean on cross-platform builds so unrelated
|
||||
warning fixes don't get bundled into behavioral PRs.
|
||||
- Data-plane: AEAD encrypt and AEAD decrypt now run on per-shard
|
||||
worker-pool threads (`std::thread` + `crossbeam_channel`), off the
|
||||
rx_loop. Hash-by-destination dispatch pins each TCP flow to one
|
||||
worker so wire ordering is preserved; per-worker `sendmmsg(2)`
|
||||
batches up to 32 outbound packets per syscall, with UDP_GSO
|
||||
(`UDP_SEGMENT`) when the batch is uniform-sized — the same kernel
|
||||
primitive WireGuard's in-kernel module and Cloudflare's userspace
|
||||
BoringTun use to hit multi-Gbps single-stream rates. On Linux +
|
||||
macOS each established UDP peer also gets a dedicated `connect(2)`-
|
||||
ed kernel socket bound to the same wildcard listen port via
|
||||
`SO_REUSEPORT`, so the kernel caches per-packet route + neighbor
|
||||
lookup and the worker sends with `msg_name = NULL`. The receive
|
||||
side mirrors: per-shard thread-local `HashMap` owns each session's
|
||||
recv cipher + replay window, replacing the previous shared
|
||||
`RwLock`. Sessions are re-registered with the decrypt pool on
|
||||
K-bit flip and rekey cutover, and unregistered on rekey drain
|
||||
completion and peer removal so the per-shard tables stay bounded.
|
||||
New `crossbeam-channel = "0.5"` dependency. Worker counts default
|
||||
to `num_cpus`; both pools are overridable via
|
||||
`FIPS_ENCRYPT_WORKERS` and `FIPS_DECRYPT_WORKERS` (the latter
|
||||
accepts `0` to disable the pool and fall back to in-line decrypt
|
||||
in rx_loop). Per-peer connected UDP can be disabled via
|
||||
`FIPS_CONNECTED_UDP=0`. Optional per-stage timing reporter
|
||||
available via `FIPS_PERF=1` (or `FIPS_PIPELINE_TRACE=1`); detailed
|
||||
knob documentation is a follow-up at
|
||||
`docs/how-to/tune-worker-pools.md`. Bench (5 × 15 s × 1 stream
|
||||
medians, Linux x86_64, docker-bridge mesh): A→D 1379→2708 Mbps
|
||||
(1.96×), A→E 1394→2663 Mbps (1.91×), E→A 1406→2624 Mbps (1.87×);
|
||||
RTT +0.11–0.19 ms from the worker queue handoff. Windows
|
||||
continues on the existing tokio-based send/recv path. Two issues in
|
||||
the off-rx_loop drain path are resolved as part of the overhaul: the
|
||||
per-peer drain worker is now detached on `Drop` rather than joined
|
||||
synchronously (a synchronous join from the runtime thread could wedge
|
||||
the whole daemon when a peer was removed with an in-flight worker),
|
||||
and the connected-UDP drain no longer busy-spins on a poll error
|
||||
(#106).
|
||||
|
||||
#### Transports & config
|
||||
|
||||
- Static host aliases in `/etc/fips/hosts` now hot-reload on mtime
|
||||
change instead of only at daemon startup, so `fipsctl`/`fipstop`
|
||||
display names reflect edits without a restart. The peer ACL and host
|
||||
map both reload once per node tick through a new lock-free
|
||||
`Reloadable` snapshot.
|
||||
- Sidecar example (`examples/sidecar-nostr-relay`): `udp.mtu` is now
|
||||
overridable via the `FIPS_UDP_MTU` environment variable, defaulting to
|
||||
1472 (preserving prior behavior). Plumbed through `docker-compose.yml`
|
||||
and documented in the README env-var table. Annotated the static-CI
|
||||
node template `mtu: 1472` literal with the same Docker-bridge
|
||||
rationale and a pointer at the daemon's 1280 default.
|
||||
|
||||
#### Packaging & deployment
|
||||
|
||||
- The Debian package no longer ships `/etc/fips/fips.yaml` as a dpkg
|
||||
conf-file. The default configuration is installed as an example at
|
||||
`/usr/share/fips/fips.yaml.example`, and `postinst` seeds
|
||||
`/etc/fips/fips.yaml` (mode 600) from it only when the file does not
|
||||
already exist — so a configuration-management-rendered or
|
||||
operator-edited config is never prompted for or clobbered on
|
||||
upgrade, removing the need for a `dpkg-divert` workaround.
|
||||
`fips.service` gains `ConditionPathExists=/etc/fips/fips.yaml`. The
|
||||
example is placed under `/usr/share/fips`, deliberately outside
|
||||
`/usr/share/doc`, which minimal and container installs path-exclude
|
||||
(so the install-time seed source is never dropped).
|
||||
- openwrt: the `.apk` package now defaults `ethernet.wan` to the
|
||||
OpenWrt 25 DSA port name `wan`; the `.ipk` package keeps `eth0` for
|
||||
OpenWrt 24 and earlier.
|
||||
|
||||
#### CI & test-harness reliability
|
||||
|
||||
- CI and release-publish workflows hardened:
|
||||
- `ci.yml` declares a top-level `concurrency` block keyed on
|
||||
`(workflow, ref)` with `cancel-in-progress: true`. Force-pushes
|
||||
and rapid successive pushes to the same ref now retire any
|
||||
in-flight run rather than letting superseded and current-tip runs
|
||||
both burn runner minutes.
|
||||
- `aur-publish.yml` rewritten to fetch the upstream source tarball
|
||||
and compute its `b2sum` in CI, then patch `pkgver` and the
|
||||
`b2sums` SKIP placeholder in `PKGBUILD` in-place. Previously
|
||||
`updpkgsums: true` downloaded the tarball into the AUR working
|
||||
tree, where it was rejected by AUR's 488 KiB max-blob hook —
|
||||
silently no-op'ing the v0.3.0 stable AUR push. `fips.sysusers` /
|
||||
`fips.tmpfiles` asset b2sums are recomputed in the same step to
|
||||
stay in sync with the local files. `workflow_dispatch` gains a
|
||||
tag input so historical release tags can be re-published
|
||||
manually, and `continue-on-error: true` is dropped so future
|
||||
regressions surface in CI.
|
||||
- New `aur-publish-git.yml` workflow for the `fips-git` VCS
|
||||
PKGBUILD, triggered on master pushes touching `PKGBUILD-git` or
|
||||
companion files plus `workflow_dispatch`. `pkgver` is computed at
|
||||
build time by the PKGBUILD's `pkgver()` function, so this workflow
|
||||
is not tied to release tags.
|
||||
- Tag-triggered `package-*` release-build workflows remain
|
||||
untouched.
|
||||
- Local and GitHub CI integration coverage brought into parity, and
|
||||
the Rust toolchain selection given a single source of truth:
|
||||
- The `admission-cap` integration suite, previously run only by
|
||||
`ci-local.sh`, now also runs as a GitHub `ci.yml` matrix leg, so a
|
||||
regression in it turns the GitHub gate red rather than depending on
|
||||
a developer remembering to run local CI. A new
|
||||
`testing/check-ci-parity.sh` (wired as `ci-local.sh
|
||||
--check-parity`) diffs the two runners' integration-suite sets and
|
||||
fails on unexpected drift; the deliberate local-only (live-Tor)
|
||||
and granularity-only differences are documented in a comment block
|
||||
atop both runners.
|
||||
- CI and packaging jobs now select the toolchain with
|
||||
`actions-rust-lang/setup-rust-toolchain` (which reads
|
||||
`rust-toolchain.toml`) instead of `dtolnay/rust-toolchain@stable`.
|
||||
The pinned channel already overrode the installed stable, so each
|
||||
job downloaded an unused toolchain and logged a misleading `rustc`
|
||||
version; the single-source action removes the waste and the
|
||||
confusion. Existing cache steps are kept (`cache: false` on the
|
||||
new action) and `RUSTFLAGS` is left untouched so no global
|
||||
`-D warnings` is newly imposed. The OpenWrt nightly Tier-3 leg
|
||||
keeps `@nightly`.
|
||||
|
||||
#### Docs & contributor tooling
|
||||
|
||||
- Overhauled `CONTRIBUTING.md`: replaced generic Rust-template framing
|
||||
with a FIPS-specific entry point covering the four-layer
|
||||
architecture, branch model and PR-target selection, structured bug
|
||||
reporting, scope discipline and local-CI requirements, an AI coding
|
||||
assistant policy, and project communication channels. Added
|
||||
`docs/branching.md` as the long-form companion covering the release
|
||||
workflow, version conventions, and merge-direction rationale.
|
||||
|
||||
### Fixed
|
||||
|
||||
#### FMP/FSP rekey reliability
|
||||
|
||||
- FMP link-layer rekey is now reliable under packet loss, bringing it up
|
||||
to the FSP session layer's rekey discipline. The rekey msg1
|
||||
retransmission driver was previously uncapped and never abandoned, so a
|
||||
rekey that never completed resent msg1 forever; it now uses a bounded
|
||||
retransmission budget (`handshake_max_resends` with exponential
|
||||
backoff) and abandons the rekey cycle cleanly once the budget is
|
||||
exhausted, mirroring the FSP rekey msg3 driver. With the cap in place
|
||||
the link-dead heartbeat is rekey-aware: `check_link_heartbeats` no
|
||||
longer reaps a link that is still actively carrying rekey-handshake
|
||||
traffic, while a genuinely dead link is still reaped once the budget
|
||||
abandons. At the K-bit cutover the receiver now authenticates an
|
||||
inbound frame against the pending session before promoting it, instead
|
||||
of promoting on the bare header K-bit; under jitter a node could
|
||||
otherwise promote a stale pending session, leaving the two endpoints on
|
||||
different keys and silently dropping traffic until the link died — the
|
||||
same failure class already closed on FSP, now closed on FMP.
|
||||
- FSP session rekey is now hitless under packet loss and reordering.
|
||||
Previously, a rekey could leave the two endpoints holding different
|
||||
key sets for a brief window — if a handshake message was lost in
|
||||
transit one side rotated keys while the other did not, and traffic
|
||||
sealed in one key epoch reached a peer still on the other epoch and
|
||||
failed to decrypt, producing bursts of AEAD decryption failures and
|
||||
dropped connectivity until a later rekey reconverged the pair. The
|
||||
receive path now trial-decrypts each frame against every live key
|
||||
epoch (current, pending, and the draining previous session) for the
|
||||
duration of the rekey transition, so no rotation ordering and no
|
||||
packet reordering can cause a decryption failure. The previous-epoch
|
||||
slot is retained as long as the peer keeps using it, with its drain
|
||||
deadline anchored on the last frame the peer authenticates against
|
||||
it rather than a fixed wall-clock timer, so a peer that did not
|
||||
receive the new keys is not stranded by a silent permanent decrypt
|
||||
failure. The lost-handshake case is closed by retransmitting the
|
||||
third rekey handshake message until the peer is confirmed on the
|
||||
new keys, with a bounded retry budget after which the rekey cycle
|
||||
is cleanly abandoned and retried. There are no FSP decryption
|
||||
failures across a rekey under lossy, jittery links.
|
||||
- ±15s symmetric jitter is applied per session to the FMP and FSP rekey
|
||||
timer trigger, eliminating the steady-state dual-initiation race in
|
||||
symmetric-start meshes (previously the smaller-NodeAddr tie-breaker
|
||||
resolved correctness only after every cycle's collision).
|
||||
`node.rekey.after_secs` becomes the nominal interval rather than a
|
||||
floor; the mean is preserved.
|
||||
- A stale FSP (session-layer) session is now cleared when a peer
|
||||
restart is detected during FMP rekey or cross-connection promotion.
|
||||
Previously the old session could linger after the peer came back
|
||||
with a new startup epoch, leaving the session-layer map out of sync
|
||||
with the freshly promoted peer.
|
||||
|
||||
#### NAT traversal / Nostr discovery
|
||||
|
||||
- Two nodes that each `auto_connect` to the other no longer stall their
|
||||
Nostr-mediated NAT-traversal handshake. Each side ran both an
|
||||
initiator and a responder traversal session, binding a separate UDP
|
||||
socket per session, and adopted only the first `Established` event; if
|
||||
the two sides adopted mismatched sessions, each sent its Noise msg1 to
|
||||
a peer port the peer had already stopped draining and both handshakes
|
||||
hung until the adoption budget expired. The responder now elects a
|
||||
single session deterministically — it declines an incoming offer only
|
||||
when it also has an in-flight outbound initiator for the same peer and
|
||||
its own NodeAddr is smaller — so one matching socket pair survives on
|
||||
both ends and the peer's redundant initiator times out harmlessly.
|
||||
One-sided (asymmetric) `auto_connect` has no co-active initiator and is
|
||||
never suppressed, so connectivity is preserved.
|
||||
- NAT-traversal cross-init adoption is now deterministic under
|
||||
simultaneous dual-initiation. Previously, when two peers'
|
||||
Nostr-mediated UDP punches completed within the same scheduling
|
||||
window, each side's bootstrap-completion event arrived with an
|
||||
in-flight handshake already recorded against the other peer (each
|
||||
side had received an inbound msg1 from the other's pre-punch
|
||||
outbound attempt). The deduplication skip then fired on both
|
||||
sides, neither installed the fresh traversal socket as canonical,
|
||||
and the 45-second peer-adoption budget expired with both nodes
|
||||
stuck waiting for an adoption that never happened. The handler now
|
||||
applies the same deterministic NodeAddr tie-breaker the codebase
|
||||
already uses for rekey dual-initiation and cross-connection
|
||||
resolution: the smaller NodeAddr wins as adopter, tears down its
|
||||
in-flight handshake state, and proceeds with adoption; the larger
|
||||
NodeAddr keeps the skip semantics, and its in-flight outbound is
|
||||
reconciled by the cross-connection logic when the winner's fresh
|
||||
msg1 arrives over the adopted socket. The dual cross-init stall is
|
||||
eliminated; cross-init NAT-traversal completes in well under a
|
||||
second even under host CPU contention.
|
||||
- Nostr-discovered NAT-traversal events (`BootstrapEvent::Established`
|
||||
and `BootstrapEvent::Failed`) for peers that are already connected
|
||||
or actively handshaking are now short-circuited at the
|
||||
`poll_nostr_discovery` dispatch sites before any cooldown
|
||||
bookkeeping or fallback retry scheduling runs. Stale `Failed` events
|
||||
previously poisoned the per-peer failure-state cooldown of healthy
|
||||
peers and could trigger redundant retraversal attempts via
|
||||
`schedule_retry` / `try_peer_addresses`; stale `Established`
|
||||
handoffs could attempt to adopt a second socket against a live
|
||||
connection. A defense-in-depth guard was added to
|
||||
`adopt_established_traversal` so the same invariant holds if a
|
||||
future caller bypasses the outer dispatch check. As a side benefit,
|
||||
narrows a cooldown-poisoning vector previously available to an
|
||||
attacker injecting stale failure events for an active peer.
|
||||
- Nostr discovery now filters unroutable direct UDP/TCP advert
|
||||
endpoints. Publisher and validator retain only endpoints that parse as
|
||||
concrete socket addresses with routable IPs and nonzero ports;
|
||||
`udp:nat` rendezvous endpoints and Tor endpoints pass through
|
||||
unchanged. Adverts that collapse to zero usable endpoints after
|
||||
filtering are rejected with a clear "missing publicly routable
|
||||
endpoints" error. Before this change, misconfigured nodes could
|
||||
publish RFC1918, loopback, link-local, CGNAT 100.64/10, IPv6 ULA,
|
||||
or IPv6 link-local endpoints into Nostr discovery, and consumers
|
||||
would cache and dial them; in mixed LAN/VPN/NAT environments, that
|
||||
could prefer a misleading one-way private path over the intended
|
||||
`udp:nat` bootstrap.
|
||||
|
||||
#### Admission / peer caps
|
||||
|
||||
- TCP and Tor `max_inbound_connections` admission cap is now compared
|
||||
against the per-direction inbound count (`pool_inbound`) rather than
|
||||
the combined pool size. Outbound connect-on-send connections share
|
||||
the same pool data structure but no longer consume slots against the
|
||||
operator-facing inbound cap. The configuration field name and
|
||||
operator semantics are preserved; only the cap-check comparison and
|
||||
accounting change. Operators with mixed outbound + inbound
|
||||
deployments no longer see legitimate inbound peers rejected once
|
||||
outbound connections fill the pool past the configured cap.
|
||||
- Outbound connection initiation now honors the `node.limits.max_peers`
|
||||
cap that was previously only checked on inbound msg1 admission. Four
|
||||
paths gated: auto-reconnect retries (`process_pending_retries`),
|
||||
Nostr-mediated discovery's `BootstrapEvent::Established` adoption, and
|
||||
both sides of the Nostr-mediated NAT-traversal punch (offer initiation
|
||||
in the runtime's outgoing path, offer acceptance in the responder's
|
||||
incoming-offer handler). At saturation, a node now performs zero
|
||||
outbound work on these paths; only existing peer maintenance and
|
||||
overlay-advert refresh continue. The inbound gate at
|
||||
`handshake.rs:1114` is unchanged. Introduces a shared
|
||||
`Node::outbound_admission_check()` helper so the invariant is
|
||||
grep-able and unit-testable.
|
||||
- Inbound `handle_msg1` now silent-drops at `node.limits.max_peers`
|
||||
saturation *before* building/sending Msg2, instead of replying with
|
||||
Msg2 and then rejecting at `promote_connection`. Adds an early cap
|
||||
check positioned after identity verification (so the
|
||||
reconnect / cross-connection bypass for known peers still fires) and
|
||||
before index allocation + Msg2 wire send. The late cap check inside
|
||||
`promote_connection` is intentionally retained as
|
||||
defense-in-depth. Wire savings observed in a 45 s tcpdump at
|
||||
saturation: ~3.6 cap-denials/s × Msg2 (~104 B + AEAD compute) each.
|
||||
Bigger win is cleaner peer-side semantics — no fake-completed
|
||||
handshake whose subsequent data frames fail decryption on this side.
|
||||
|
||||
#### Spanning-tree / mesh-size / routing
|
||||
|
||||
- The mesh-size estimator (`compute_mesh_size`) no longer over-counts
|
||||
under filter overlap. It previously summed the per-filter cardinality
|
||||
of the parent and each child filter, which assumes the filters are
|
||||
perfectly disjoint; a stale or oversized parent filter or a routing
|
||||
loop inflated the reported mesh size to several times the true value,
|
||||
and dropping the parent on a tree rebalance collapsed the upward leg
|
||||
and flapped the count (the symptom operators saw as the size
|
||||
nearly-but-not-exactly doubling during rebalancing). The estimator now
|
||||
computes the cardinality of the OR-union over self plus every
|
||||
connected peer's inbound filter, dropping the parent/child tree gating
|
||||
entirely. OR is idempotent, so any overlap is deduplicated — the
|
||||
result equals the old sum in the disjoint case, stays correct under
|
||||
overlap, damps the parent-switch flap, and removes the estimate's
|
||||
dependence on tree-declaration cache freshness. The per-peer 500 ms
|
||||
rate-limiter and overall recompute cadence are unchanged.
|
||||
- Spanning-tree state distribution is now eventually-consistent.
|
||||
Previously every `send_tree_announce_to_all` call site fired only
|
||||
on a local state-change event (parent switch, self-root promotion,
|
||||
ancestry change, peer promotion, parent loss). Once a partition
|
||||
latched — for example, a parent-switch announce lost in transit
|
||||
via the brief cross-init handshake swap window where one peer's
|
||||
outbound session is about to become the loser session and the
|
||||
receiver has no matching decrypt-worker entry — no node's state
|
||||
changed again, so no node ever re-broadcast. The existing 60-second
|
||||
`check_periodic_parent_reeval` short-circuited silently on no-change
|
||||
(it was a re-evaluation, not a re-broadcast), and production-side
|
||||
healing depended on incidental link churn (NAT keepalive refresh,
|
||||
MMP timeout, peer re-promotion after a transport blip). The
|
||||
function now ends with an unconditional `send_tree_announce_to_all`
|
||||
on the no-change branch, alongside the existing switch and
|
||||
self-promote arms; receivers coalesce by sequence comparison
|
||||
(`ParentDeclaration::is_fresher_than`) and short-circuit at the
|
||||
`if !updated` gate in `handle_tree_announce`, so same-sequence
|
||||
repeats drop silently with no cascade. The per-peer 500 ms
|
||||
rate-limiter is well below this 60-second cadence and does not
|
||||
suppress the heartbeat broadcast. `BASELINE_CONVERGENCE_TIMEOUT`
|
||||
in `testing/static/scripts/rekey-test.sh` is bumped from 60 to 65
|
||||
so any partition healed by the periodic broadcast at T+60 lands
|
||||
inside the convergence window; `wait_for_full_baseline` early-exits
|
||||
on PASS, so successful reps see no extra wall-clock.
|
||||
- A single-uplink node stranded out of the tree now re-attaches within
|
||||
a round-trip instead of waiting for the periodic re-broadcast cadence.
|
||||
A node with one tree peer has periodic parent re-evaluation disabled,
|
||||
so a lost one-shot attaching `TreeAnnounce` left it self-rooted and
|
||||
unreachable until the next periodic re-broadcast
|
||||
(`reeval_interval_secs` later). Tree-position exchange is now
|
||||
self-healing on the receive path: when an accepted `TreeAnnounce`
|
||||
advertises a root strictly worse (higher NodeAddr; election is
|
||||
smallest-wins) than our own, we echo our current declaration back to
|
||||
that peer, provoking the better-rooted peer to re-push its real
|
||||
position immediately. The echo fires only in that one direction and is
|
||||
bounded by the existing per-peer rate limiter.
|
||||
- Coord cache invalidation made surgical at parent-position-change
|
||||
and root-change sites. Replaces the previous unconditional
|
||||
`CoordCache::clear()` calls with two targeted methods:
|
||||
`invalidate_via_node(node_addr)` (drops entries whose cached
|
||||
ancestry contains the changed node, used at parent-switch /
|
||||
become-root / loop-detection sites) and `invalidate_other_roots`
|
||||
(drops entries from a different tree, used at root-change sites).
|
||||
The previous global flush left `find_next_hop` returning `None`
|
||||
for every non-direct-peer destination after every parent switch
|
||||
until the cache passively re-warmed; surgical invalidation
|
||||
preserves entries that remain correct across the topology change.
|
||||
Peer-removal retains the original "no invalidation" behavior
|
||||
(`find_next_hop` already recomputes against the current peer set
|
||||
every call, and Discovery handles "no route" on demand).
|
||||
- `rx_loop` tick-arm stall under convergence-phase mesh pressure
|
||||
is eliminated. Previously, the tick body's per-peer `check_*`
|
||||
loops (heartbeats, bloom announces, MMP reports, tree announces)
|
||||
called `transport.send` directly for every active peer. For
|
||||
TCP/Tor peers whose pool entry was not yet established,
|
||||
`send_async` fell through to a synchronous connect-on-send
|
||||
branch that wrapped `TcpStream::connect` in
|
||||
`tokio::time::timeout(connect_timeout_ms, …)` — 5 seconds by
|
||||
default — and blocked the entire tick body for the duration per
|
||||
unreachable peer. Under post-restart convergence on a high-peer
|
||||
mesh, this cascaded into multi-second tick stalls; the same
|
||||
mechanism also starved the master-only per-tick control-snapshot
|
||||
republish and pushed `fipsctl show *` queries onto an mpsc
|
||||
fallback that was itself queued behind the wedged `rx_loop`,
|
||||
producing the five-second `fipsctl` head-of-line pattern
|
||||
operators observed on loaded nodes. The send path now gates on
|
||||
`transport.connection_state(addr)` before sending: proceed only
|
||||
when `Connected`; on `None`, kick off a non-blocking background
|
||||
`connect` (idempotent — deduplicates against the connecting
|
||||
pool, spawns the timeout-bounded `TcpStream::connect` inside its
|
||||
own tokio task) and fail this send fast with a clear
|
||||
`transport connection not ready` error. A subsequent tick
|
||||
retries once the pool has an entry. The existing reconnect
|
||||
lifecycle (heartbeat-dead detection in `check_link_heartbeats`,
|
||||
scheduled retries via `process_pending_retries`, background-
|
||||
connect polling via `poll_pending_connects`) is unchanged.
|
||||
The connect-on-send branch in `transport.send_async` itself
|
||||
remains in place for code paths that legitimately need
|
||||
synchronous connect (e.g., explicit operator-driven
|
||||
`fipsctl connect`); the tick path just no longer trips it.
|
||||
|
||||
#### Data-plane / metrics / observability
|
||||
|
||||
- The Tor transport now increments its `connect_refused` statistic (the
|
||||
"Refused" line in fipstop) when a SOCKS5 connection is actively
|
||||
refused, instead of recording every connect failure as a generic
|
||||
SOCKS5 error. The counter previously stayed at zero.
|
||||
- MMP sender metrics now ignore duplicate or regressed receiver reports
|
||||
before updating RTT, loss, goodput, or ETX. Receiver reports also
|
||||
suppress timestamp echo when dwell time overflows, so stale reports
|
||||
cannot inflate SRTT.
|
||||
- Reject-reason counters no longer double-count now that the rollout's
|
||||
interim direct increments are removed. Six discovery counters
|
||||
(`req_decode_error`, `req_duplicate`, `req_ttl_exhausted`,
|
||||
`resp_decode_error`, `resp_identity_miss`, `resp_proof_failed`), six
|
||||
bloom counters (`decode_error`, `invalid`, `non_v1`, `unknown_peer`,
|
||||
`stale`, `fill_exceeded`), and five forwarding reject packet counters
|
||||
(`decode_error_packets`, `ttl_exhausted_packets`,
|
||||
`drop_no_route_packets`, `drop_mtu_exceeded_packets`,
|
||||
`drop_send_error_packets`) were each incremented both by a direct bump
|
||||
and again through the typed reject dispatch. The redundant direct
|
||||
increments are removed — for the forwarding family the two calls are
|
||||
collapsed into a single byte-aware reject entry point — so each counter
|
||||
(and, for forwarding, its byte tally) counts once per event.
|
||||
- Transport-layer mutex poisoning no longer cascades. Ten
|
||||
`Mutex::lock().unwrap()` sites across the UDP, BLE, and Ethernet
|
||||
transports would turn a single panic (poisoning the mutex) into a
|
||||
cascade of panics on every subsequent lock. Each is replaced with
|
||||
`lock().unwrap_or_else(|e| e.into_inner())`, recovering the guarded
|
||||
data with no new dependency and no call-graph change; four
|
||||
`local_addr.unwrap()` calls on the UDP start/adopt paths get a
|
||||
provably-safe sentinel fallback. The critical sections are short,
|
||||
locally-scoped, and not reachable from peer input, so this is
|
||||
robustness hardening, not a remotely-triggerable fix.
|
||||
|
||||
#### Peer lifecycle / gateway
|
||||
|
||||
- A manual `fipsctl disconnect` now notifies the peer so teardown is
|
||||
symmetric. Previously a manual disconnect tore down only the local
|
||||
side and sent the peer nothing, so the peer kept its session and never
|
||||
re-emitted its tree and filter announcements; on reconnect it was
|
||||
never re-adopted as a child and its bloom filter was never recorded.
|
||||
The local side now sends the disconnected peer a scoped `Disconnect`
|
||||
(the same message graceful shutdown sends), so both ends tear down and
|
||||
re-handshake cleanly on the next connection.
|
||||
- `fips-gateway` no longer drops long-lived or DNS-cached client
|
||||
mappings while traffic is still flowing. The virtual-IP pool's TTL
|
||||
clock advanced only on DNS re-query, never on traffic, and the mapping
|
||||
TTL is wired equal to the DNS TTL, so an in-use mapping was forced to
|
||||
drain at TTL and reclaimed at the first zero-conntrack tick — breaking
|
||||
long-lived, bursty, or DNS-cached clients. The tick now refreshes the
|
||||
mapping's last-referenced time whenever conntrack reports active
|
||||
sessions, and recovers a draining mapping to active (with a fresh
|
||||
grace window) when traffic resumes; only genuinely idle mappings
|
||||
drain.
|
||||
|
||||
#### macOS self-traffic / resolver
|
||||
|
||||
- Self-addressed mesh traffic is now delivered locally on macOS instead
|
||||
of being dropped, for both `ping6` and full TCP/UDP. The point-to-point
|
||||
`utun` interface egresses self-addressed traffic into the daemon, which
|
||||
previously pushed it onto the mesh outbound path where it was dropped
|
||||
for lack of a route to self; such packets are now hairpinned back to
|
||||
the TUN for inbound delivery. macOS first routes self-addressed packets
|
||||
as loopback (a `LOCAL` route via `lo0`), which leaves their transport
|
||||
TX checksum offloaded and unfinished, so re-injecting them verbatim
|
||||
made the local stack drop every segment whose checksum MSS clamping did
|
||||
not happen to rewrite (the SYN and SYN-ACK got through, but the bare
|
||||
ACK, data, and FIN were dropped, so connections to a node's own
|
||||
`<npub>.fips` service half-opened and hung). The hairpin path now
|
||||
recomputes the TCP/UDP checksum before re-injection, so full
|
||||
self-connections — not just `ping6` — to a node's own `<npub>.fips`
|
||||
address work. Linux was unaffected (the kernel already loops
|
||||
self-traffic via `lo`). (#117)
|
||||
- macOS `.fips` name resolution now works on a fresh install: the
|
||||
shipped resolver shim points at `::1`, matching the daemon's default
|
||||
IPv6 DNS listener, instead of `127.0.0.1`. The mismatched shim
|
||||
(`nameserver 127.0.0.1` while the daemon listens on `::1`) broke
|
||||
`getaddrinfo` for `.fips` on every macOS install since the resolver
|
||||
was introduced.
|
||||
|
||||
#### CI & test-harness reliability
|
||||
|
||||
- Node-level multi-node tests no longer flake under parallel CPU load.
|
||||
They previously delivered handshake packets over real localhost UDP,
|
||||
whose kernel receive buffer could overflow and drop a packet when many
|
||||
tests ran concurrently, panicking the large-network convergence tests.
|
||||
A `cfg(test)`-only loopback `TransportHandle` variant now delivers
|
||||
packets directly between nodes over an unbounded in-process channel, so
|
||||
there is no socket buffer to overflow, and the previously-quarantined
|
||||
large-network tests run in the default suite again. The shipping daemon
|
||||
build is unaffected (the variant is test-gated).
|
||||
- Integration suites that wait for the mesh to converge no longer
|
||||
false-fail under concurrent CI load. The rekey, static-mesh, and
|
||||
sidecar suites replace a fixed wall-clock baseline timeout (and a blind
|
||||
sleep) with a progress-aware wait that polls the suite's own pairwise
|
||||
pings, returns as soon as every pair is reachable, extends its deadline
|
||||
while the reachable-pair count is still climbing, and gives up only
|
||||
when progress stalls.
|
||||
- Rekey integration test (`testing/static/scripts/rekey-test.sh`) no
|
||||
longer false-fails on GitHub runners under packet loss and CPU
|
||||
contention. Phase 1, Phase 3, and Phase 5 strict per-pair pings retry
|
||||
up to 4 attempts (configurable via `MAX_PING_ATTEMPTS` /
|
||||
`PING_RETRY_DELAY`) — under 1% per-direction loss, single-shot 20-pair
|
||||
ping_all misses ~33% per phase from ICMP noise alone, and the
|
||||
4-attempt retry brings that floor to ~3.2e-6 per phase; the
|
||||
`wait_for_full_baseline` convergence loop stays single-shot so retries
|
||||
there cannot conflate transient ping loss with still-converging routing
|
||||
state. Phase 1 baseline-convergence headroom is bumped from 36s to 60s
|
||||
to eliminate the intermittent Phase 1 timeout that previously required
|
||||
a `gh run rerun --failed`, and a post-second-rekey settle window is
|
||||
added in Phase 5 (mirroring Phase 3's 12-second pattern) to close the
|
||||
post-rekey per-pair-ping flake from convergence exceeding the per-ping
|
||||
5-second timeout. Test scaffold only; no daemon code changes, and the
|
||||
success path is unchanged because the wait loops return as soon as all
|
||||
20 pairs converge.
|
||||
- ACL-allowlist integration test (`testing/acl-allowlist/test.sh`):
|
||||
converted `assert_log_contains` from a one-shot `docker logs | grep`
|
||||
snapshot into a bounded poll with the same wait-with-timeout shape
|
||||
as `wait_for_peers_exact`. Absorbs the millisecond-to-second
|
||||
variance in the XX-handshake cross-connection tie-breaker: the
|
||||
inbound-handshake-context rejection can land tens of milliseconds
|
||||
after the test's previous one-shot grep gave up, producing a
|
||||
pre-existing flake on CI. Success-path cost is unchanged — the helper
|
||||
returns as soon as the pattern appears.
|
||||
|
||||
#### Packaging & deployment
|
||||
|
||||
- AUR packaging: the `fips` and `fips-git` PKGBUILDs now install the
|
||||
`fips-dns-setup` and `fips-dns-teardown` helpers into
|
||||
`/usr/lib/fips/`, matching the Debian package. The AUR `package()`
|
||||
step previously omitted them, so `fips-dns.service` failed to
|
||||
start on Arch installs ("Unable to locate executable
|
||||
`/usr/lib/fips/fips-dns-setup`", #98). The PKGBUILDs additionally
|
||||
opt out of the debug split package and declare the `*-debug`
|
||||
variant as a conflict, so a stale debug build cannot own installed
|
||||
files across a package switch.
|
||||
- macOS package build: the `.pkg` architecture is now derived from
|
||||
the Cargo `--target` triple instead of the build host's
|
||||
`uname -m`. The arm64 and x86_64 release legs build on the same
|
||||
Apple-silicon runner, so `uname -m` named both outputs
|
||||
`fips-0.3.0-macos-arm64.pkg`; the release job's `merge-multiple`
|
||||
artifact download then interleaved the two identically named
|
||||
files into a single corrupt xar archive, and no x86_64 package
|
||||
reached the release at all. (This shipped as the broken v0.3.0
|
||||
macOS `.pkg`, GitHub #102.) The release workflow now also asserts
|
||||
the arch-named file is present and carries a SHA-256 integrity
|
||||
chain from the build runner through to `gh release upload`, so a
|
||||
recurrence fails CI instead of publishing.
|
||||
|
||||
#### fipstop
|
||||
|
||||
- `fipstop` no longer renders a garbled screen on startup or leaves
|
||||
stray bytes on quit, most visible over SSH and inside tmux. Startup
|
||||
forces a full repaint (`terminal.clear()`) before the first draw so
|
||||
prior alternate-screen contents no longer show through; quit gives the
|
||||
stdin-poll thread a stop flag and joins it before restoring the
|
||||
terminal, so post-raw-mode keystrokes or terminal query responses no
|
||||
longer echo onto the restored screen.
|
||||
|
||||
## [0.3.0] - 2026-05-11
|
||||
|
||||
### Added
|
||||
|
||||
309
CONTRIBUTING.md
309
CONTRIBUTING.md
@@ -1,94 +1,267 @@
|
||||
# Contributing to FIPS
|
||||
|
||||
## Getting Started
|
||||
<!-- markdownlint-disable MD013 -->
|
||||
|
||||
Clone the repo:
|
||||
FIPS is a mesh routing protocol for Nostr identities over arbitrary
|
||||
transports. The architecture is layered, top to bottom:
|
||||
|
||||
```
|
||||
- **IPv6 TUN compatibility layer** — presents the mesh as a local
|
||||
network interface (`fips0`) so unmodified applications can use it.
|
||||
Applications send IPv6 packets to `fd::/8` addresses derived from
|
||||
Nostr pubkeys; the daemon converts between IPv6 packets and FSP
|
||||
datagrams.
|
||||
- **FSP** (FIPS Session Protocol) — end-to-end encrypted sessions
|
||||
between identities, with periodic rekey.
|
||||
- **FMP** (FIPS Mesh Protocol) — peer management, spanning tree,
|
||||
bloom filters, routing and forwarding, and link encryption.
|
||||
- **Transport** — the actual wire: UDP, TCP, Tor, Bluetooth LE,
|
||||
Ethernet, and so on. Each transport plugs into FMP via a trait.
|
||||
|
||||
Most non-trivial changes affect behavior visible across the mesh —
|
||||
how nodes find each other, how packets route, how sessions rekey, how
|
||||
peers recover from failure. A single-node `cargo test` run is
|
||||
necessary but not sufficient for that class of change; the integration
|
||||
harness in [testing/](testing/) is where regressions actually surface.
|
||||
This document covers the workflow assuming that context. Protocol
|
||||
depth lives in [docs/design/](docs/design/).
|
||||
|
||||
## Quick start
|
||||
|
||||
```bash
|
||||
git clone https://github.com/jmcorgan/fips.git
|
||||
cd fips
|
||||
```
|
||||
|
||||
Before changing code, read the protocol docs in this order:
|
||||
|
||||
- [docs/design/README.md](docs/design/README.md)
|
||||
- [docs/design/fips-intro.md](docs/design/fips-intro.md)
|
||||
- the specific design doc for the behavior you are touching
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Rust 1.94.1 and Linux with TUN support
|
||||
- Use the pinned toolchain from [rust-toolchain.toml](rust-toolchain.toml) for deterministic builds
|
||||
- For the default BLE-enabled build on Debian/Ubuntu:
|
||||
`sudo apt install bluez libdbus-1-dev pkg-config`
|
||||
- Docker is required for the integration harnesses under [testing/](testing/)
|
||||
|
||||
If you do not want BLE locally, build and test without default features:
|
||||
|
||||
```bash
|
||||
cargo build --no-default-features --features tui
|
||||
cargo test --no-default-features --features tui
|
||||
```
|
||||
|
||||
## Local Verification
|
||||
|
||||
Choose the narrowest check that matches your change:
|
||||
|
||||
- Docs-only changes:
|
||||
|
||||
```bash
|
||||
git diff --check
|
||||
```
|
||||
|
||||
- Normal code changes:
|
||||
|
||||
```bash
|
||||
cargo build
|
||||
cargo test
|
||||
cargo clippy --all -- -D warnings
|
||||
```
|
||||
|
||||
- Local CI-style unit test run:
|
||||
The pinned toolchain in [rust-toolchain.toml](rust-toolchain.toml) is
|
||||
used for deterministic builds. On Linux, a source build requires
|
||||
`libclang` (`sudo apt install libclang-dev` on Debian/Ubuntu): the LAN
|
||||
gateway's nftables bindings are generated by `bindgen` at build time
|
||||
and fail without it. BLE-capable builds additionally need `bluez`,
|
||||
`libdbus-1-dev`, and `pkg-config` installed; the default build picks
|
||||
up BLE if those are present and skips it cleanly if not.
|
||||
|
||||
On Nix, `nix develop` provides the pinned toolchain and all of these
|
||||
build prerequisites without any manual install; see the Nix / NixOS
|
||||
section of [packaging/README.md](packaging/README.md).
|
||||
|
||||
For multi-node integration runs, Docker is required. The harness
|
||||
under [testing/](testing/) starts containerized topologies and
|
||||
exercises real mesh behavior; see [testing/README.md](testing/README.md)
|
||||
for the suite catalog.
|
||||
|
||||
For a guided first-run that joins the public test mesh, see
|
||||
[docs/tutorials/join-the-test-mesh.md](docs/tutorials/join-the-test-mesh.md).
|
||||
Pointing your local daemon at a `test-*` node is the cheapest way to
|
||||
dogfood a change end-to-end before opening a PR.
|
||||
|
||||
## Choosing a branch to target
|
||||
|
||||
FIPS uses three long-lived branches, each a superset of the previous:
|
||||
|
||||
- **`maint`** — bug fixes for the latest released version.
|
||||
- **`master`** — compatible work for the next feature release.
|
||||
- **`next`** — wire-format-breaking and API-breaking work, staged for
|
||||
the next forklift release.
|
||||
|
||||
Pick the branch that matches the scope of your change:
|
||||
|
||||
| Your change | Target |
|
||||
| --- | --- |
|
||||
| Bug fix in a feature that shipped in the latest release | `maint` |
|
||||
| Bug fix in code added on `master` since the last release | `master` |
|
||||
| Bug fix in `next`-only code (wire-format-breaking work) | `next` |
|
||||
| New feature, no wire-format or API break | `master` |
|
||||
| Wire-format-breaking or API-breaking change | `next` |
|
||||
| Documentation, CI, contributor-facing changes | `maint` if they apply to released material, else `master` |
|
||||
|
||||
When in doubt, ask in the issue. The maintainer can retarget if
|
||||
needed. The full release workflow, version conventions, and
|
||||
merge-direction rationale are in [docs/branching.md](docs/branching.md).
|
||||
|
||||
## Reporting bugs
|
||||
|
||||
Search [open issues](https://github.com/jmcorgan/fips/issues) before
|
||||
filing a new one — duplicates are common in a young project.
|
||||
|
||||
When you open a bug report, please include:
|
||||
|
||||
- **FIPS version** (`fipsctl --version`)
|
||||
- **Rust toolchain version** (`rustc --version`)
|
||||
- **OS / distro** (Linux distro + kernel, or macOS / Windows version)
|
||||
- **What you expected to happen** — your mental model of the
|
||||
behavior, ideally referencing the relevant docs or config field.
|
||||
- **What actually happened** — the observed behavior, including the
|
||||
surprise.
|
||||
- **Reproduction steps** — minimal and deterministic if you can.
|
||||
Multi-node bugs should include the topology and per-node config
|
||||
excerpts.
|
||||
- **Evidence** — relevant log excerpts (`journalctl -u fips` or stdout
|
||||
with `RUST_LOG=info` or `debug`), `fipsctl show` output if relevant
|
||||
(`peers`, `links`, `status`), and any visible mesh state.
|
||||
|
||||
One issue per bug. Don't bundle unrelated symptoms even if you
|
||||
suspect they share a root cause — the maintainer will link them if
|
||||
they turn out to be related.
|
||||
|
||||
## Submitting pull requests
|
||||
|
||||
### Scope discipline
|
||||
|
||||
Every PR should make one logical change. The reviewer should be able
|
||||
to read the whole diff and trace every line back to the PR's stated
|
||||
purpose.
|
||||
|
||||
- No drive-by reformatting of unrelated files.
|
||||
- No unrelated refactors folded into a bug fix or a feature PR.
|
||||
- No "while I was in there" cleanups in files outside the change's
|
||||
natural footprint. Send them as separate PRs; they'll usually land
|
||||
faster on their own.
|
||||
- Pre-existing lint warnings in files you didn't touch are not yours
|
||||
to fix in this PR.
|
||||
|
||||
### Required before opening any PR
|
||||
|
||||
Run these locally and confirm they all pass:
|
||||
|
||||
```bash
|
||||
./testing/ci-local.sh --test-only
|
||||
cargo fmt --check
|
||||
cargo build
|
||||
cargo clippy --all-targets -- -D warnings
|
||||
cargo test
|
||||
```
|
||||
|
||||
- Narrow integration run for transport, routing, Docker, or packaging-sensitive changes:
|
||||
`fmt` and `clippy -D warnings` are CI gates — PRs with formatting
|
||||
drift or new clippy warnings will fail CI and be sent back.
|
||||
|
||||
Then run the integration suite that exercises your change:
|
||||
|
||||
```bash
|
||||
./testing/ci-local.sh --only static-mesh
|
||||
./testing/ci-local.sh --only <suite>
|
||||
```
|
||||
|
||||
See [testing/README.md](testing/README.md) for the available integration and chaos harnesses.
|
||||
See [testing/README.md](testing/README.md) for the available suites
|
||||
and what each covers. Routing, discovery, rekey, NAT, gateway, and
|
||||
transport changes all have specific suites; pick the narrowest one
|
||||
that touches your code path.
|
||||
|
||||
## Filing Issues
|
||||
**Recommended before opening**: the full local CI run.
|
||||
|
||||
- Search existing issues before opening a new one.
|
||||
- Include FIPS version, Rust version, and OS.
|
||||
- For bugs: steps to reproduce, expected vs actual behavior.
|
||||
```bash
|
||||
./testing/ci-local.sh
|
||||
```
|
||||
|
||||
## Pull Requests
|
||||
This is the same matrix that runs on GitHub Actions. Catching a
|
||||
regression locally is much cheaper than catching it in CI.
|
||||
|
||||
- All PRs must pass `cargo build`, `cargo test`, and `cargo clippy --all -- -D warnings`.
|
||||
- Keep commits focused — one logical change per commit.
|
||||
- Add tests for new functionality.
|
||||
- Reference relevant design docs if the change touches protocol behavior.
|
||||
- Pull requests are merged via squash-merge.
|
||||
- Update docs in the same change when you modify:
|
||||
- protocol or routing behavior
|
||||
- wire formats
|
||||
- configuration shape or defaults
|
||||
- operational workflows or testing instructions
|
||||
### Self-review against the project review checklist
|
||||
|
||||
In practice this usually means updating one or more of:
|
||||
The 13-criteria checklist the maintainer runs on every incoming PR is
|
||||
published at [PR-REVIEW.md](PR-REVIEW.md). Run your own change through
|
||||
it before opening — or hand the document to your coding agent with
|
||||
"review my branch against this checklist" and let it do the pass. The
|
||||
checklist covers PR hygiene (body, commit shape, base freshness), diff
|
||||
content (does the change do what the description says, does it fit the
|
||||
codebase as a natural extension), and cross-cutting concerns (tests,
|
||||
docs, dependencies, security, contributor-conventional Rust patterns).
|
||||
|
||||
- [docs/design/fips-mesh-operation.md](docs/design/fips-mesh-operation.md)
|
||||
- [docs/design/fips-wire-formats.md](docs/design/fips-wire-formats.md)
|
||||
- [docs/design/fips-configuration.md](docs/design/fips-configuration.md)
|
||||
- [README.md](README.md)
|
||||
- [testing/README.md](testing/README.md)
|
||||
This is the first thing the maintainer does on any submission, so
|
||||
running it yourself saves a review round trip.
|
||||
|
||||
## Questions
|
||||
### Additional requirements for feature PRs
|
||||
|
||||
Open a GitHub issue for design or implementation questions.
|
||||
- **New CI coverage.** Features added without a test that exercises
|
||||
them won't be reviewed. Either extend an existing integration
|
||||
suite or add a new one under `testing/`. Coverage of just the
|
||||
happy path is fine for an initial PR; edge cases can land as
|
||||
follow-ups.
|
||||
- **Documentation updated alongside the code.** Protocol changes
|
||||
update the relevant [docs/design/](docs/design/) page. Config
|
||||
changes update the operator-facing docs in [docs/](docs/) and the
|
||||
reference config. Behavior visible to operators updates
|
||||
[README.md](README.md) and any tutorial it touches.
|
||||
|
||||
### Additional requirements for bug-fix PRs
|
||||
|
||||
- **A regression test** where practical. If a regression test isn't
|
||||
tractable (some bugs only surface under timing or scale that's hard
|
||||
to encode), say so in the PR description with a one-paragraph
|
||||
explanation.
|
||||
- **Commit message references the bug**: the symptom, the root cause
|
||||
in one sentence, and the fix shape.
|
||||
|
||||
### Merge mechanics
|
||||
|
||||
PRs are merged via **squash-merge**. One logical change per PR
|
||||
becomes one commit on the destination branch, which keeps `git
|
||||
bisect` useful across the integration suite. Your in-PR commit
|
||||
history doesn't matter for the final landed history — the maintainer
|
||||
rewrites the commit message at merge time.
|
||||
|
||||
## AI coding assistant policy
|
||||
|
||||
Use of AI coding assistants (Claude Code, Copilot, Cursor, Aider, and
|
||||
similar) in preparing a contribution is welcome. These tools are
|
||||
force multipliers and we have no objection in principle to their use
|
||||
in writing code, tests, documentation, or PR descriptions.
|
||||
|
||||
What we require is that the contributor does a thorough manual review
|
||||
and editorial pass over the output before submission. Concretely:
|
||||
|
||||
- Verify that the code does what it claims, not just that it
|
||||
compiles.
|
||||
- Verify that any tests the agent wrote actually test something
|
||||
useful, not just that they pass.
|
||||
- Verify that any documentation matches the behavior.
|
||||
- Spot-check the diff for nothing-surprising: no unrelated files
|
||||
modified, no fabricated APIs, no references to symbols that don't
|
||||
exist, no version bumps you didn't intend, no churn outside the
|
||||
change's natural footprint.
|
||||
- Be ready to discuss the design choices in the PR as if you wrote
|
||||
every line, because for the purposes of accountability you did.
|
||||
|
||||
The coding agent is a tool. The contributor is the author of record
|
||||
and is accountable for whatever they submit. PRs are reviewed on
|
||||
what they contain, not on who or what wrote them.
|
||||
|
||||
**Review effort scales with submission effort.** A submission that
|
||||
shows signs of being unreviewed agent output — irrelevant edits
|
||||
scattered across the tree, hallucinated function names, mismatched
|
||||
test/behavior pairs, fabricated API references, ChatGPT-style summary
|
||||
prose in comments — will receive an AI-coding-agent reply in turn,
|
||||
without human review. If you want a human reviewer's attention, do
|
||||
the editorial pass yourself first.
|
||||
|
||||
Repeated submissions of unreviewed AI output will result in the
|
||||
contributor being asked to step back and may result in account
|
||||
restrictions.
|
||||
|
||||
## Where the conversation happens
|
||||
|
||||
- **GitHub issues** — bugs, feature requests, design discussions
|
||||
that don't fit on a specific PR.
|
||||
- **GitHub PRs** — design discussion specific to a change in
|
||||
flight. Comment threads on the diff are the right place to push
|
||||
back on a decision.
|
||||
- **[fips.network](https://fips.network)** — community page, podcast,
|
||||
and the project's Nostr account. Broader project conversation and
|
||||
announcements happen here.
|
||||
|
||||
For implementation questions specific to your PR, ask in the PR
|
||||
itself. For design or roadmap questions that don't have a clear PR
|
||||
home yet, file a GitHub issue with the `design` label.
|
||||
|
||||
## Further reading
|
||||
|
||||
- [PR-REVIEW.md](PR-REVIEW.md) — the 13-criteria PR review checklist
|
||||
the maintainer runs on every incoming PR; run it yourself before
|
||||
opening to save a round trip.
|
||||
- [docs/design/README.md](docs/design/README.md) — protocol design tree.
|
||||
- [docs/branching.md](docs/branching.md) — full release workflow and
|
||||
merge-direction rationale.
|
||||
- [docs/getting-started.md](docs/getting-started.md) — operator
|
||||
walkthrough for a new node.
|
||||
- [docs/tutorials/join-the-test-mesh.md](docs/tutorials/join-the-test-mesh.md)
|
||||
— how to dogfood your change against the public test mesh.
|
||||
- [testing/README.md](testing/README.md) — integration suite catalog.
|
||||
|
||||
574
Cargo.lock
generated
574
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
18
Cargo.toml
18
Cargo.toml
@@ -1,12 +1,15 @@
|
||||
[package]
|
||||
name = "fips"
|
||||
version = "0.3.0"
|
||||
version = "0.5.0-dev"
|
||||
edition = "2024"
|
||||
description = "A distributed, decentralized network routing protocol for mesh nodes connecting over arbitrary transports"
|
||||
license = "MIT"
|
||||
authors = ["Johnathan Corgan <jcorgan@corganlabs.com>"]
|
||||
repository = "https://github.com/jmcorgan/fips"
|
||||
homepage = "https://fips.network"
|
||||
readme = "README.md"
|
||||
keywords = ["mesh", "p2p", "decentralized", "overlay-network", "nostr"]
|
||||
categories = ["network-programming", "command-line-utilities", "cryptography"]
|
||||
|
||||
[dependencies]
|
||||
ratatui = "0.30"
|
||||
@@ -14,7 +17,9 @@ secp256k1 = { version = "0.30", features = ["rand", "global-context"] }
|
||||
sha2 = "0.10"
|
||||
hkdf = "0.12"
|
||||
ring = "0.17"
|
||||
libm = "0.2"
|
||||
rand = "0.10.1"
|
||||
crossbeam-channel = "0.5"
|
||||
thiserror = "2.0"
|
||||
bech32 = "0.11"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
@@ -28,12 +33,14 @@ tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
tokio = { version = "1", features = ["rt", "macros", "signal", "sync", "net", "time", "process", "io-util"] }
|
||||
futures = "0.3"
|
||||
simple-dns = "0.11.2"
|
||||
mdns-sd = "0.19"
|
||||
socket2 = { version = "0.6.2", features = ["all"] }
|
||||
tokio-socks = "0.5"
|
||||
portable-atomic = { version = "1", features = ["std"] }
|
||||
|
||||
nostr = { version = "0.44", features = ["std", "nip59"] }
|
||||
nostr-sdk = "0.44"
|
||||
arc-swap = "1"
|
||||
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
tun = { version = "0.8.7", features = ["async"] }
|
||||
@@ -71,7 +78,7 @@ assets = [
|
||||
["target/release/fips", "/usr/bin/", "755"],
|
||||
["target/release/fipsctl", "/usr/bin/", "755"],
|
||||
["target/release/fipstop", "/usr/bin/", "755"],
|
||||
["packaging/common/fips.yaml", "/etc/fips/fips.yaml", "600"],
|
||||
["packaging/common/fips.yaml", "/usr/share/fips/fips.yaml.example", "644"],
|
||||
["packaging/common/hosts", "/etc/fips/hosts", "644"],
|
||||
["packaging/common/fips.nft", "/etc/fips/fips.nft", "644"],
|
||||
["packaging/debian/fips.service", "/lib/systemd/system/fips.service", "644"],
|
||||
@@ -84,7 +91,7 @@ assets = [
|
||||
["packaging/debian/fips-gateway.service", "/lib/systemd/system/fips-gateway.service", "644"],
|
||||
["docs/design/fips-security.md", "/usr/share/doc/fips/fips-security.md", "644"],
|
||||
]
|
||||
conf-files = ["/etc/fips/fips.yaml", "/etc/fips/hosts", "/etc/fips/fips.nft"]
|
||||
conf-files = ["/etc/fips/hosts", "/etc/fips/fips.nft"]
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3.15"
|
||||
@@ -102,3 +109,8 @@ path = "src/bin/fips-gateway.rs"
|
||||
[[bin]]
|
||||
name = "fipstop"
|
||||
path = "src/bin/fipstop/main.rs"
|
||||
|
||||
[[bench]]
|
||||
name = "routing_next_hop"
|
||||
path = "benches/routing_next_hop.rs"
|
||||
harness = false
|
||||
|
||||
199
PR-REVIEW.md
Normal file
199
PR-REVIEW.md
Normal file
@@ -0,0 +1,199 @@
|
||||
# PR Review Checklist
|
||||
|
||||
<!-- markdownlint-disable MD013 -->
|
||||
|
||||
This is the 13-criteria checklist the maintainer runs against every
|
||||
incoming PR. The first pass on any submission is exactly this list,
|
||||
so executing it yourself before opening — or after pushing a fresh
|
||||
revision — saves a review round trip and surfaces problems faster.
|
||||
|
||||
The document is also written so you can hand it to a coding agent
|
||||
(Claude Code, Copilot, Cursor, Aider, etc.) with "review my branch
|
||||
against this checklist" and get a structured pass. The agent gets
|
||||
better results than a free-form "review my PR" because every concern
|
||||
the maintainer cares about is enumerated below.
|
||||
|
||||
## Step 1 — Should this even be reviewed?
|
||||
|
||||
Skip the review (and say so) if the PR is:
|
||||
|
||||
- closed, merged, or marked draft
|
||||
- automated (bot author, dependabot, etc.) and trivially OK
|
||||
- so small and obviously correct (typo fix, single-line doc tweak)
|
||||
that a thirteen-point pass is overkill — a one-paragraph informal
|
||||
review is better in that case
|
||||
|
||||
## Step 2 — Gather context
|
||||
|
||||
Read these *before* analyzing the diff so the review is grounded:
|
||||
|
||||
1. PR metadata. Title, body, author, head ref, base ref, head SHA,
|
||||
base SHA, mergeable status, CI rollup, commit list.
|
||||
|
||||
```bash
|
||||
gh pr view <num> --json title,body,author,headRefName,baseRefName,headRefOid,baseRefOid,mergeable,statusCheckRollup,commits
|
||||
```
|
||||
|
||||
2. The diff.
|
||||
|
||||
```bash
|
||||
gh pr diff <num>
|
||||
```
|
||||
|
||||
3. Base-branch freshness. How many commits have landed on the PR's
|
||||
base since the PR forked from it.
|
||||
4. Project guidance. Read [CLAUDE.md](CLAUDE.md) at the repo root and
|
||||
any nested `CLAUDE.md` in directories the diff touches. These
|
||||
describe project-specific conventions and constraints not visible
|
||||
from the diff alone.
|
||||
5. Related work on GitHub. Skim the [open issues](https://github.com/jmcorgan/fips/issues)
|
||||
and other [open PRs](https://github.com/jmcorgan/fips/pulls) for
|
||||
work that overlaps, duplicates, partially addresses, or is unblocked
|
||||
by this PR.
|
||||
6. For "this looks wrong" observations later: `git blame` the modified
|
||||
lines and read recent commit history on the same files for context
|
||||
before flagging something as a problem. What looks like a bug at
|
||||
first glance is often a deliberate workaround documented in a prior
|
||||
commit message.
|
||||
|
||||
## Step 3 — The 13 criteria
|
||||
|
||||
The review must address all 13 criteria below at some point. They
|
||||
group naturally into PR hygiene, diff content, and cross-cutting
|
||||
concerns — but the report itself is *not* organized this way; see
|
||||
Step 4.
|
||||
|
||||
### Group A — PR hygiene (structural review)
|
||||
|
||||
1. **PR body and issue cross-reference**. Does the body accurately
|
||||
describe the change (feature added or bug fixed) and match what
|
||||
the diff actually does? Is there an associated issue that
|
||||
should be referenced via `Closes #N` / `Fixes #N`?
|
||||
2. **Commit hygiene and base freshness**. Is the PR a clean set of
|
||||
commits (or a single commit) representing appropriately chunked
|
||||
development items, or are there intermediate "WIP" / "fix typo" /
|
||||
"address review" commits that should have been squashed? Is the
|
||||
branch based off a recent `maint` / `master` / `next`, or has the
|
||||
base diverged far enough that rebase work is needed?
|
||||
3. **Commit message quality**. Are the commit messages well-structured
|
||||
(subject + body where the change warrants), accurately referencing
|
||||
everything actually in each commit, and free of extraneous footers
|
||||
— particularly coding-assistant attribution (`Generated with
|
||||
Claude Code`, `Co-Authored-By: Claude`, similar from other AI
|
||||
tools)?
|
||||
|
||||
### Group B — Diff content
|
||||
|
||||
4. **Does it do what it says it does**. Walk each claimed behavior
|
||||
from the PR body against the actual diff lines.
|
||||
5. **Coherent whole**. Are all parts of the diff in service of the
|
||||
stated goal, or are there drive-by formatting changes, unrelated
|
||||
touch-ups, or scope creep?
|
||||
6. **Fits the codebase as a natural extension**. Does the new code
|
||||
use existing idioms, helpers, error types, and patterns, or does
|
||||
it introduce new ones where existing ones would have served?
|
||||
|
||||
### Group C — Cross-cutting concerns
|
||||
|
||||
7. **New dependency surface**. Any new crates, system deps,
|
||||
build-time requirements, or external-service dependencies?
|
||||
8. **New test coverage**. Are the new code paths covered, are the
|
||||
tests scoped correctly (unit / integration / end-to-end), and
|
||||
are there obvious test gaps? Don't reflag anything CI already
|
||||
enforces (formatting, lint, type errors, unit-test pass/fail).
|
||||
9. **Documentation impact**. Does this need a CHANGELOG entry,
|
||||
rustdoc updates, design-doc changes
|
||||
([docs/design/](docs/design/)), README adjustments, or operator
|
||||
doc updates in [docs/](docs/)?
|
||||
10. **Security vulnerabilities**. Any new attack surface,
|
||||
untrusted-input parsing, `unsafe` blocks, panic-on-untrusted
|
||||
paths, secret-handling concerns, or side-channel exposure?
|
||||
11. **Rust and OSS best practices**. Idiomatic error handling, no
|
||||
silently-swallowed errors, no `unwrap` / `expect` on untrusted
|
||||
input, no `#[allow]` without justification, appropriate
|
||||
visibility (`pub` vs `pub(crate)` vs private), naming, and
|
||||
module shape.
|
||||
12. **Overlap with existing work**. Cross-check open issues and
|
||||
other open PRs (and recently closed/merged ones) for related
|
||||
work that overlaps, duplicates, partially addresses, or is
|
||||
unblocked by this PR.
|
||||
13. **Other concerns**. Anything not captured above — wire-format
|
||||
implications, branch-flow questions (`maint` vs `master` vs
|
||||
`next`; see [docs/branching.md](docs/branching.md)),
|
||||
deployment / packaging impact, contributor coordination needs,
|
||||
fragility notes for future maintainers.
|
||||
|
||||
## Step 4 — Compose the review
|
||||
|
||||
The review report is **not** a Q&A walk through the 13 criteria.
|
||||
Write it as natural prose in a coherent, integrated narrative that
|
||||
reads start-to-finish. All 13 criteria must be addressed at some
|
||||
point in the body, but ordering, grouping, and emphasis follow the
|
||||
actual shape of THIS PR — lead with what matters most for this PR,
|
||||
not a fixed template.
|
||||
|
||||
A typical shape that often falls out naturally:
|
||||
|
||||
- **Opening paragraph**: what the PR does and the headline
|
||||
observations (subsumes criteria 1 and 4).
|
||||
- **Substantive body**: diff analysis, design fit, cross-cutting
|
||||
concerns, surprises, fragilities, missing coverage,
|
||||
cross-PR/issue overlap, anything unusual. Don't reference
|
||||
criterion numbers in the prose.
|
||||
- **Closing**: short summary and a proposed disposition — *land*,
|
||||
*land-with-followups* (list them), *request-changes* (with the
|
||||
blocking items called out), or *hold-for-thematic-batch*.
|
||||
|
||||
Short subheadings are fine where they aid scanning. Bullets are fine
|
||||
for enumerable items (test names, file paths, follow-up actions).
|
||||
Avoid bullets that just enumerate criterion responses.
|
||||
|
||||
## Step 5 — Filter aggressively
|
||||
|
||||
Quality over quantity. Do not flag:
|
||||
|
||||
- Pre-existing issues on lines the PR did not modify
|
||||
- Issues that linter, type-checker, formatter, or CI would catch
|
||||
- Pedantic style nitpicks a senior engineer would not call out
|
||||
- Likely intentional changes related to the broader goal
|
||||
- Things explicitly silenced by an `#[allow]` with justification
|
||||
- Stylistic preferences not anchored in `CLAUDE.md` or the
|
||||
surrounding codebase's idioms
|
||||
|
||||
When in doubt about whether something is worth surfacing: would a
|
||||
senior maintainer skim past it, or would they want it raised?
|
||||
Skim-past items don't belong in the report.
|
||||
|
||||
For every issue you *do* surface, include a concrete fix suggestion
|
||||
inline ("rename X to Y", "extract this into the existing helper at
|
||||
`foo.rs:42`", "add a test exercising the `Err` branch") so the
|
||||
author can act without a round-trip.
|
||||
|
||||
## Step 6 — Citation discipline
|
||||
|
||||
When the review references a specific code location, use full-SHA
|
||||
GitHub permalinks so the link survives future history rewrites:
|
||||
|
||||
```text
|
||||
https://github.com/jmcorgan/fips/blob/<full-40-char-sha>/<path>#L<start>-L<end>
|
||||
```
|
||||
|
||||
For multi-line ranges include at least one line of context before
|
||||
and after the line(s) being discussed. After `gh pr checkout <num>`,
|
||||
use `git rev-parse HEAD` to grab the full SHA — never partial SHAs
|
||||
in permalinks.
|
||||
|
||||
## Notes
|
||||
|
||||
- The review is one human's read of the PR. Confidence calibration
|
||||
matters: distinguish "this is a blocker" from "this is worth asking
|
||||
about" from "this is a fragility note for future maintainers." The
|
||||
closing disposition makes the action explicit.
|
||||
- If a re-review is triggered after the author pushes new commits,
|
||||
lead with the delta from the prior review rather than re-walking
|
||||
the whole PR.
|
||||
- This checklist exists to surface problems, not to assign blame.
|
||||
If you're running it as the author or via an agent, treat each
|
||||
finding as "would the maintainer ask about this?" — and either fix
|
||||
it before opening, or pre-empt it in the PR body so the maintainer
|
||||
doesn't have to ask.
|
||||
89
README.md
89
README.md
@@ -3,7 +3,7 @@
|
||||

|
||||
[](LICENSE)
|
||||
[](https://www.rust-lang.org/)
|
||||
[](#status--roadmap)
|
||||
[](#status--roadmap)
|
||||
|
||||
A self-organizing encrypted mesh network built on Nostr identities,
|
||||
capable of operating over arbitrary transports without central
|
||||
@@ -40,8 +40,8 @@ same way it would on a local network.
|
||||
- **Self-organizing mesh routing.** Spanning-tree coordinates with
|
||||
bloom-filter-guided discovery; no global routing tables, no
|
||||
flooding.
|
||||
- **Multi-transport.** UDP, TCP, Ethernet, Tor, and Bluetooth (BLE
|
||||
L2CAP) ship today; transports compose on a single mesh and a
|
||||
- **Multi-transport.** UDP, TCP, Ethernet, Tor, Nym, and Bluetooth
|
||||
(BLE L2CAP) ship today; transports compose on a single mesh and a
|
||||
node may run several at once.
|
||||
- **Two-layer encryption.** Noise IK between peers (hop-by-hop) and
|
||||
Noise XK between mesh endpoints (independent end-to-end), with
|
||||
@@ -55,7 +55,8 @@ same way it would on a local network.
|
||||
- **Nostr-mediated discovery and NAT traversal.** Peers publish
|
||||
endpoint adverts on public Nostr relays, exchange candidates via
|
||||
NIP-59 gift-wrapped offers and answers, and establish direct
|
||||
paths through NATs using STUN-assisted hole punching.
|
||||
paths through NATs using STUN-assisted hole punching. On the local
|
||||
network, mDNS LAN discovery finds peers directly without relays.
|
||||
- **LAN gateway.** Optional `fips-gateway` service folds an entire
|
||||
unmodified LAN into the mesh: outbound (LAN clients reach mesh
|
||||
destinations through a DNS-allocated virtual IPv6 pool and
|
||||
@@ -97,9 +98,9 @@ This installs the daemon, CLI tools (`fipsctl`, `fipstop`), the
|
||||
optional `fips-gateway` service, systemd units, and a default
|
||||
`/etc/fips/fips.yaml` you can edit before starting.
|
||||
|
||||
For macOS, Windows, OpenWrt, the systemd tarball, or a from-source
|
||||
build, see [docs/getting-started.md](docs/getting-started.md) for
|
||||
the full multi-platform installation guide.
|
||||
For macOS, Windows, OpenWrt, the systemd tarball, a Nix flake, or a
|
||||
from-source build, see [docs/getting-started.md](docs/getting-started.md)
|
||||
for the full multi-platform installation guide.
|
||||
|
||||
To join a live mesh and reach your first peer, follow the new-user
|
||||
tutorial progression starting at
|
||||
@@ -111,23 +112,45 @@ tutorial progression starting at
|
||||
cargo build --release
|
||||
```
|
||||
|
||||
Requires Rust 1.94.1+ (edition 2024). Linux, macOS, and Windows are
|
||||
supported; transport availability varies by platform.
|
||||
Requires Rust 1.94.1+ (edition 2024). Linux, macOS, and Windows run as
|
||||
standalone daemons; Android is supported as an embedded library (the host
|
||||
app owns the TUN, e.g. a `VpnService`). Transport availability varies by
|
||||
platform.
|
||||
|
||||
| Transport | Linux | macOS | Windows | OpenWrt |
|
||||
|-----------|:-----:|:-----:|:-------:|:-------:|
|
||||
| UDP | ✅ | ✅ | ✅ | ✅ |
|
||||
| TCP | ✅ | ✅ | ✅ | ✅ |
|
||||
| Ethernet | ✅ | ✅ | ❌ | ✅ |
|
||||
| Tor | ✅ | ✅ | ✅ | ✅ |
|
||||
| BLE | ✅ | ❌ | ❌ | ❌ |
|
||||
| Transport | Linux | macOS | Windows | Android | OpenWrt |
|
||||
|-----------|:-----:|:-----:|:-------:|:-------:|:-------:|
|
||||
| UDP | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
| TCP | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
| Ethernet | ✅ | ✅ | ❌ | ❌ | ✅ |
|
||||
| Tor | ✅ | ✅ | ✅ | ❌ | ✅ |
|
||||
| Nym | ✅ | ✅ | ✅ | ❌ | ❌ |
|
||||
| BLE | ✅ | ❌ | ❌ | ❌ | ❌ |
|
||||
|
||||
On Linux, BLE requires BlueZ and libdbus
|
||||
(`sudo apt install bluez libdbus-1-dev` on Debian / Ubuntu) and is
|
||||
On Linux, a source build requires `libclang` — the LAN gateway's
|
||||
nftables bindings are generated by `bindgen` at build time, which
|
||||
needs `libclang.so` on the build host. Install it before building
|
||||
(`sudo apt install libclang-dev` on Debian / Ubuntu); without it the
|
||||
build fails inside the `rustables` crate with an "Unable to find
|
||||
libclang" error. This is a build-time prerequisite only — it is not a
|
||||
runtime dependency, and the pre-built `.deb` artifacts do not need it.
|
||||
|
||||
BLE is optional and, on Linux, requires BlueZ and libdbus
|
||||
(`sudo apt install bluez libdbus-1-dev` on Debian / Ubuntu). It is
|
||||
gated on a build-script probe — install the dependencies first and
|
||||
the `cargo build` line above picks it up. The OpenWrt ipk omits
|
||||
BLE because libdbus is not available on the target.
|
||||
|
||||
Nym (mixnet) transport builds on all desktop platforms. The OpenWrt
|
||||
❌ is provisional, pending verification of `nym-socks5-client`
|
||||
availability on the target; it will flip to ✅ only if confirmed
|
||||
buildable there.
|
||||
|
||||
Alternatively, the repo ships a [Nix flake](flake.nix): `nix develop`
|
||||
drops you into a shell with the pinned toolchain and every build
|
||||
prerequisite (libclang, dbus, pkg-config) already provided, and
|
||||
`nix build .#fips` builds all four binaries with no host setup. See the
|
||||
Nix / NixOS section of [packaging/README.md](packaging/README.md).
|
||||
|
||||
## Documentation
|
||||
|
||||
`docs/` is organised by reader purpose:
|
||||
@@ -161,6 +184,12 @@ and [testing/README.md](testing/README.md).
|
||||
reachable exclusively over the FIPS mesh. The relay container
|
||||
shares the FIPS sidecar's network namespace and is isolated from
|
||||
the host network.
|
||||
- **[examples/sidecar-nostr-mixnet-relay/](examples/sidecar-nostr-mixnet-relay/)** —
|
||||
Single-container demo of FIPS peering through a **mixnet**
|
||||
(implemented with [Nym](https://nym.com/)): the FIPS daemon, the mixnet
|
||||
proxy, and a strfry Nostr relay all in one isolated container, with
|
||||
the direct route to the peer firewalled off so traffic provably
|
||||
crosses the mixnet.
|
||||
- **[examples/k8s-sidecar/](examples/k8s-sidecar/)** — Run FIPS as
|
||||
a Kubernetes Pod sidecar. The sidecar creates `fips0` in the
|
||||
Pod's shared network namespace so every other container in the
|
||||
@@ -183,14 +212,16 @@ testing/ Docker-based integration test harnesses + chaos simulation
|
||||
|
||||
## Status & roadmap
|
||||
|
||||
FIPS is at **v0.3.0**. The core protocol works end-to-end over
|
||||
UDP, TCP, Ethernet, Tor, and Bluetooth on a small live mesh of
|
||||
deployed nodes. v0.3.0 is the testing-and-polishing track for
|
||||
everything accumulated since v0.2.0 on the v0.2.x wire format —
|
||||
Nostr-mediated peer discovery, UDP NAT traversal, peer ACL, the
|
||||
DNS-responder fix, packaging hardening, and discovery rate-limit
|
||||
retuning. New wire-format work is staged on the `next` branch for
|
||||
the post-v0.3.0 release line.
|
||||
FIPS is at **v0.5.0-dev** on the `master` branch.
|
||||
[v0.4.1](https://github.com/jmcorgan/fips/releases/tag/v0.4.1) has
|
||||
shipped; this development line continues the testing-and-polishing
|
||||
track toward v0.5.0. The core protocol works end-to-end over
|
||||
UDP, TCP, Ethernet, Tor, Nym, and Bluetooth on a global, public test
|
||||
mesh of thousands of nodes. v0.4.0 added the Nym mixnet transport and
|
||||
mDNS LAN discovery alongside the existing Nostr-mediated peer discovery,
|
||||
UDP NAT traversal, peer ACL, and packaging hardening. New wire-format work
|
||||
continues to be staged on the `next` branch for the subsequent
|
||||
release line.
|
||||
|
||||
### What works today
|
||||
|
||||
@@ -208,10 +239,10 @@ the post-v0.3.0 release line.
|
||||
estimation.
|
||||
- ECN congestion signaling (hop-by-hop CE relay, IPv6 CE marking,
|
||||
kernel-drop detection).
|
||||
- UDP, TCP, Ethernet, Tor, and BLE transports (BLE via L2CAP CoC
|
||||
with per-link MTU negotiation).
|
||||
- UDP, TCP, Ethernet, Tor, Nym (mixnet), and BLE transports (BLE
|
||||
via L2CAP CoC with per-link MTU negotiation).
|
||||
- Nostr-mediated overlay endpoint discovery and UDP hole punching
|
||||
for NAT traversal.
|
||||
for NAT traversal, plus mDNS LAN discovery for local peers.
|
||||
- LAN gateway (`fips-gateway`) with both outbound (LAN-to-mesh)
|
||||
and inbound (mesh-to-LAN port-forwarding) modes.
|
||||
- Peer ACL: per-npub allow / deny admission control at the link
|
||||
|
||||
844
RELEASE-NOTES.md
844
RELEASE-NOTES.md
@@ -1,696 +1,136 @@
|
||||
# FIPS v0.3.0
|
||||
# FIPS v0.4.1
|
||||
|
||||
**Released**: 2026-05-11
|
||||
**Released**: 2026-07-19
|
||||
|
||||
v0.3.0 is the testing-and-polishing release on the v0.2.x wire format.
|
||||
It widens the platform reach of FIPS from Linux-only to Linux, macOS,
|
||||
Windows, and OpenWrt; adds two large new mesh capabilities (Nostr-mediated
|
||||
peer discovery with UDP NAT traversal, and the `fips-gateway` LAN bridge);
|
||||
ships a default-deny security baseline for the mesh interface; introduces
|
||||
mesh-peer access control; substantially speeds up session-layer crypto and
|
||||
the Linux receive path; and tightens packaging across every supported
|
||||
distribution channel.
|
||||
v0.4.1 is a maintenance release on the v0.4.x line. It raises the default
|
||||
antipoison cap on inbound bloom filter announcements, removes a redundant
|
||||
spanning-tree metric counter, fixes two convergence and path-MTU bugs, and
|
||||
cuts per-packet CPU in the bloom and identity paths. There is no wire
|
||||
format change and no new feature surface.
|
||||
|
||||
v0.3.0 is wire-compatible with v0.2.x. Mixed meshes interoperate; there
|
||||
is no flag-day upgrade.
|
||||
|
||||
v0.3.0 also rolls forward all changes from the v0.2.1 maintenance
|
||||
release. The sections below cover the cumulative v0.2.0 → v0.3.0
|
||||
delta; the per-section intros call out which entries first shipped
|
||||
in v0.2.1.
|
||||
v0.4.1 is wire-compatible with v0.4.0. Nodes can be upgraded one at a time
|
||||
with no coordinated restart, though one behavior change below is worth
|
||||
reading before you start a rolling upgrade.
|
||||
|
||||
## At a glance
|
||||
|
||||
- 123 commits since v0.2.0 (109 non-merge), spanning 307 files with
|
||||
+44,186 / -4,078 lines.
|
||||
- 10 committers plus 3 issue reporters across feature work, fixes,
|
||||
packaging, and reviews.
|
||||
- 5 new GitHub Actions CI workflows (Linux Package, macOS Package,
|
||||
Windows Package, OpenWrt Package, AUR Publish) plus expanded
|
||||
integration matrices (gateway, NAT-cone, NAT-symmetric, NAT-LAN,
|
||||
rekey-accept-off, `.deb` install across Debian 12/13 + Ubuntu
|
||||
22/24/26, multi-backend `.fips` DNS resolver across the same five
|
||||
distros).
|
||||
- The long-standing systemd-resolved DNS-responder silent-drop is
|
||||
closed end-to-end.
|
||||
- Pre-1.0 control-socket JSON schema change for two query fields;
|
||||
see [Upgrade notes](#upgrade-notes).
|
||||
|
||||
## What's new
|
||||
|
||||
### Mesh discovery and NAT traversal
|
||||
|
||||
Previously, two FIPS nodes could only become peers if they had a way
|
||||
to find each other beforehand: a configured address, a shared LAN
|
||||
segment, or a Bluetooth radio range. v0.3.0 introduces a Nostr-based
|
||||
overlay-discovery channel that lets nodes find each other through any
|
||||
public Nostr relay set, plus a STUN-assisted UDP hole-punching path
|
||||
that connects peers across most consumer NATs.
|
||||
|
||||
Each participating node publishes a signed overlay advert as a Nostr
|
||||
**Kind 37195** parameterized replaceable event. (The kind sits in the
|
||||
application-defined replaceable range and the digits visually spell
|
||||
*FIPS*: 7=F, 1=I, 9=P, 5=S.) The advert lists reachable transport
|
||||
endpoints (UDP, TCP, Tor) and is consumed by other nodes to populate
|
||||
fallback addresses for `via_nostr` peers. Under `policy: open`, the
|
||||
advert cache is also dialed for non-configured peers within a budget
|
||||
cap.
|
||||
|
||||
When both peers are behind NAT, the daemon coordinates a UDP hole
|
||||
punch using NIP-59 gift-wrap signaling for the offer/answer exchange
|
||||
and STUN for reflexive address discovery. A candidate-pair punch
|
||||
planner attempts LAN-private and reflexive paths in parallel; on
|
||||
success the live socket is handed into the standard FIPS UDP transport
|
||||
via a bootstrap-handoff API.
|
||||
|
||||
Operators turn this on with `node.discovery.nostr.enabled: true` and
|
||||
the configured relay set. `policy: open` adds best-effort dialing of
|
||||
non-configured peers seen on the relays. New `peers[].via_nostr` and
|
||||
per-transport `advertise_on_nostr` / `public` flags control what each
|
||||
endpoint contributes to the published advert. Cross-field validation
|
||||
runs at startup to catch mis-configured combinations early.
|
||||
|
||||
A Docker NAT lab covering cone, symmetric, and LAN scenarios is wired
|
||||
into the integration CI matrix. A daemon-side failure-suppression
|
||||
layer (per-npub cooldown after consecutive failures, ±60s clock-skew
|
||||
tolerance, rate-limited WARN logs) keeps relay traffic well-mannered
|
||||
when peers come and go from the open discovery cache. A separate
|
||||
structural cooldown (`protocol_mismatch_cooldown_secs`, default 24h)
|
||||
suppresses retraversal when a punched peer turns out to be running an
|
||||
FMP version this daemon cannot handshake with: the punch completes at
|
||||
the UDP layer, the rx loop spots the version-mismatched packet,
|
||||
reverse-maps to the originating npub, and removes the peer from the
|
||||
next sweep until either side upgrades.
|
||||
|
||||
The auto-connect retry loop pins itself to relay ground truth. Each
|
||||
retry attempt refetches the cached overlay advert against the
|
||||
configured `advert_relays` (one filter query, 2s timeout) before
|
||||
dialing, so a peer whose NAT rebound to a fresh endpoint is recovered
|
||||
on the next retry rather than looping on a stale cached address.
|
||||
`NoTransportForType` triggers a fire-and-forget re-fetch that either
|
||||
replaces or evicts the cache entry. A startup peer-init failure (no
|
||||
operational transport, all addresses unreachable) now schedules a
|
||||
retry instead of leaving the peer in a dead state until the daemon is
|
||||
restarted. Adopted NAT-traversed UDP transports inherit the operator's
|
||||
primary `[transports.udp]` listener config (MTU, recv/send buffer
|
||||
sizes) instead of falling back to the 1280 IPv6-minimum default.
|
||||
|
||||
### Cross-platform reach
|
||||
|
||||
FIPS now ships first-class binaries for **Linux, macOS, Windows, and
|
||||
OpenWrt**.
|
||||
|
||||
- **macOS** support uses the native `utun` TUN interface, raw
|
||||
Ethernet via BPF, a `.pkg` installer with a launchd plist and
|
||||
uninstall script, and an x86_64 cross-compile from arm64 build
|
||||
hosts. A new CI matrix entry runs build and unit-test jobs on
|
||||
macOS hosts.
|
||||
- **Windows** support uses [wintun](https://www.wintun.net/) for the
|
||||
TUN device, a TCP control socket on `localhost:21210` (replacing
|
||||
the Unix domain socket Linux and macOS use), Windows Service
|
||||
lifecycle (`fips.exe --install-service`, `--uninstall-service`,
|
||||
`--service`), and a ZIP package with PowerShell install/uninstall
|
||||
scripts.
|
||||
- **MIPS** atomic-ABI portability lets the daemon build for 32-bit
|
||||
MIPS targets (`mips`, `mipsel`, MIPS32r2) by routing through
|
||||
`portable_atomic`. This unblocks OpenWrt deployments on
|
||||
consumer-grade MIPS routers.
|
||||
- **OpenWrt** packaging gets a procd init with dnsmasq forwarding,
|
||||
proxy NDP, RA route advertisements, and IPv6 forwarding sysctls.
|
||||
The `fips-gateway` is enabled by default in the OpenWrt build.
|
||||
|
||||
### FIPS gateway
|
||||
|
||||
The new `fips-gateway` binary lets unmodified LAN hosts reach FIPS
|
||||
mesh destinations without running the FIPS daemon themselves. Two
|
||||
flows ship together:
|
||||
|
||||
- **Outbound (LAN -> mesh)**: a virtual-IP pool (default
|
||||
`fd01::/112`) is allocated on demand from `.fips`-name DNS lookups.
|
||||
A state-machine lifecycle, conntrack-backed session tracking, proxy
|
||||
NDP on the LAN interface, and TTL-based reclamation handle the
|
||||
bookkeeping. A LAN host that resolves `peer.fips` gets a virtual
|
||||
address it can reach over IP, and the gateway translates the flow
|
||||
to the mesh.
|
||||
- **Inbound (mesh -> LAN)**: new `gateway.port_forwards` config
|
||||
installs prerouting DNAT rules so mesh peers can reach a configured
|
||||
`host:port` on the gateway's LAN. A LAN-side masquerade is added
|
||||
automatically when any forwards are configured, so replies flow
|
||||
back through conntrack.
|
||||
|
||||
A dedicated control socket at `/run/fips/gateway.sock` exposes
|
||||
`show_gateway` and `show_mappings`. `fipstop` adds a Gateway tab with
|
||||
a pool gauge and mappings table.
|
||||
|
||||
The gateway's `dns.listen` source default is now `[::1]:5353`,
|
||||
matching the canonical deployment model: the gateway sits on a host
|
||||
already serving DHCP and DNS to a LAN segment (an OpenWrt AP, a Linux
|
||||
router), port 53 there is taken by the existing resolver, and `.fips`
|
||||
queries are forwarded to the gateway over loopback. The OpenWrt ipk
|
||||
previously overrode the prior `[::]:53` source default in its packaged
|
||||
config; that override is now redundant and has been dropped.
|
||||
Operators on a host without a pre-existing resolver on port 53 can
|
||||
opt back into the wildcard bind by setting `dns.listen: "[::]:53"`
|
||||
explicitly. The new default binds IPv6 loopback only, so forwarders
|
||||
that reach the gateway over IPv4 loopback need an explicit IPv4
|
||||
listen address.
|
||||
|
||||
The cold-boot startup race between `fips.service` and
|
||||
`fips-gateway.service` is handled by a systemd `After=fips.service`
|
||||
ordering, an `ExecStartPre` poll loop that waits up to 30 seconds for
|
||||
the `fips0` interface to appear, and a DNS upstream probe in the
|
||||
gateway itself that retries up to 5 times with 1-second backoff.
|
||||
|
||||
Packaging covers systemd, Debian, AUR, and OpenWrt. The full design
|
||||
is in [`docs/design/fips-gateway.md`](../design/fips-gateway.md).
|
||||
|
||||
### Mesh-interface security baseline
|
||||
|
||||
The FIPS mesh is a flat layer-3 segment. Every authenticated peer can
|
||||
route packets to every other peer's `fips0` address. Peer identity is
|
||||
authenticated end-to-end by the FMP and FSP Noise handshakes, but
|
||||
identity is not authorization. A service on a mesh host that binds to
|
||||
a wildcard address is, by default, reachable from every peer in the
|
||||
mesh.
|
||||
|
||||
v0.3.0 ships an opt-in default-deny baseline that closes this gap on
|
||||
Linux:
|
||||
|
||||
- **`/etc/fips/fips.nft`** is installed as a documented operator
|
||||
conffile. It defines a single `inet fips` nftables table with one
|
||||
chain hooked at `input`, default-denies inbound traffic on
|
||||
`fips0`, and is a no-op for every other interface.
|
||||
- **`fips-firewall.service`** loads it. The unit ships **disabled by
|
||||
default**; activation is an explicit
|
||||
`systemctl enable --now fips-firewall.service`.
|
||||
- Per-service allowances live in **`/etc/fips/fips.d/*.nft`**
|
||||
drop-ins that the baseline includes.
|
||||
|
||||
Choosing opt-in keeps the mesh quick to bring up for evaluation while
|
||||
giving operators a documented, packaged path to lock it down for
|
||||
production. The full design (threat model, rule layout, conntrack
|
||||
handling, drop-in mechanism, and the rationale for a conffile rather
|
||||
than an auto-loaded package side-effect) is in
|
||||
[`docs/design/fips-security.md`](../design/fips-security.md).
|
||||
|
||||
`fipstop`'s Node tab gains a **"Listening on fips0" panel** that
|
||||
surfaces the answer to the operational question "what services on
|
||||
this host are reachable from the mesh, and what does the firewall
|
||||
currently say about each of them?" The panel lists every IPv6
|
||||
listening socket bound to either the wildcard address or this node's
|
||||
`fd00::/8` address, paired with its classification against the
|
||||
running `inet fips` baseline chain: `OPEN` (canonical accept rule),
|
||||
`filt` (falls through to drop), or `filt?` (referenced with matchers
|
||||
the panel cannot fully decompose, e.g. saddr filters or jumps). When
|
||||
`fips-firewall.service` is inactive, a yellow banner above the table
|
||||
reminds the operator that every listener is mesh-exposed; wildcard
|
||||
binds carry a trailing `*` in the Process column. The classifier is
|
||||
built on a new `show_listening_sockets` control query (Linux-only),
|
||||
which is also useful from `fipsctl` for scripting.
|
||||
|
||||
### Peer access control
|
||||
|
||||
Operators can now restrict which mesh peers a node will form direct
|
||||
links with. Optional `/etc/fips/peers.allow` and `/etc/fips/peers.deny`
|
||||
files (TCP-Wrappers style) match against npub, hex pubkey, host
|
||||
alias, or `ALL`. Enforcement runs at three points:
|
||||
|
||||
1. Outbound connect (before dialing).
|
||||
2. Inbound msg1 (the first FMP handshake message from a new peer).
|
||||
3. Outbound msg2 (the response).
|
||||
|
||||
Files reload automatically on mtime change; a new `fipsctl acl show`
|
||||
query reports the effective rule set. A six-node Docker integration
|
||||
harness (`testing/acl/`) exercises allowlist and denylist patterns
|
||||
end-to-end.
|
||||
|
||||
**Important scope distinction**: peer ACLs are an FMP-layer
|
||||
restriction. They control who can establish a *direct link* with this
|
||||
node. They do **not** control session-layer (FSP) reachability through
|
||||
the mesh. A node that denies peer X with an ACL can still receive FSP
|
||||
traffic from X relayed via other peers.
|
||||
|
||||
### Bluetooth Low Energy transport (experimental, Linux)
|
||||
|
||||
A new BLE L2CAP Connection-Oriented Channel transport lets FIPS nodes
|
||||
peer over Bluetooth Low Energy without any IP infrastructure in
|
||||
between. The transport handles per-link MTU negotiation, continuous
|
||||
scan/probe peer discovery with cooldown-based deduplication,
|
||||
continuous advertising, deterministic NodeAddr cross-probe
|
||||
tie-breaker, and a configurable connection pool with eviction.
|
||||
|
||||
This transport is **experimental in v0.3.0**. It is implemented and
|
||||
functional on Linux (BlueZ via `bluer`), but the reliability follow-up
|
||||
logic (probe cooldown, cross-probe tie-breaker, pubkey timeout,
|
||||
continuous advertising semantics, probe-promotion, fail-fast send) is
|
||||
not yet behaviorally tested in CI. Its maturity path is field-driven;
|
||||
please file issues with field reports. macOS BLE support is in
|
||||
development as a separate track and is not part of v0.3.0.
|
||||
|
||||
### UDP transport profiles
|
||||
|
||||
The UDP transport gains posture flags organized around deployment
|
||||
patterns:
|
||||
|
||||
- **Public-facing inbound nodes**: `bind_addr: "0.0.0.0:2121"`,
|
||||
`accept_connections: true` (default), `public: true` for advert
|
||||
publication. v0.3.0 adds STUN-based public-IP autodiscovery so
|
||||
cloud nodes (AWS EIP, GCP, Azure 1:1 NAT) advertise the right
|
||||
address even when the public IP isn't on a host interface.
|
||||
- **Ephemeral leaf nodes**: `outbound_only: true` binds an ephemeral
|
||||
port (`0.0.0.0:0`), refuses inbound msg1, and is never advertised
|
||||
on Nostr regardless of `advertise_on_nostr`. Use this for client
|
||||
postures that should connect outbound only, without exposing an
|
||||
inbound listener on a known port.
|
||||
- **General-purpose nodes**: `accept_connections: false` mirrors the
|
||||
Ethernet/BLE knob without changing the bind address. The Node-level
|
||||
handshake gate carves out msg1 from peers already established on
|
||||
this transport so rekey continues to work.
|
||||
|
||||
Startup validation now rejects `bind_addr` set to a loopback address
|
||||
when at least one peer has a non-loopback UDP address, closing a
|
||||
silent-failure trap from v0.2.0 where Linux's source-address routing
|
||||
check would drop outbound flows from the loopback-bound socket.
|
||||
|
||||
A new `external_addr` field on `transports.udp.*` and
|
||||
`transports.tcp.*` lets operators specify the advertise-as address
|
||||
explicitly. This is useful for UDP as a deterministic alternative to
|
||||
STUN, and required for TCP on cloud-NAT setups (where binding to the
|
||||
public IP fails with `EADDRNOTAVAIL` because the IP isn't on a host
|
||||
interface).
|
||||
|
||||
### `.fips` DNS resolver overhaul
|
||||
|
||||
The IPv6 adapter's `.fips` name resolution has been rebuilt around
|
||||
the constraints of contemporary systemd-based hosts. The default
|
||||
`dns.bind_addr` is now `::1` (IPv6 loopback), and a setup script
|
||||
picks one of five backends in priority order:
|
||||
|
||||
1. systemd-resolved global drop-in
|
||||
(`/etc/systemd/resolved.conf.d/fips.conf`).
|
||||
2. systemd dns-delegate (per-link configuration handed off to
|
||||
systemd-resolved).
|
||||
3. `resolvectl` per-link configuration.
|
||||
4. Standalone `dnsmasq`.
|
||||
5. NetworkManager's dnsmasq plugin.
|
||||
|
||||
Teardown reverses only what setup applied, recorded in a state file
|
||||
at `/run/fips/dns-backend`. A new `testing/dns-resolver/` harness
|
||||
exercises every backend across Debian 12, Debian 13, Ubuntu 22.04,
|
||||
Ubuntu 24.04, and Ubuntu 26.04, so a regression in any of the five
|
||||
backends shows up in CI rather than in the field.
|
||||
|
||||
This overhaul resolves the long-standing silent-drop case where the
|
||||
`resolvectl dns fips0 [<fips0_addr>]:5354` target collided with the
|
||||
daemon's mesh-interface filter on certain systemd-resolved
|
||||
deployments (typically Ubuntu 22 with systemd 249's interface-scoped
|
||||
routing).
|
||||
|
||||
### Operator tooling additions
|
||||
|
||||
A handful of additions land in `fipsctl`, `fipstop`, and the daemon's
|
||||
configuration surface:
|
||||
|
||||
- **`node.log_level`** config field replaces the hardcoded
|
||||
`RUST_LOG=info` previously baked into systemd units and the
|
||||
OpenWrt procd init. The daemon now loads config before
|
||||
initializing tracing so the configured level takes effect.
|
||||
`RUST_LOG` still overrides when set.
|
||||
- **`fipsctl show identity-cache`** is a new query that lists every
|
||||
cached node identity (npub, IPv6 address, display name, LRU age)
|
||||
alongside the configured cache capacity.
|
||||
- **`fipsctl show peers / sessions / cache / routing`** are
|
||||
substantially extended: per-peer security signals (replay
|
||||
suppression count, consecutive decrypt failures), Noise session
|
||||
counters, session indices, rekey lifecycle state, handshake resend
|
||||
counts, K-bit epoch, coords-warmup remaining, drain state, per-peer
|
||||
retry state, per-target lookup detail (attempt, age, last sent),
|
||||
and pending TUN packet queue depth.
|
||||
- **Historical statistics**: in-memory time-series rings on the
|
||||
daemon (1-second × 3600 fast, 1-minute × 1440 slow) cover per-node
|
||||
and per-peer metrics. New `show_stats_*` control-socket queries, a
|
||||
`fipsctl stats list / peers / history` subcommand with Unicode
|
||||
sparkline rendering, and a `fipstop` Graphs tab with btop-style
|
||||
sparklines surface them to the operator.
|
||||
|
||||
### Performance
|
||||
|
||||
Two independent perf threads land in v0.3.0: a session-layer crypto
|
||||
backend swap, and a Linux receive-path overhaul.
|
||||
|
||||
**Session-layer crypto backend.** The ChaCha20-Poly1305 backend used
|
||||
by every FIPS Noise session (end-to-end FSP traffic and link-layer
|
||||
FMP traffic alike) has been swapped from RustCrypto's
|
||||
`chacha20poly1305` crate to `ring 0.17`. ring wraps BoringSSL's
|
||||
hand-tuned ChaCha20-Poly1305 implementation, which dispatches to NEON
|
||||
on aarch64 and AVX2 / AVX-512 on x86_64. Typical throughput is in the
|
||||
3-5 GB/s/core range, versus the ~600-800 MB/s/core RustCrypto soft
|
||||
path on the same hardware.
|
||||
|
||||
Wire format is unchanged. ChaCha20-Poly1305 is byte-deterministic for
|
||||
a given `(key, nonce, plaintext, aad)`, so any correct AEAD
|
||||
implementation produces identical ciphertext. A mixed mesh with some
|
||||
nodes pre-swap and some post-swap interoperates without protocol
|
||||
awareness; v0.3.0 can roll out across a mesh in any order.
|
||||
|
||||
Measurements on an aarch64 Apple Silicon docker target:
|
||||
|
||||
- Two-node TCP single-stream: 437 -> 1097 Mbps (about 2.5×).
|
||||
- Two-node UDP at 1000 Mbit: 599 Mbps with 40% loss -> lossless at
|
||||
line rate.
|
||||
- Three-node ping under bulk-traffic load: 7.68 ms avg / 215 ms max
|
||||
-> 0.72 ms / 3.6 ms max as the relay path stops being crypto-bound.
|
||||
|
||||
No operator-visible action is required; the swap is internal to the
|
||||
session layer.
|
||||
|
||||
**Linux UDP receive path.** The Linux UDP receive path now uses
|
||||
`recvmmsg(2)` with a 32-packet batch in place of single-packet
|
||||
`recvmsg(2)`. A single `readable()` wakeup drains up to 32 datagrams
|
||||
in one syscall before yielding back to the reactor, eliminating the
|
||||
per-packet scheduler-hop and futex cost that previously capped
|
||||
inbound rate at one event per scheduler quantum independent of CPU.
|
||||
`SO_RXQ_OVFL` is sampled once per batch and surfaced through
|
||||
`AsyncUdpSocket::recv_batch` so the existing 1Hz transport-congestion
|
||||
detector continues to feed the per-transport `dropping` flag. macOS
|
||||
and Windows fall through to the per-packet path; `recvmmsg` is
|
||||
Linux-specific.
|
||||
|
||||
**Inner rx-loop drain batching.** `Node::run_rx_loop` drains up to
|
||||
256 additional ready items via `try_recv()` after each
|
||||
`tokio::select!` await fires on the packet and TUN-outbound branches,
|
||||
in a tight inner loop before yielding. Previously the select cost a
|
||||
full scheduler hop and futex per packet, capping throughput at one
|
||||
event per scheduler quantum with the worker near-idle. `biased`
|
||||
ordering keeps data-plane branches priority over tick / control / DNS
|
||||
under sustained load; the 256 cap keeps the worker on a busy stream
|
||||
between yield points (about 400 KB of contiguous traffic) while still
|
||||
bounding the inner loop so a flood on one branch cannot starve the
|
||||
periodic tick or control socket.
|
||||
|
||||
**Eager `pubkey_full` precompute.** `PeerIdentity::pubkey_full()`
|
||||
precomputes the parity-aware full secp256k1 public key at
|
||||
construction in `from_pubkey`. Previously the method fell through to
|
||||
an EC point parse on every call when the full key wasn't passed at
|
||||
construction (i.e. for every peer constructed from an npub or x-only
|
||||
key), about 6% of per-packet CPU on the bulk-data send path for a
|
||||
value that never changed after construction. The same parse already
|
||||
runs at construction inside `NodeAddr::from_pubkey`, so the cost is
|
||||
paid once where it would be paid anyway.
|
||||
|
||||
These three changes are a coordinated set: the syscall batching
|
||||
removes the per-packet kernel cost, the inner-loop drain removes the
|
||||
per-packet scheduler cost, and the pubkey-cache change removes the
|
||||
per-packet crypto-derivation cost. Like the AEAD swap, they are all
|
||||
internal and require no operator action.
|
||||
|
||||
### Examples
|
||||
|
||||
- **macOS WireGuard companion** ([#51](https://github.com/jmcorgan/fips/pull/51)):
|
||||
run FIPS in a local Docker container and route `.fips` traffic
|
||||
from the macOS host through a WireGuard tunnel to the container's
|
||||
`fips0`. Only traffic destined for `fd00::/8` transits the
|
||||
companion; regular internet traffic continues to use the host
|
||||
network. Persistent FIPS and WireGuard key material is generated
|
||||
on first run.
|
||||
|
||||
### Documentation
|
||||
|
||||
- **`docs/design/port-advertisement-and-nat-traversal.md`**
|
||||
documents how nodes find each other through Nostr relays and the
|
||||
STUN-assisted UDP hole punch.
|
||||
- **`docs/design/fips-gateway.md`** documents the gateway's virtual
|
||||
IP pool, lifecycle, control surface, and packaging.
|
||||
- **`docs/design/fips-security.md`** documents the mesh-interface
|
||||
security posture, threat model, default-deny baseline, and drop-in
|
||||
workflow.
|
||||
- **`CONTRIBUTING.md`** has been expanded with build prerequisites,
|
||||
Rust toolchain setup, and first-build steps.
|
||||
|
||||
The `docs/` tree has been reorganized end-to-end into four sections
|
||||
(*tutorials / how-to / reference / design*) with a new
|
||||
[`docs/getting-started.md`](../getting-started.md) and per-section
|
||||
landing pages. Content was reconciled against current source:
|
||||
protocol-layer details, wire-format diagrams, configuration knobs,
|
||||
and CLI references were brought back into agreement with the
|
||||
implementation. See [Documentation pointers](#documentation-pointers)
|
||||
below for entry points by reader intent.
|
||||
- `node.bloom.max_inbound_fpr` default moves from `0.10` to `0.20`.
|
||||
- The `parent_switched` metric counter is gone. Use `parent_switches`.
|
||||
- Spanning tree no longer serves stale coordinates after a parent link is
|
||||
lost through peer removal.
|
||||
- Discovery no longer loosens a path MTU clamp it had correctly tightened.
|
||||
- Bloom probing and identity operations do measurably less work per call,
|
||||
with identical results.
|
||||
|
||||
## Behavior changes worth flagging
|
||||
|
||||
These default-config changes affect every operator on upgrade, even
|
||||
those with no explicit configuration. Two items below — bloom-filter
|
||||
fill-ratio validation and TreeAnnounce ancestry validation — first
|
||||
shipped in v0.2.1 and roll forward into v0.3.0; the rest are
|
||||
v0.3.0-net-new.
|
||||
### The inbound filter FPR cap default doubles again
|
||||
|
||||
- **Discovery rate-limiting** has been retuned to be less aggressive
|
||||
at cold start. v0.2.0 used a single-lookup-with-internal-retry
|
||||
model where a timed-out lookup during bloom-filter propagation
|
||||
could suppress retries for 30 seconds while none of the reset
|
||||
triggers fired on a stable post-handshake topology. v0.3.0
|
||||
replaces this with a per-attempt timeout sequence
|
||||
(`node.discovery.attempt_timeouts_secs`, default `[1, 2, 4, 8]`,
|
||||
15s total). Each attempt sends a fresh `LookupRequest` with a new
|
||||
`request_id`, letting successive attempts take different
|
||||
forwarding paths as the bloom and tree state evolve. Post-failure
|
||||
suppression is **off by default**; operators with chatty
|
||||
applications can opt back in via `backoff_base_secs` /
|
||||
`backoff_max_secs`.
|
||||
- **MMP report intervals** are retuned for constrained transports.
|
||||
The steady-state floor moves from 100ms to 1000ms, the ceiling
|
||||
from 2000ms to 5000ms, with a cold-start phase running 200ms for
|
||||
the first 5 SRTT samples. This reduces BLE overhead by roughly
|
||||
10× while keeping reports well above the EWMA convergence
|
||||
threshold. Session-layer MMP intervals are unchanged.
|
||||
- **Bloom filter fill-ratio validation** runs on every inbound
|
||||
`FilterAnnounce`. Filters whose derived false-positive rate
|
||||
exceeds `node.bloom.max_inbound_fpr` (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.
|
||||
- **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 / v0.3.0
|
||||
meshes may produce WARN log lines on the v0.2.1+ side until all
|
||||
peers upgrade; behavior is correct, log noise only.
|
||||
- **Log noise reduction**: 35 info-level log messages have been
|
||||
demoted to debug (handshake cross-connection mechanics, periodic
|
||||
MMP telemetry, TUN/transport shutdown, retry scheduling). The
|
||||
default `RUST_LOG` in systemd units is now `info`, where it
|
||||
previously ran at `debug`. Operator-visible info output now
|
||||
focuses on lifecycle events, peer promotions, session
|
||||
establishment, parent switches, and transport start/stop.
|
||||
`node.bloom.max_inbound_fpr` goes from `0.10` to `0.20`. The cap rejects
|
||||
inbound `FilterAnnounce` frames whose advertised false positive rate
|
||||
exceeds it. On the fixed 1 KB, k=5 filter, `0.10` corresponds to a fill of
|
||||
0.631 and roughly 1,630 reachable entries, and the busiest nodes'
|
||||
aggregates had started reaching that ceiling as the mesh grew. `0.20`
|
||||
corresponds to a fill of 0.7248 and roughly 2,114 entries.
|
||||
|
||||
Be aware that this is the second time in two releases that this default
|
||||
has doubled, for the same reason both times. That is worth stating plainly
|
||||
rather than repeating the previous release's framing: raising the cap buys
|
||||
headroom, it does not fix anything. The real constraint is the fixed 1 KB
|
||||
filter size, which is a protocol constant. The structural remedy is the v2
|
||||
filter work, where filter capacity scales with the mesh instead of being
|
||||
pinned. This release is an interim step to keep legitimate aggregates from
|
||||
being rejected until that lands. It is not the start of a pattern of
|
||||
raising the cap once per release, and if you are sizing capacity planning
|
||||
around this number, plan against the v2 work rather than against a third
|
||||
raise.
|
||||
|
||||
The antipoison property the cap exists for is preserved. A saturated or
|
||||
deliberately poisoned filter still presents an FPR near 100% and is still
|
||||
rejected.
|
||||
|
||||
**This matters during a rolling upgrade.** A v0.4.1 node accepts a
|
||||
`FilterAnnounce` with a derived FPR between 0.10 and 0.20; a v0.4.0 node
|
||||
drops the same frame, and the drop is silent on the wire with no NACK. The
|
||||
cap also gates the mesh size estimator, which declines to produce a value
|
||||
when any contributing filter is over the cap. So while a mesh is partly
|
||||
upgraded, upgraded and not-yet-upgraded nodes can legitimately report
|
||||
different mesh sizes, or one can report a size while the other reports
|
||||
unknown. This resolves once every node is on v0.4.1. If you want to avoid
|
||||
the window entirely, set `node.bloom.max_inbound_fpr: 0.10` explicitly in
|
||||
your config before upgrading and remove it after the last node is done.
|
||||
|
||||
### The `parent_switched` counter is removed
|
||||
|
||||
`parent_switched` was incremented on the line immediately before
|
||||
`parent_switches` at every site and never independently, so the two
|
||||
counters always held the same value. `parent_switched` is now gone from
|
||||
the tree metrics, the control socket snapshot, and the `fipstop` tree
|
||||
view. `parent_switches` remains and is unchanged.
|
||||
|
||||
If you scrape the control socket, or have dashboards or alerts referencing
|
||||
`parent_switched`, point them at `parent_switches`. Anything still asking
|
||||
for `parent_switched` will find nothing rather than a zero.
|
||||
|
||||
## Notable bug fixes
|
||||
|
||||
These pre-existing v0.2.0 bugs are worth singling out because they
|
||||
either affected real-world deployments or produced misleading
|
||||
operator experiences. The CHANGELOG has the exhaustive list; this is
|
||||
the operator-relevant subset. Four items below first shipped in
|
||||
v0.2.1 and roll forward into v0.3.0: auto-connect Disconnect-reconnect,
|
||||
`fipsctl connect` mesh-address rejection, `fd00::/8` routing
|
||||
protection from Tailscale interception, and bloom-filter routing
|
||||
greedy-tree fallback. The control-socket path-detection fix landed
|
||||
in v0.2.1 as well, and the unified resolver below is the v0.3.0
|
||||
refactor that builds on it.
|
||||
### Stale coordinates after losing a parent through peer removal
|
||||
|
||||
- **DNS responder silent-drop on systemd-resolved** is fixed: the
|
||||
responder no longer drops queries on Ubuntu 22 / Debian 13 and
|
||||
similar deployments where systemd applies interface-scoped
|
||||
routing. Default bind moves to `::1`; new global drop-in backend
|
||||
available ([#52](https://github.com/jmcorgan/fips/issues/52),
|
||||
[#77](https://github.com/jmcorgan/fips/issues/77)).
|
||||
- **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)).
|
||||
- **Default control-socket path resolution unified.** Daemon and
|
||||
client tools now share a single resolver, eliminating a divergence
|
||||
where `fipsctl` / `fipstop` could connect to a socket the daemon
|
||||
never bound (notably on dev runs with `XDG_RUNTIME_DIR` set, or
|
||||
after a prior packaged install left a root-owned `/run/fips`
|
||||
behind). Canonical order is
|
||||
`/run/fips` -> `$XDG_RUNTIME_DIR/fips/` -> `/tmp/fips-<name>`. The
|
||||
`/run/fips` arm is selected by directory existence; the kernel
|
||||
enforces actual access at `connect(2)` time, so users not yet in
|
||||
the `fips` group get a clear `EACCES` rather than a silent path
|
||||
mismatch and a misleading `No such file` fallback to
|
||||
`$XDG_RUNTIME_DIR`. `XDG_RUNTIME_DIR` is validated as an existing
|
||||
directory before being used so stale post-logout values are
|
||||
treated as missing. The deployed fleet is unaffected: packaged
|
||||
configs set `node.control.socket_path` explicitly
|
||||
([#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.
|
||||
- **TCP-over-FIPS reliability on mixed-MTU paths** is markedly
|
||||
improved. Four interlocking changes ship together:
|
||||
`Node::transport_mtu()` is now deterministic across daemon
|
||||
restarts (min across operational transports rather than
|
||||
insertion-order-dependent); the TCP MSS clamp at the TUN boundary
|
||||
reads per-destination path MTU instead of a single global ceiling;
|
||||
reactive `MtuExceeded` from forwarders is mirrored back into the
|
||||
TUN-side `path_mtu_lookup` so later flows pick up forward-path
|
||||
bottlenecks without re-discovery; and the proactive end-to-end
|
||||
`PathMtuNotification` echoed by the destination is mirrored into
|
||||
the same TUN-side store. Without that fourth piece, on long-lived
|
||||
stable paths where the destination's echo had tightened the
|
||||
session MTU but no transit router had emitted a fresh
|
||||
`MtuExceeded`, new TCP flows opened in that window were clamped by
|
||||
the staler discovery-time value. The proactive mirror uses the
|
||||
same tighter-only semantics as the reactive mirror, so it never
|
||||
loosens the clamp. The Windows TUN reader receives the same
|
||||
per-destination plumbing.
|
||||
- **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.
|
||||
- **`fipstop` graceful tty-init failure.** `ratatui::try_init()`
|
||||
produces a clean error message instead of a hard crash when
|
||||
terminal initialization fails (Docker on macOS Sequoia, ttyless
|
||||
environments).
|
||||
- **TreeAnnounce ancestry on self-root transitions.** When a node
|
||||
had no smaller-NodeAddr peer to use as a parent, the spanning-tree
|
||||
state correctly promoted it to root, but the ancestry advertised
|
||||
on the next `TreeAnnounce` still referenced its previous parent's
|
||||
path. Receiving peers rejected the announce as
|
||||
`invalid ancestry: advertised root X is not the minimum path entry
|
||||
Y`, blocking mesh transit on any path that needed to traverse the
|
||||
node. The self-root transition is now detected explicitly in
|
||||
`TreeState::become_root` and the advertised ancestry rebuilt to
|
||||
start from self; the MMP receive handler corrects stale ancestry
|
||||
inherited across reconnect eagerly rather than waiting for the
|
||||
next observation tick.
|
||||
- **Spanning-tree internal-path updates** that change only the
|
||||
internal path between root and leaf (without changing the root or
|
||||
the depth) now propagate to leaves correctly. Previously, a leaf
|
||||
could continue routing against a stale internal path until the
|
||||
parent or depth also changed.
|
||||
When a node's parent link dropped via peer removal, the node correctly
|
||||
reparented or self-rooted, but skipped the coordinate cache invalidation
|
||||
that every other position-change path performs. Cached entries for
|
||||
downstream destinations kept the node's old coordinate prefix. This did
|
||||
not self-correct the way a stale cache entry normally would: routing
|
||||
access refreshes an entry's TTL, so an entry that was actively being
|
||||
routed through never expired, and was only fixed by an unrelated fresh
|
||||
insert. Both invalidation classes now run on this path, matching the
|
||||
loop-detection branch.
|
||||
|
||||
### Discovery could loosen a tightened path MTU clamp
|
||||
|
||||
An originator handling a `LookupResponse` overwrote its cached path MTU
|
||||
unconditionally. If a reactive `MtuExceeded` or `PathMtuNotification` had
|
||||
already taught it a tighter value, a later, looser discovery estimate
|
||||
would clobber that and re-loosen the clamp, risking a return to dropped
|
||||
oversized packets. The cached and received values are now compared and the
|
||||
tighter one is kept.
|
||||
|
||||
## Upgrade notes
|
||||
|
||||
Operator-actionable items when moving from v0.2.x to v0.3.0:
|
||||
This is a drop-in upgrade from v0.4.0 with no wire format change, no
|
||||
config migration, and no coordinated restart. Upgrade nodes in whatever
|
||||
order you like.
|
||||
|
||||
- **Control socket JSON schema (breaking, pre-1.0).**
|
||||
- `show_cache` response field `entries` has changed type from a
|
||||
`u64` count to an array of entry objects. The previous scalar
|
||||
value is now in a new `count` field.
|
||||
- `show_routing` response field `pending_lookups` has changed
|
||||
type from a `u64` count to an array of per-target lookup
|
||||
objects.
|
||||
- External tooling parsing these fields as numbers must be
|
||||
updated. In-tree `fipstop` is adjusted to the new schema. The
|
||||
control-socket interface remains pre-1.0 and is not covered by
|
||||
stability guarantees.
|
||||
Two things to do rather than assume:
|
||||
|
||||
- **Cargo feature flags removed.** `tui`, `ble`, `gateway`, and
|
||||
`nostr-discovery` are gone. Subsystem inclusion is now driven by
|
||||
platform `cfg` gates, so plain `cargo build` compiles everything
|
||||
available on the target without `--features` invocations.
|
||||
Source-build tooling that passed any of these features should be
|
||||
updated to omit them.
|
||||
1. If you monitor `parent_switched`, move to `parent_switches` before
|
||||
upgrading, or your dashboards will go blank rather than error.
|
||||
2. During the rolling window, expect upgraded and not-yet-upgraded nodes
|
||||
to potentially disagree about mesh size, per the FPR cap section above.
|
||||
This is expected and self-resolves. Do not chase it as a bug unless it
|
||||
persists after every node reports `0.4.1`.
|
||||
|
||||
- **Discovery rate-limiting defaults changed.** Post-failure
|
||||
suppression is **off by default**
|
||||
(`node.discovery.backoff_base_secs: 0`, `backoff_max_secs: 0`).
|
||||
Operators relying on the prior 30s base / 300s cap behavior must
|
||||
set those fields explicitly. The per-attempt sequence
|
||||
(`attempt_timeouts_secs`, default `[1, 2, 4, 8]`) now governs
|
||||
cold-start lookup behavior.
|
||||
If you have pinned `node.bloom.max_inbound_fpr` explicitly in your config,
|
||||
your setting is honored and nothing changes for you. The change only
|
||||
affects nodes taking the default.
|
||||
|
||||
- **`.fips` DNS bind address default changed.** The default
|
||||
`dns.bind_addr` is now `::1`. Operators with explicit overrides
|
||||
of this field should review them; many existing overrides were
|
||||
workarounds for the silent-drop bug that this release fixes
|
||||
properly.
|
||||
Downgrading to v0.4.0 is supported and needs no special handling.
|
||||
|
||||
- **Gateway `dns.listen` source default changed.** The
|
||||
`fips-gateway` `dns.listen` default is now `[::1]:5353` (was
|
||||
`[::]:53`), matching the canonical deployment model where a
|
||||
pre-existing resolver on the host already owns port 53. The
|
||||
OpenWrt ipk previously overrode this in its packaged config; the
|
||||
override is now redundant and has been dropped. Operators on a
|
||||
host without a pre-existing resolver on port 53 can opt back into
|
||||
the wildcard bind by setting `dns.listen: "[::]:53"` explicitly.
|
||||
The new default binds IPv6 loopback only, so forwarders that
|
||||
reach the gateway over IPv4 loopback need an explicit IPv4 listen
|
||||
address.
|
||||
|
||||
- **systemd unit log level.** The shipped systemd units no longer
|
||||
hardcode `RUST_LOG=info`; the daemon's effective log level is
|
||||
driven by `node.log_level` (default `info`). `RUST_LOG`, when
|
||||
set, still overrides.
|
||||
|
||||
- **UDP transport `bind_addr` validation.** Startup now rejects a
|
||||
`bind_addr` set to a loopback address when at least one peer has
|
||||
a non-loopback UDP address. Operators who configured a loopback
|
||||
UDP bind as a workaround should switch to `outbound_only: true`
|
||||
for the same effect, plus the correct semantics (kernel-assigned
|
||||
ephemeral port, refuses inbound, never advertised).
|
||||
|
||||
- **Tor advert port.** If the Tor `HiddenServicePort` virtual port
|
||||
isn't 443, set `transports.tor.advertised_port` to match. The
|
||||
default is 443 and matches the conventional virtual-port choice.
|
||||
|
||||
## Documentation pointers
|
||||
|
||||
v0.3.0 ships a `docs/` tree reorganized into four sections
|
||||
(*tutorials / how-to / reference / design*). A new top-level
|
||||
[`docs/getting-started.md`](../getting-started.md) and per-section
|
||||
landing pages anchor the entry points.
|
||||
|
||||
Entry points by reader intent:
|
||||
|
||||
- **New users**: [`docs/getting-started.md`](../getting-started.md)
|
||||
and [`docs/tutorials/`](../tutorials/) cover guided introductions
|
||||
for bringing up your first node, joining the test mesh,
|
||||
advertising a node over Nostr, hosting a service, deploying a
|
||||
gateway, walking through the IPv6 adapter, and resolving peers
|
||||
via Nostr.
|
||||
- **Operators with a specific task**:
|
||||
[`docs/how-to/`](../how-to/) holds task-driven guides for enabling
|
||||
Nostr discovery, deploying the gateway, troubleshooting the
|
||||
gateway, deploying a Tor onion, hosting aliases, persistent
|
||||
identity, running unprivileged, setting up a Bluetooth peer,
|
||||
enabling the mesh firewall, tuning UDP buffers, and diagnosing
|
||||
MTU issues.
|
||||
- **Reference lookups**: [`docs/reference/`](../reference/) holds
|
||||
the config field reference, control-socket query reference, the
|
||||
`fips`, `fipsctl`, `fipstop`, and `fips-gateway` CLI references,
|
||||
and the protocol diagram set.
|
||||
- **Architectural background**: [`docs/design/`](../design/) holds
|
||||
design rationale for FIPS as a whole, FMP and FSP, the spanning
|
||||
tree, bloom-filter discovery, transports, the IPv6 adapter, the
|
||||
Nostr discovery layer, and the gateway.
|
||||
- **Security**: [`docs/design/fips-security.md`](../design/fips-security.md)
|
||||
documents the mesh-interface security baseline, threat model, and
|
||||
drop-in workflow.
|
||||
|
||||
## Getting v0.3.0
|
||||
## Getting v0.4.1
|
||||
|
||||
- **Linux x86_64 / aarch64**: `.deb` and tarball at the
|
||||
[v0.3.0 release page](https://github.com/jmcorgan/fips/releases/tag/v0.3.0).
|
||||
[v0.4.1 release page](https://github.com/jmcorgan/fips/releases/tag/v0.4.1).
|
||||
- **Arch Linux**: `fips` from the AUR.
|
||||
- **macOS**: `.pkg` at the v0.3.0 release page.
|
||||
- **Windows**: ZIP at the v0.3.0 release page.
|
||||
- **OpenWrt**: `.ipk` at the v0.3.0 release page.
|
||||
- **From source**: `cargo build --release` from a checkout of the
|
||||
v0.3.0 tag.
|
||||
- **macOS**: `.pkg` at the v0.4.1 release page.
|
||||
- **Windows**: ZIP at the v0.4.1 release page.
|
||||
- **OpenWrt**: `.ipk` (OpenWrt 24.x and earlier) or `.apk` (OpenWrt 25+)
|
||||
at the v0.4.1 release page.
|
||||
- **From source**: `cargo build --release` from a checkout of the v0.4.1
|
||||
tag (Rust 1.94.1 per `rust-toolchain.toml`; `libclang-dev` is a
|
||||
required Linux build prerequisite).
|
||||
- **Nix / NixOS**: `nix build .#fips` from a checkout of the v0.4.1 tag
|
||||
builds the binaries from source with the pinned toolchain and no manual
|
||||
prerequisites (see the Nix section of `packaging/README.md`).
|
||||
|
||||
The full per-commit changelog lives in
|
||||
[`CHANGELOG.md`](../../CHANGELOG.md). Issues and discussion at
|
||||
@@ -698,67 +138,9 @@ The full per-commit changelog lives in
|
||||
|
||||
## Contributors
|
||||
|
||||
Thanks to everyone who contributed code, packaging work, bug reports,
|
||||
or reviews to this release.
|
||||
Thanks to everyone who contributed code, packaging work, bug reports, or
|
||||
reviews to this release.
|
||||
|
||||
**Code and packaging**:
|
||||
|
||||
- [@jcorgan](https://github.com/jmcorgan): release shepherd, Nostr
|
||||
discovery / NAT traversal, `fips-gateway`, ACL infrastructure,
|
||||
packaging, security baseline, BLE follow-ups.
|
||||
- [@Origami74](https://github.com/Origami74): macOS platform support,
|
||||
from-source Docker companion build and `fipstop` terminal-init
|
||||
handling, gateway co-development, OpenWrt BLE-feature build fix,
|
||||
AUR-workflow follow-ups.
|
||||
- [@jodobear](https://github.com/jodobear): Linux release-artifact
|
||||
workflow and target-aware build scripts, CONTRIBUTING.md
|
||||
expansion, rekey integration-test stabilization.
|
||||
- [@tidley](https://github.com/tidley): Nostr-mediated overlay
|
||||
discovery and UDP NAT traversal
|
||||
([#53](https://github.com/jmcorgan/fips/pull/53)).
|
||||
- [@alexxie16](https://github.com/alexxie16): peer ACL enforcement
|
||||
([#50](https://github.com/jmcorgan/fips/pull/50)),
|
||||
macOS WireGuard companion example
|
||||
([#51](https://github.com/jmcorgan/fips/pull/51)),
|
||||
follow-up ([#67](https://github.com/jmcorgan/fips/pull/67)).
|
||||
- [@osh](https://github.com/osh): diagnostic queries for security
|
||||
validation and mesh debugging
|
||||
([#42](https://github.com/jmcorgan/fips/pull/42)).
|
||||
- [@OceanSlim](https://github.com/0ceanSlim): Windows platform
|
||||
support ([#45](https://github.com/jmcorgan/fips/pull/45)).
|
||||
- [@mmalmi](https://github.com/mmalmi): ring AEAD backend
|
||||
([#80](https://github.com/jmcorgan/fips/pull/80)),
|
||||
hot-path drain batching + recvmmsg + eager pubkey_full
|
||||
([#81](https://github.com/jmcorgan/fips/pull/81)),
|
||||
TreeAnnounce self-root ancestry + overlay-advert retry hygiene
|
||||
([#82](https://github.com/jmcorgan/fips/pull/82)),
|
||||
NAT-traversal MTU inheritance
|
||||
([#83](https://github.com/jmcorgan/fips/pull/83)).
|
||||
- [@dskvr](https://github.com/dskvr): initial Arch Linux AUR
|
||||
packaging ([#21](https://github.com/jmcorgan/fips/pull/21)) and
|
||||
the AUR publish workflow.
|
||||
- [@SatsAndSports](https://github.com/SatsAndSports): rekey
|
||||
message-1 admit fix on non-accepting transports
|
||||
([#49](https://github.com/jmcorgan/fips/pull/49)),
|
||||
TreeAnnounce semantic validation, gateway test image fix
|
||||
([#69](https://github.com/jmcorgan/fips/pull/69)).
|
||||
- [@andrewheadricke](https://github.com/andrewheadricke): MIPS
|
||||
atomic-ABI portability via `portable_atomic`
|
||||
([#62](https://github.com/jmcorgan/fips/pull/62)).
|
||||
- [@sh1ftred](https://github.com/sh1ftred): Arch packaging namcap
|
||||
fixes ([#63](https://github.com/jmcorgan/fips/pull/63)).
|
||||
- [@oleksky](https://github.com/oleksky): macOS WireGuard companion
|
||||
collaboration on [#51](https://github.com/jmcorgan/fips/pull/51).
|
||||
|
||||
**Issue reports that drove fixes in this release**:
|
||||
|
||||
- [@deavmi](https://github.com/deavmi): MIPS daemon build support
|
||||
([#26](https://github.com/jmcorgan/fips/issues/26)).
|
||||
- [@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)).
|
||||
- [@jcorgan](https://github.com/jmcorgan): release shepherd, spanning-tree
|
||||
and discovery fixes, bloom and identity performance work, antipoison cap
|
||||
change, and testing.
|
||||
|
||||
365
benches/routing_next_hop.rs
Normal file
365
benches/routing_next_hop.rs
Normal file
@@ -0,0 +1,365 @@
|
||||
//! Micro-benchmark quantifying the per-forwarded-packet heap-allocation cost
|
||||
//! of the routing next-hop candidate-assembly path.
|
||||
//!
|
||||
//! `find_next_hop` runs once per forwarded data packet. Its sans-IO core
|
||||
//! assembles a `Vec<Candidate>` by enumerating every peer through the
|
||||
//! `RoutingView` seam: `peer_addrs()` materializes a `Vec<NodeAddr>` of all
|
||||
//! peers, the survivors are snapshotted (each cloning its `TreeCoordinate`),
|
||||
//! and the result is collected into a second `Vec`. This bench measures that
|
||||
//! per-call allocation against a fused zero-alloc reference that iterates the
|
||||
//! peer map directly and borrows coordinates instead of cloning.
|
||||
//!
|
||||
//! Visibility caveat: the production `routing_candidates` / `select_best_candidate`
|
||||
//! / `RoutingView` / `Candidate` are `pub(crate)` (src/proto/routing/core.rs)
|
||||
//! and are not re-exported at the crate root, so an external bench crate cannot
|
||||
//! name them. Rather than change production visibility, this file reproduces
|
||||
//! that path verbatim over the real public `NodeAddr` / `TreeCoordinate` /
|
||||
//! `CoordEntry` / `BloomFilter` types with the same iterator chain and the same
|
||||
//! `HashMap`-backed view the shell uses (src/node/mod.rs NodeRoutingView). The
|
||||
//! allocation behavior is therefore identical to production by construction;
|
||||
//! only the symbol identity differs.
|
||||
|
||||
use std::alloc::{GlobalAlloc, Layout, System};
|
||||
use std::collections::HashMap;
|
||||
use std::hint::black_box;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
use criterion::{BenchmarkId, Criterion, criterion_group, criterion_main};
|
||||
use fips::{BloomFilter, NodeAddr, TreeCoordinate};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Counting global allocator: bumps a process-global counter on every heap
|
||||
// allocation operation (alloc / alloc_zeroed / realloc). Sampled tightly and
|
||||
// single-threaded in `report_allocs` so no unrelated allocations are captured.
|
||||
// ---------------------------------------------------------------------------
|
||||
struct CountingAlloc;
|
||||
|
||||
static ALLOCS: AtomicUsize = AtomicUsize::new(0);
|
||||
|
||||
unsafe impl GlobalAlloc for CountingAlloc {
|
||||
unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
|
||||
ALLOCS.fetch_add(1, Ordering::Relaxed);
|
||||
unsafe { System.alloc(layout) }
|
||||
}
|
||||
unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) {
|
||||
unsafe { System.dealloc(ptr, layout) }
|
||||
}
|
||||
unsafe fn alloc_zeroed(&self, layout: Layout) -> *mut u8 {
|
||||
ALLOCS.fetch_add(1, Ordering::Relaxed);
|
||||
unsafe { System.alloc_zeroed(layout) }
|
||||
}
|
||||
unsafe fn realloc(&self, ptr: *mut u8, layout: Layout, new_size: usize) -> *mut u8 {
|
||||
ALLOCS.fetch_add(1, Ordering::Relaxed);
|
||||
unsafe { System.realloc(ptr, layout, new_size) }
|
||||
}
|
||||
}
|
||||
|
||||
#[global_allocator]
|
||||
static GLOBAL: CountingAlloc = CountingAlloc;
|
||||
|
||||
const PEER_COUNTS: [usize; 4] = [8, 32, 128, 256];
|
||||
/// Fraction of peers whose bloom filter reports the destination reachable.
|
||||
const REACH_NUMERATOR: usize = 1;
|
||||
const REACH_DENOMINATOR: usize = 2;
|
||||
/// Tree depth for synthetic coordinates (self..root), a realistic mesh depth.
|
||||
const COORD_DEPTH: usize = 8;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Reproduction of the pub(crate) routing seam (src/proto/routing/core.rs).
|
||||
// ---------------------------------------------------------------------------
|
||||
trait RoutingView {
|
||||
fn peer_addrs(&self) -> Vec<NodeAddr>;
|
||||
fn peer_may_reach(&self, peer: &NodeAddr, dest: &NodeAddr) -> bool;
|
||||
fn peer_can_send(&self, peer: &NodeAddr) -> bool;
|
||||
fn peer_link_cost(&self, peer: &NodeAddr) -> f64;
|
||||
fn peer_coords(&self, peer: &NodeAddr) -> Option<TreeCoordinate>;
|
||||
}
|
||||
|
||||
struct Candidate {
|
||||
addr: NodeAddr,
|
||||
can_send: bool,
|
||||
link_cost: f64,
|
||||
coords: Option<TreeCoordinate>,
|
||||
}
|
||||
|
||||
/// Verbatim from `routing::routing_candidates` (core.rs). Allocates the
|
||||
/// `peer_addrs` Vec, clones each survivor's coords, and collects into a Vec.
|
||||
fn routing_candidates(rv: &impl RoutingView, dest: &NodeAddr) -> Vec<Candidate> {
|
||||
rv.peer_addrs()
|
||||
.into_iter()
|
||||
.filter(|peer| rv.peer_may_reach(peer, dest))
|
||||
.map(|peer| Candidate {
|
||||
can_send: rv.peer_can_send(&peer),
|
||||
link_cost: rv.peer_link_cost(&peer),
|
||||
coords: rv.peer_coords(&peer),
|
||||
addr: peer,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Verbatim from `routing::select_best_candidate` (core.rs). Pure, no alloc.
|
||||
fn select_best_candidate(
|
||||
candidates: &[Candidate],
|
||||
dest_coords: &TreeCoordinate,
|
||||
my_coords: &TreeCoordinate,
|
||||
) -> Option<NodeAddr> {
|
||||
let my_distance = my_coords.distance_to(dest_coords);
|
||||
let mut best: Option<(&Candidate, f64, usize)> = None;
|
||||
for candidate in candidates {
|
||||
if !candidate.can_send {
|
||||
continue;
|
||||
}
|
||||
let cost = candidate.link_cost;
|
||||
let dist = candidate
|
||||
.coords
|
||||
.as_ref()
|
||||
.map(|pc| pc.distance_to(dest_coords))
|
||||
.unwrap_or(usize::MAX);
|
||||
if dist >= my_distance {
|
||||
continue;
|
||||
}
|
||||
let dominated = match &best {
|
||||
None => true,
|
||||
Some((_, best_cost, best_dist)) => {
|
||||
cost < *best_cost
|
||||
|| (cost == *best_cost && dist < *best_dist)
|
||||
|| (cost == *best_cost
|
||||
&& dist == *best_dist
|
||||
&& candidate.addr < best.as_ref().unwrap().0.addr)
|
||||
}
|
||||
};
|
||||
if dominated {
|
||||
best = Some((candidate, cost, dist));
|
||||
}
|
||||
}
|
||||
best.map(|(candidate, _, _)| candidate.addr)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Bench-local view, HashMap-backed exactly like src/node/mod.rs NodeRoutingView.
|
||||
// ---------------------------------------------------------------------------
|
||||
struct BenchPeer {
|
||||
bloom: BloomFilter,
|
||||
can_send: bool,
|
||||
link_cost: f64,
|
||||
}
|
||||
|
||||
struct BenchView {
|
||||
peers: HashMap<NodeAddr, BenchPeer>,
|
||||
coords: HashMap<NodeAddr, TreeCoordinate>,
|
||||
}
|
||||
|
||||
impl RoutingView for BenchView {
|
||||
fn peer_addrs(&self) -> Vec<NodeAddr> {
|
||||
self.peers.keys().copied().collect()
|
||||
}
|
||||
fn peer_may_reach(&self, peer: &NodeAddr, dest: &NodeAddr) -> bool {
|
||||
self.peers.get(peer).is_some_and(|p| p.bloom.contains(dest))
|
||||
}
|
||||
fn peer_can_send(&self, peer: &NodeAddr) -> bool {
|
||||
self.peers.get(peer).is_some_and(|p| p.can_send)
|
||||
}
|
||||
fn peer_link_cost(&self, peer: &NodeAddr) -> f64 {
|
||||
self.peers.get(peer).map_or(f64::INFINITY, |p| p.link_cost)
|
||||
}
|
||||
fn peer_coords(&self, peer: &NodeAddr) -> Option<TreeCoordinate> {
|
||||
self.coords.get(peer).cloned()
|
||||
}
|
||||
}
|
||||
|
||||
/// Zero-alloc reference: what an iterator/visitor seam would do. Iterates the
|
||||
/// peer map directly, fuses the may_reach + can_send filters, borrows coords
|
||||
/// instead of cloning, and tracks the best hop inline. No Vec, no coord clone.
|
||||
fn resolve_next_hop_zeroalloc(
|
||||
view: &BenchView,
|
||||
dest: &NodeAddr,
|
||||
dest_coords: &TreeCoordinate,
|
||||
my_coords: &TreeCoordinate,
|
||||
) -> Option<NodeAddr> {
|
||||
let my_distance = my_coords.distance_to(dest_coords);
|
||||
let mut best: Option<(NodeAddr, f64, usize)> = None;
|
||||
for (addr, peer) in &view.peers {
|
||||
if !peer.bloom.contains(dest) {
|
||||
continue;
|
||||
}
|
||||
if !peer.can_send {
|
||||
continue;
|
||||
}
|
||||
let cost = peer.link_cost;
|
||||
let dist = view
|
||||
.coords
|
||||
.get(addr)
|
||||
.map(|pc| pc.distance_to(dest_coords))
|
||||
.unwrap_or(usize::MAX);
|
||||
if dist >= my_distance {
|
||||
continue;
|
||||
}
|
||||
let dominated = match &best {
|
||||
None => true,
|
||||
Some((best_addr, best_cost, best_dist)) => {
|
||||
cost < *best_cost
|
||||
|| (cost == *best_cost && dist < *best_dist)
|
||||
|| (cost == *best_cost && dist == *best_dist && *addr < *best_addr)
|
||||
}
|
||||
};
|
||||
if dominated {
|
||||
best = Some((*addr, cost, dist));
|
||||
}
|
||||
}
|
||||
best.map(|(addr, _, _)| addr)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Scenario construction.
|
||||
// ---------------------------------------------------------------------------
|
||||
fn addr(tag: u8, i: u16) -> NodeAddr {
|
||||
let mut b = [0u8; 16];
|
||||
b[0] = tag;
|
||||
b[1..3].copy_from_slice(&i.to_le_bytes());
|
||||
NodeAddr::from_bytes(b)
|
||||
}
|
||||
|
||||
/// A depth-`COORD_DEPTH` coordinate whose leaf is `leaf`, sharing a fixed
|
||||
/// interior path and root with `shared_tag`. Peers built with the dest's
|
||||
/// shared_tag sit close to the destination (distance 2); a distinct shared_tag
|
||||
/// sits far (near the root), modeling our own position.
|
||||
fn coord(leaf: NodeAddr, shared_tag: u8) -> TreeCoordinate {
|
||||
let mut path = Vec::with_capacity(COORD_DEPTH);
|
||||
path.push(leaf);
|
||||
for level in 1..(COORD_DEPTH - 1) {
|
||||
path.push(addr(shared_tag, level as u16));
|
||||
}
|
||||
path.push(addr(9, 0)); // common root
|
||||
TreeCoordinate::from_addrs(path).expect("valid coord path")
|
||||
}
|
||||
|
||||
struct Scenario {
|
||||
view: BenchView,
|
||||
dest: NodeAddr,
|
||||
dest_coords: TreeCoordinate,
|
||||
my_coords: TreeCoordinate,
|
||||
}
|
||||
|
||||
impl Scenario {
|
||||
fn new(n: usize) -> Self {
|
||||
let dest = addr(2, 0);
|
||||
// Destination path uses interior tag 4; peers reuse tag 4 so survivors
|
||||
// are close to the destination. Our own coords use tag 5 (far).
|
||||
let dest_coords = coord(dest, 4);
|
||||
let my_coords = coord(addr(6, 0), 5);
|
||||
|
||||
let mut peers = HashMap::new();
|
||||
let mut coords = HashMap::new();
|
||||
for i in 0..n {
|
||||
let paddr = addr(1, i as u16);
|
||||
let mut bloom = BloomFilter::new();
|
||||
// Realistic fill: a handful of unrelated reachable addrs.
|
||||
for f in 0..4u16 {
|
||||
bloom.insert(&addr(7, i as u16 * 4 + f));
|
||||
}
|
||||
// A controlled fraction advertise the destination as reachable.
|
||||
if (i % REACH_DENOMINATOR) < REACH_NUMERATOR {
|
||||
bloom.insert(&dest);
|
||||
}
|
||||
peers.insert(
|
||||
paddr,
|
||||
BenchPeer {
|
||||
bloom,
|
||||
can_send: true,
|
||||
link_cost: 1.0 + (i as f64) * 0.01,
|
||||
},
|
||||
);
|
||||
// Peers share the destination's interior path (tag 4) → close.
|
||||
coords.insert(paddr, coord(paddr, 4));
|
||||
}
|
||||
|
||||
Self {
|
||||
view: BenchView { peers, coords },
|
||||
dest,
|
||||
dest_coords,
|
||||
my_coords,
|
||||
}
|
||||
}
|
||||
|
||||
fn survivors(&self) -> usize {
|
||||
self.view
|
||||
.peers
|
||||
.values()
|
||||
.filter(|p| p.bloom.contains(&self.dest))
|
||||
.count()
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Allocation-per-call report (printed once, before criterion timing).
|
||||
// ---------------------------------------------------------------------------
|
||||
fn count_allocs<T>(iters: usize, mut f: impl FnMut() -> T) -> f64 {
|
||||
for _ in 0..8 {
|
||||
black_box(f());
|
||||
}
|
||||
let start = ALLOCS.load(Ordering::Relaxed);
|
||||
for _ in 0..iters {
|
||||
black_box(f());
|
||||
}
|
||||
let end = ALLOCS.load(Ordering::Relaxed);
|
||||
(end - start) as f64 / iters as f64
|
||||
}
|
||||
|
||||
fn report_allocs() {
|
||||
const ITERS: usize = 2000;
|
||||
println!("\n=== allocations per call (heap alloc ops: alloc+alloc_zeroed+realloc) ===");
|
||||
println!(
|
||||
"{:>6} {:>10} {:>16} {:>16}",
|
||||
"peers", "survivors", "current/call", "zero-alloc/call"
|
||||
);
|
||||
for &n in &PEER_COUNTS {
|
||||
let s = Scenario::new(n);
|
||||
let survivors = s.survivors();
|
||||
let current = count_allocs(ITERS, || {
|
||||
let cands = routing_candidates(&s.view, &s.dest);
|
||||
select_best_candidate(&cands, &s.dest_coords, &s.my_coords)
|
||||
});
|
||||
let zero = count_allocs(ITERS, || {
|
||||
resolve_next_hop_zeroalloc(&s.view, &s.dest, &s.dest_coords, &s.my_coords)
|
||||
});
|
||||
println!("{n:>6} {survivors:>10} {current:>16.2} {zero:>16.2}");
|
||||
}
|
||||
println!();
|
||||
}
|
||||
|
||||
fn bench_next_hop(c: &mut Criterion) {
|
||||
report_allocs();
|
||||
|
||||
let mut group = c.benchmark_group("find_next_hop");
|
||||
for &n in &PEER_COUNTS {
|
||||
let scenario = Scenario::new(n);
|
||||
group.bench_with_input(BenchmarkId::new("current_alloc", n), &n, |b, _| {
|
||||
b.iter(|| {
|
||||
let cands = routing_candidates(&scenario.view, &scenario.dest);
|
||||
black_box(select_best_candidate(
|
||||
&cands,
|
||||
&scenario.dest_coords,
|
||||
&scenario.my_coords,
|
||||
))
|
||||
});
|
||||
});
|
||||
group.bench_with_input(BenchmarkId::new("zero_alloc_ref", n), &n, |b, _| {
|
||||
b.iter(|| {
|
||||
black_box(resolve_next_hop_zeroalloc(
|
||||
&scenario.view,
|
||||
&scenario.dest,
|
||||
&scenario.dest_coords,
|
||||
&scenario.my_coords,
|
||||
))
|
||||
});
|
||||
});
|
||||
}
|
||||
group.finish();
|
||||
}
|
||||
|
||||
criterion_group! {
|
||||
name = benches;
|
||||
config = Criterion::default().sample_size(50);
|
||||
targets = bench_next_hop
|
||||
}
|
||||
criterion_main!(benches);
|
||||
143
docs/branching.md
Normal file
143
docs/branching.md
Normal file
@@ -0,0 +1,143 @@
|
||||
# FIPS Branching and Merging Strategy
|
||||
|
||||
<!-- markdownlint-disable MD013 -->
|
||||
|
||||
This document explains how the three long-lived branches relate, when
|
||||
to target each one, and how merges propagate fixes and features. For
|
||||
the day-to-day "how do I send a PR" workflow, see
|
||||
[CONTRIBUTING.md](../CONTRIBUTING.md).
|
||||
|
||||
## Branch Structure
|
||||
|
||||
Three long-lived branches track parallel development streams:
|
||||
|
||||
```text
|
||||
next ──●──●──●──●──●──────────────●──●── (wire-format-breaking work)
|
||||
\ /
|
||||
master ────●──●──●──●──●──●──────●──●──●── (compatible features, latest release line)
|
||||
\ /
|
||||
maint ────────●──●──●──●──●────────────── (bug fixes for the latest release)
|
||||
```
|
||||
|
||||
### maint
|
||||
|
||||
- Reset to each minor release tag at release time
|
||||
- Accepts only bug fixes for functionality that shipped in the
|
||||
latest release
|
||||
- No new features, no API changes, no wire-format changes
|
||||
- Patch releases tag from here (e.g., `v0.3.1`, `v0.3.2`)
|
||||
- Periodically merged forward into `master` so fixes propagate
|
||||
|
||||
### master
|
||||
|
||||
- Compatible development for the next feature release
|
||||
- Multiple feature releases may ship from master (`v0.4.0`, `v0.5.0`)
|
||||
before `next` promotes
|
||||
- No wire-format breaking changes; no API breaks
|
||||
- Receives merges from `maint` so released-line fixes flow forward
|
||||
- Periodically merged forward into `next`
|
||||
|
||||
### next
|
||||
|
||||
- Accumulates work that breaks wire format, API, or compatibility
|
||||
- Receives merges from `master` so it stays current with bug fixes
|
||||
and compatible feature work
|
||||
- Cargo version on `next` is the expected release version with a
|
||||
`-dev` suffix, updated if `master` ships additional minor
|
||||
releases first
|
||||
- Becomes the new `master` at the next breaking release; at the same
|
||||
point the old `master` becomes the new `maint`
|
||||
|
||||
## Versioning
|
||||
|
||||
While the project is in the `0.x` era, semver treats minor bumps as
|
||||
potentially breaking. Both `master` and `next` bump the minor version;
|
||||
the distinction between compatible and breaking is captured in the
|
||||
changelog and in which branch the work landed on.
|
||||
|
||||
The `-dev` suffix in `Cargo.toml` indicates an unreleased development
|
||||
state on the branch.
|
||||
|
||||
## Merge Direction
|
||||
|
||||
Fixes and features flow in **one direction only**: `maint → master → next`.
|
||||
Never merge backward (`next` into `master`, or `master` into `maint`).
|
||||
|
||||
```text
|
||||
maint ──→ master ──→ next
|
||||
```
|
||||
|
||||
This guarantees:
|
||||
|
||||
- Bug fixes shipped in a release reach all subsequent branches
|
||||
- Compatible features reach `next`
|
||||
- Wire-format-breaking work stays isolated on `next` until release
|
||||
|
||||
If you submit a PR on `next` that should also be on master or maint
|
||||
(rare, since the criteria for needing it on multiple branches are
|
||||
usually mutually exclusive), the PR stays on its target; the
|
||||
maintainer either backports as a separate commit on the upstream
|
||||
branch or asks you to.
|
||||
|
||||
## Choosing a Branch for Your PR
|
||||
|
||||
Pick the branch that matches the scope of your change:
|
||||
|
||||
| Your change | Target branch | Why |
|
||||
| --- | --- | --- |
|
||||
| Bug fix in a feature that shipped in the latest release | `maint` | Fix forward-merges to `master` and `next` |
|
||||
| Bug fix in code added on `master` since the last release (not in any released version) | `master` | The released v0.x.y line is unaffected, so `maint` does not need the change |
|
||||
| Bug fix in code added on `next` (wire-format-breaking work) | `next` | The bug only exists where the breaking work exists |
|
||||
| New feature that does not break wire format or API | `master` | Becomes part of the next compatible release |
|
||||
| Wire-format breaking change, API break, or fundamental protocol shape change | `next` | Stays isolated until the next forklift release |
|
||||
| Documentation, CI, or contributor-facing changes | `maint` if they apply to released material, else `master` | Forward-merges propagate naturally |
|
||||
|
||||
If you are not sure, ask in the related issue. The safest defaults
|
||||
are `master` for new features and `maint` for bug fixes; the
|
||||
maintainer will retarget the PR if needed.
|
||||
|
||||
## Release Workflow
|
||||
|
||||
### Bug fix release (from `maint`)
|
||||
|
||||
1. Fix on `maint`
|
||||
2. Bump patch version, tag (e.g., `v0.3.1`)
|
||||
3. Merge `maint` into `master`
|
||||
4. Merge `master` into `next`
|
||||
|
||||
### Compatible feature release (from `master`)
|
||||
|
||||
1. Finalize features on `master`
|
||||
2. Merge `maint` into `master` to pick up any pending fixes
|
||||
3. Set version, tag (e.g., `v0.4.0`)
|
||||
4. Reset `maint` to the new tag
|
||||
5. Bump `master` to the next `-dev` version
|
||||
6. Merge `master` into `next`
|
||||
|
||||
### Breaking release (from `next`)
|
||||
|
||||
1. Finalize features on `next`
|
||||
2. Merge `master` into `next` to pick up pending fixes and features
|
||||
3. Assign version as the next minor after `master`'s last release, tag
|
||||
4. `master` becomes the new `maint`
|
||||
5. `next` becomes the new `master`
|
||||
6. Create a new `next` branch from `master`
|
||||
|
||||
## Practical Guidelines
|
||||
|
||||
- **Commit to the appropriate branch for the scope of the change.**
|
||||
Do not commit bug fixes to `master` when they apply to the latest
|
||||
release — put them on `maint` and let the forward-merge propagate.
|
||||
- **Feature branches base off the long-lived branch they target.**
|
||||
Create with `git checkout -b my-feature maint` (or `master` or
|
||||
`next`), not `git checkout -b my-feature origin/maint`. The
|
||||
`origin/`-prefixed form auto-sets the new branch's upstream to
|
||||
the source ref, which can cause `git push` to land on the wrong
|
||||
ref under some configurations.
|
||||
- **When in doubt about whether a change is compatible**, target
|
||||
`next`. The maintainer can advise on retargeting.
|
||||
- **Resolve merge conflicts on the receiving branch**, preserving
|
||||
both the inherited fix and the new development.
|
||||
- **PRs are merged via squash-merge.** One logical change per PR
|
||||
becomes one commit on the destination branch, making bisect
|
||||
clean across the integration suite.
|
||||
@@ -221,9 +221,11 @@ discovery protocol, and error-recovery integration view live in
|
||||
## Transport Abstraction
|
||||
|
||||
FIPS treats the communication medium as a pluggable component. UDP,
|
||||
TCP, raw Ethernet, Tor, and BLE all implement the same small datagram
|
||||
interface (send, receive, report MTU) and feed peers into a single FMP
|
||||
routing layer; radio and serial transports are in the planned set.
|
||||
TCP, raw Ethernet, Tor, BLE, and Nym all implement the same small
|
||||
datagram interface (send, receive, report MTU) and feed peers into a
|
||||
single FMP routing layer; radio and serial transports are in the
|
||||
planned set. Nym (an outbound-only mixnet transport) and Tor are
|
||||
privacy-oriented deployment modes rather than failover paths.
|
||||
Multi-transport nodes bridge between networks transparently. The
|
||||
transport-layer specification — including per-transport categories,
|
||||
the trait surface, the connection model, and implementation status —
|
||||
|
||||
@@ -180,7 +180,7 @@ network with no overlap (excluding the node itself at the split point).
|
||||
|
||||
All peers — including non-tree mesh shortcuts — still **receive**
|
||||
FilterAnnounce messages and **store** received filters locally. These
|
||||
stored filters are consulted during routing (step 3 of `find_next_hop()`)
|
||||
stored filters are consulted during routing (step 4 of `find_next_hop()`)
|
||||
for single-hop shortcut discovery. However, mesh peer filters contain
|
||||
only the mesh peer's own tree-propagated information, not transitive
|
||||
entries from the broader network.
|
||||
@@ -339,25 +339,35 @@ positions that folding produces.
|
||||
|
||||
## Mesh Size Estimation
|
||||
|
||||
Each filter's saturation can be inverted into an estimated entry count
|
||||
A filter's saturation can be inverted into an estimated entry count
|
||||
via the standard formula `n ≈ -(m/k) · ln(1 − X/m)`, where `m` is the
|
||||
filter size in bits, `k` is the hash count, and `X` is the population
|
||||
count. Combining the parent's inbound filter with the children's
|
||||
inbound filters gives an estimate of the whole network: parent + each
|
||||
child's subtree are disjoint by construction, and adding 1 for the
|
||||
node itself yields the total. The result is cached on the node and
|
||||
exposed through the control socket and `fipstop` dashboard.
|
||||
count. Rather than estimate per-filter and sum, the node first builds
|
||||
an **OR-union of every connected peer's inbound filter** — all routing
|
||||
peers, including cross-links, not just the tree parent and children —
|
||||
inserts its own address into the union, and inverts the cardinality
|
||||
**once on the resulting union**. Because filter propagation is
|
||||
split-horizon (each outgoing filter excludes the peer it routes back
|
||||
to), every routing peer advertises a near-complete "whole mesh minus
|
||||
my subtree" view, so the union covers the network. OR-ing is
|
||||
idempotent, so overlapping bits deduplicate instead of over-counting,
|
||||
and folding in all peers rather than only the tree neighborhood damps
|
||||
the count flap on a parent switch (the cross-links still carry the
|
||||
upward coverage) and removes any dependence on tree-declaration cache
|
||||
freshness. The result is cached on the node and exposed through the
|
||||
control socket and `fipstop` dashboard. (See `compute_mesh_size()` in
|
||||
`src/node/mod.rs`.)
|
||||
|
||||
The estimator refuses to produce a value when any contributing filter
|
||||
is above the antipoison FPR cap (`node.bloom.max_inbound_fpr`,
|
||||
default `0.05`); a partial aggregate would silently underestimate.
|
||||
default `0.20`); a partial aggregate would silently underestimate.
|
||||
Consumers handle the resulting `None` by displaying an "unknown"
|
||||
state rather than a misleading number.
|
||||
|
||||
## Antipoison: Inbound FPR Cap
|
||||
|
||||
Inbound `FilterAnnounce` payloads are checked against
|
||||
`node.bloom.max_inbound_fpr` (default `0.05`). Filters whose
|
||||
`node.bloom.max_inbound_fpr` (default `0.20`). Filters whose
|
||||
estimated false positive rate exceeds the cap are dropped silently
|
||||
(no NACK on the wire) — they would otherwise inflate downstream
|
||||
candidate evaluation cost without contributing useful discrimination.
|
||||
@@ -378,7 +388,7 @@ as described above.
|
||||
| 500ms rate limiting | **Implemented** |
|
||||
| FilterAnnounce gossip (all peers) | **Implemented** |
|
||||
| Filter cardinality logging | **Implemented** |
|
||||
| Mesh size estimation (parent + children + 1) | **Implemented** |
|
||||
| Mesh size estimation (OR-union of peer filters) | **Implemented** |
|
||||
| Inbound FPR cap (antipoison) | **Implemented** |
|
||||
| Size class negotiation | Future direction |
|
||||
| Folding support | Future direction |
|
||||
|
||||
@@ -218,7 +218,7 @@ involving the DNS proxy or the pool.
|
||||
|
||||
### Virtual IP Pool
|
||||
|
||||
The pool allocates IPv6 addresses from a configured CIDR (default
|
||||
The pool allocates IPv6 addresses from a required CIDR (commonly
|
||||
`fd01::/112`). Each address maps to one mesh destination, keyed by
|
||||
`NodeAddr` rather than by hostname — different `.fips` aliases for
|
||||
the same node share a virtual IP. Address 0 (the network-equivalent)
|
||||
|
||||
@@ -302,6 +302,34 @@ alternative — running under a dedicated unprivileged service
|
||||
account with the capability granted on the binary — see
|
||||
[../how-to/run-as-unprivileged-user.md](../how-to/run-as-unprivileged-user.md).
|
||||
|
||||
### App-Owned TUN (embedded hosts)
|
||||
|
||||
On platforms where FIPS is embedded rather than run as a daemon — notably
|
||||
Android, where the `VpnService` owns the TUN fd and the app has no
|
||||
`CAP_NET_ADMIN` — FIPS does not create `fips0` itself. Instead the embedder owns
|
||||
the fd and exchanges IPv6 packet bytes with FIPS over channels.
|
||||
|
||||
`Node::enable_app_owned_tun()` sets this up. It is called after `Node::new` and
|
||||
before `start()` (and before the node is moved into a background task), mirroring
|
||||
`control_read_handle()`, and returns two app-side channel ends:
|
||||
|
||||
- **app → mesh** — the embedder pushes IPv6 packets read from its fd into
|
||||
`app_outbound_tx`. These are drained by `run_rx_loop` into `handle_tun_outbound`
|
||||
and routed exactly as the Reader Thread's output would be.
|
||||
- **mesh → app** — inbound mesh traffic on port 256 is reconstructed and written
|
||||
to the node's `tun_tx` (the same sink the Writer Thread reads); the embedder
|
||||
pulls from `app_inbound_rx` and writes to its fd.
|
||||
|
||||
With the channels installed, `start()` skips system-TUN creation (it gates on
|
||||
`tun_tx` being unset), so FIPS does no `CAP_NET_ADMIN` operations.
|
||||
|
||||
Because packets enter via `app_outbound_tx` rather than the Reader Thread, they
|
||||
**bypass `handle_tun_packet`** — the `fd00::/8` destination filter, the ICMPv6
|
||||
Destination Unreachable for off-mesh dests (see [Reader Thread](#reader-thread)),
|
||||
and the [TUN-Side TCP MSS Clamping](#tun-side-tcp-mss-clamping). The embedder is
|
||||
therefore responsible for routing only `fd00::/8` to its TUN (so only mesh-bound
|
||||
packets arrive) and for clamping TCP MSS on outbound SYNs.
|
||||
|
||||
## Implementation Status
|
||||
|
||||
| Feature | Status |
|
||||
|
||||
@@ -100,6 +100,11 @@ inter-frame processing delays inflate spin bit RTT measurements
|
||||
unpredictably. Timestamp-echo from ReceiverReports (with dwell-time
|
||||
compensation) is the sole SRTT source.
|
||||
|
||||
Duplicate or regressed ReceiverReports are ignored before any RTT, loss,
|
||||
goodput, or ETX update. If receiver-side dwell time exceeds the wire
|
||||
field, the report keeps its counters but sends a zero timestamp echo so
|
||||
the sender cannot form an invalid RTT sample.
|
||||
|
||||
The spin bit lives in the link-layer FMP inner header, so this
|
||||
mechanism applies to link-layer MMP only. Session-layer MMP carries
|
||||
its spin bit in the FSP encrypted inner header but uses it the same
|
||||
|
||||
@@ -251,7 +251,7 @@ would later drop.
|
||||
The adapter integrates with the MTU subsystem rather than owning it.
|
||||
The "why we clamp and what `max_mss` means" lives here in the MTU
|
||||
design; the "how the clamp is implemented at the TUN" lives in the
|
||||
[IPv6 adapter](fips-ipv6-adapter.md#tcp-mss-clamping) doc.
|
||||
[IPv6 adapter](fips-ipv6-adapter.md#tun-side-tcp-mss-clamping) doc.
|
||||
|
||||
## ICMP Packet Too Big
|
||||
|
||||
|
||||
@@ -1,4 +1,13 @@
|
||||
# FIPS Nostr-Mediated Discovery and NAT Traversal
|
||||
# FIPS Discovery: Nostr-Mediated and LAN/mDNS
|
||||
|
||||
FIPS nodes have two discovery mechanisms beyond the static `peers[]`
|
||||
list. The bulk of this document describes **Nostr-mediated discovery**,
|
||||
which works across the internet using public Nostr relays as a
|
||||
signaling channel and can punch through UDP NAT. A second, much
|
||||
simpler mechanism — **LAN/mDNS discovery** — finds peers on the same
|
||||
local link with no relay, STUN, or NAT traversal at all; it is
|
||||
described in its own section near the end. The two are independent: a
|
||||
node can enable either, both, or neither.
|
||||
|
||||
Nostr-mediated discovery lets FIPS nodes find each other, and if
|
||||
necessary, punch through UDP NAT, using public Nostr relays as the
|
||||
@@ -377,6 +386,182 @@ semaphore and replay-cache layers downstream.
|
||||
advert says "I am npub X at 1.2.3.4:5678" but whose FMP handshake
|
||||
presents a different static key is rejected at the mesh layer.
|
||||
|
||||
## LAN/mDNS discovery
|
||||
|
||||
LAN discovery is a separate, link-local discovery mechanism that finds
|
||||
peers on the same broadcast domain using mDNS / DNS-SD
|
||||
([RFC 6762](https://www.rfc-editor.org/rfc/rfc6762) /
|
||||
[RFC 6763](https://www.rfc-editor.org/rfc/rfc6763)). Unlike
|
||||
Nostr-mediated discovery, it contacts no relay, runs no STUN
|
||||
observation, and performs no NAT traversal: an endpoint learned from a
|
||||
LAN advert is by construction routable from the consumer's own link.
|
||||
The result is sub-second peer pairing on the same LAN.
|
||||
|
||||
It is unrelated to the "LAN candidate" terminology used in the
|
||||
NAT-traversal sections above (which refers to a host's own
|
||||
locally-bound address offered as a hole-punch candidate). LAN/mDNS
|
||||
discovery is a distinct subsystem under `src/discovery/lan/`.
|
||||
|
||||
### Role
|
||||
|
||||
LAN discovery adds two capabilities, both confined to the local link:
|
||||
|
||||
- **Advertising.** The node publishes a `_fips._udp.local.` DNS-SD
|
||||
service advert carrying its `npub`, its protocol version, and (if
|
||||
configured) a discovery scope. The advert is multicast on the local
|
||||
link only; it does not leave the broadcast domain unless the
|
||||
operator's network bridges mDNS.
|
||||
- **Browsing.** The node concurrently browses for the same service
|
||||
type, learns the endpoints of other FIPS nodes on the link, and
|
||||
initiates a normal FMP link to each newly-seen peer.
|
||||
|
||||
The mDNS service type is `_fips._udp.local.`
|
||||
(`src/discovery/lan/mod.rs:45`). Per RFC 6763 the `_udp` label denotes
|
||||
the IP transport used for the advert, not the FIPS upper protocol —
|
||||
both UDP and TCP FIPS endpoints announce under the same service type
|
||||
because the link-layer handshake travels over UDP either way. (In
|
||||
practice LAN discovery dials only over a UDP transport; see the
|
||||
handshake subsection.)
|
||||
|
||||
### When to use it
|
||||
|
||||
- **You run several FIPS nodes on one LAN** (a lab bench, an office
|
||||
segment, a home network) and want them to find each other without
|
||||
hand-maintaining `peers[]` blocks or standing up Nostr discovery.
|
||||
- **You want the lowest-latency pairing path.** Same-link pairing
|
||||
completes in well under a second with no relay round-trip.
|
||||
|
||||
Skip it when nodes are not on a shared broadcast domain (mDNS does not
|
||||
cross routed boundaries), or when you do not want the node to multicast
|
||||
its identity on the local link. LAN discovery is **opt-in and disabled
|
||||
by default**, so doing nothing leaves it off.
|
||||
|
||||
### How it works
|
||||
|
||||
The LAN discovery runtime (`src/discovery/lan/mod.rs`) is started
|
||||
during node initialization when `node.discovery.lan.enabled` is true.
|
||||
It is independent of Nostr discovery and runs even when Nostr is
|
||||
disabled (`src/node/lifecycle.rs:1159-1162`). Startup requires an
|
||||
operational UDP transport: the node advertises the port of its
|
||||
lowest-`TransportId` operational, non-bootstrap UDP transport, chosen
|
||||
deterministically so the advertised port is stable across restarts
|
||||
(`src/node/lifecycle.rs:1169-1180`). If no such port exists, the
|
||||
runtime returns `NoAdvertisedPort` and LAN discovery does not start
|
||||
(`src/discovery/lan/mod.rs:156-158`).
|
||||
|
||||
The runtime does two things concurrently:
|
||||
|
||||
1. **Responder.** It registers a DNS-SD service with instance name
|
||||
`fips-<first-16-chars-of-npub>` and a TXT record carrying the keys
|
||||
below. `mdns-sd`'s address auto-detection appends every non-loopback
|
||||
interface address, with `127.0.0.1` seeded so same-host peers and
|
||||
integration tests can still resolve the advert
|
||||
(`src/discovery/lan/mod.rs:182-203`).
|
||||
2. **Browser.** A background pump receives `ServiceResolved` events for
|
||||
the same service type. For each resolved advert it extracts the
|
||||
`npub` and `scope` TXT values, drops adverts that echo the node's own
|
||||
npub, drops cross-scope adverts (see scope filtering), drops records
|
||||
without an `npub`, and surfaces one `LanDiscoveredPeer` per routable
|
||||
interface address (`src/discovery/lan/mod.rs:212-299`). IPv6
|
||||
unicast link-local addresses without an interface scope id are
|
||||
skipped, since they cannot be dialed unambiguously
|
||||
(`src/discovery/lan/mod.rs:348-365`).
|
||||
|
||||
The TXT record carries three keys (`src/discovery/lan/mod.rs:47-55`):
|
||||
|
||||
| TXT key | Contents |
|
||||
| --- | --- |
|
||||
| `npub` | bech32-encoded npub of the advertising node |
|
||||
| `scope` | the node's discovery scope, if one is configured (omitted otherwise) |
|
||||
| `v` | FIPS protocol version (the same `PROTOCOL_VERSION` used by the Nostr advert) |
|
||||
|
||||
Once per node tick, the node drains browser events and acts on them in
|
||||
`poll_lan_discovery()` (`src/node/lifecycle.rs:907`, called from
|
||||
`src/node/dataplane/rx_loop.rs:266`). For each discovered peer it finds
|
||||
a UDP transport whose family matches the peer address, parses the
|
||||
`npub` into a `PeerIdentity`, skips peers it is already connected to or
|
||||
currently connecting to, and otherwise initiates a connection.
|
||||
|
||||
### Handshake: Noise IK
|
||||
|
||||
LAN-discovered peers are dialed through the standard FMP outbound link
|
||||
path. `poll_lan_discovery()` calls `initiate_connection()`
|
||||
(`src/node/lifecycle.rs:380`), which, for connectionless transports
|
||||
such as UDP, allocates a link and **starts the Noise IK handshake**
|
||||
(documented at `src/node/lifecycle.rs:373-374`). This is the same
|
||||
link-layer handshake used by every other FMP connection — IK at the
|
||||
link layer per the FIPS architecture — not a different pattern for LAN
|
||||
peers.
|
||||
|
||||
The mDNS advert is **unauthenticated**: anyone on the link can
|
||||
multicast a TXT claiming any `npub`. Identity is proven end-to-end by
|
||||
the Noise IK handshake against the observed endpoint. A spoofed advert
|
||||
carrying another node's npub fails the handshake — the impostor does
|
||||
not hold the matching static key — and the half-open link is dropped.
|
||||
The mDNS advert is therefore a routing hint, never an identity
|
||||
assertion, exactly as a Nostr advert is treated (a successful contact
|
||||
is not trusted until FMP's Noise IK handshake completes).
|
||||
|
||||
> Note: a stale source doc-comment at `src/node/lifecycle.rs:904-906`
|
||||
> describes this path as a "Noise XX" handshake. That comment is
|
||||
> inaccurate — the path uses Noise IK as described above. The comment
|
||||
> is flagged for a separate source fix and does not reflect actual
|
||||
> behavior.
|
||||
|
||||
### Scope filtering
|
||||
|
||||
When a discovery scope is configured, the advert carries it in the
|
||||
`scope` TXT entry and the browser surfaces only peers whose advert
|
||||
carries a matching scope. Nodes on the same physical LAN but configured
|
||||
for different mesh networks therefore do not cross-feed each other.
|
||||
|
||||
The scope is resolved by `lan_discovery_scope()`
|
||||
(`src/node/lifecycle.rs:880-902`): the explicit
|
||||
`node.discovery.lan.scope`, if non-empty, is used directly. Otherwise
|
||||
the node falls back to deriving a scope from the Nostr discovery `app`
|
||||
tag (stripping the `fips-overlay-v1:` prefix when present). This lets
|
||||
an application keep its public, relay-visible Nostr `app` tag generic
|
||||
while still isolating LAN discovery per private network, or share one
|
||||
value across both. A node with no scope on either side surfaces all
|
||||
adverts it sees on the link.
|
||||
|
||||
### Configuration
|
||||
|
||||
LAN discovery is configured under `node.discovery.lan.*`
|
||||
(`src/config/node.rs:222-227`, `src/discovery/lan/mod.rs:88-129`):
|
||||
|
||||
| Key | Type | Default | Meaning |
|
||||
| --- | --- | --- | --- |
|
||||
| `node.discovery.lan.enabled` | bool | `false` | Master switch. LAN discovery is opt-in; default-off avoids an unexpected per-link identity multicast on upgrade. |
|
||||
| `node.discovery.lan.service_type` | string | `_fips._udp.local.` | DNS-SD service type. Overridable mainly so integration tests can isolate multiple services on one loopback interface. |
|
||||
| `node.discovery.lan.scope` | string (optional) | unset | Application/network scope carried in the LAN-only `scope` TXT record. Kept deliberately separate from the public Nostr `app` tag. When unset, the scope falls back to the derived Nostr `app` value. |
|
||||
|
||||
The identity surface published over mDNS (`npub`, version, optional
|
||||
scope) is a strict subset of what `nostr.advertise` already publishes
|
||||
publicly, so enabling LAN discovery adds no marginal privacy cost
|
||||
beyond making the node's presence observable on its own local link.
|
||||
|
||||
### Relationship to Nostr discovery
|
||||
|
||||
The two mechanisms are complementary and independent:
|
||||
|
||||
| | Nostr-mediated | LAN/mDNS |
|
||||
| --- | --- | --- |
|
||||
| Reach | Internet-wide, via relays | Same broadcast domain only |
|
||||
| Signaling channel | Public Nostr relays | mDNS multicast on the local link |
|
||||
| NAT traversal | STUN + UDP hole-punch for `udp:nat` peers | None — endpoint is link-routable by construction |
|
||||
| Identity carrier | signed kind 37195 advert (authenticated at publish) | unauthenticated mDNS TXT (routing hint only) |
|
||||
| Identity proof | FMP Noise IK on the connection | FMP Noise IK on the connection |
|
||||
| Default | disabled (`nostr.enabled: false`) | disabled (`lan.enabled: false`) |
|
||||
| Scope key | `app` tag (public) | `scope` TXT (link-local), falls back to `app` |
|
||||
|
||||
Both ultimately converge on the same trust boundary: discovery only
|
||||
supplies candidate endpoints, and no peer is trusted until FMP's Noise
|
||||
IK handshake confirms the claimed identity. A node may run both at
|
||||
once — for example, advertising globally over Nostr while also pairing
|
||||
instantly with same-LAN peers — with no interaction between the two
|
||||
beyond the shared scope fallback.
|
||||
|
||||
## See also
|
||||
|
||||
- [../how-to/enable-nostr-discovery.md](../how-to/enable-nostr-discovery.md)
|
||||
|
||||
@@ -120,6 +120,7 @@ for internet connectivity:
|
||||
| UDP/IP | host:port | 1280–1472 | Unreliable | Primary internet transport |
|
||||
| TCP/IP | host:port | Stream | Reliable | Requires length-prefix framing |
|
||||
| Tor | .onion | Stream | Reliable | High latency, strong anonymity |
|
||||
| Nym | host:port | Stream | Reliable | Mixnet, outbound-only, strong anonymity |
|
||||
|
||||
**Shared medium transports** operate over broadcast- or multicast-capable
|
||||
media:
|
||||
@@ -190,6 +191,7 @@ proceed.
|
||||
| --------- | ---------------- |
|
||||
| TCP/IP | TCP three-way handshake |
|
||||
| Tor | Circuit establishment (typically 10–60s, default timeout 120s) |
|
||||
| Nym | SOCKS5 connect through mixnet (minutes possible, default timeout 300s) |
|
||||
| BLE | L2CAP CoC or GATT connection |
|
||||
| Serial | Physical connection (static) |
|
||||
|
||||
@@ -260,7 +262,7 @@ UDP (1500 vs 1472 MTU).
|
||||
- **No IP dependency**: Operates below the IP layer. Nodes on the same
|
||||
Ethernet segment can communicate without IP addresses or routing
|
||||
infrastructure
|
||||
- **Broadcast discovery**: Nodes discover each other via periodic beacon
|
||||
- **Broadcast neighbor detection**: Nodes discover each other via periodic beacon
|
||||
broadcasts on the shared medium, with no static peer configuration required
|
||||
- **Higher MTU**: Standard Ethernet frames carry 1500 bytes of payload,
|
||||
yielding an effective FIPS MTU of 1499 after the frame type prefix
|
||||
@@ -291,7 +293,7 @@ socket.
|
||||
| Addressing | 6-byte MAC address |
|
||||
| Platform | Linux only (`CAP_NET_RAW` required) |
|
||||
|
||||
### Beacon Discovery
|
||||
### Neighbor Beacons
|
||||
|
||||
Ethernet nodes discover peers via broadcast beacons sent to
|
||||
ff:ff:ff:ff:ff:ff. Each beacon is a 34-byte frame containing the sender's
|
||||
@@ -299,7 +301,7 @@ x-only public key. Receiving nodes extract the MAC source address from the
|
||||
frame and the public key from the payload, then report the discovered peer
|
||||
to FMP.
|
||||
|
||||
Four configuration flags control discovery behavior — `discovery`
|
||||
Four configuration flags control neighbor behavior — `listen`
|
||||
(listen for beacons), `announce` (broadcast beacons), `auto_connect`
|
||||
(initiate handshakes to discovered peers), and `accept_connections`
|
||||
(accept inbound handshakes). The flag table and per-flag defaults
|
||||
@@ -308,13 +310,13 @@ under `transports.ethernet.*`.
|
||||
|
||||
A typical discoverable node sets `announce`, `auto_connect`, and
|
||||
`accept_connections` all true. A passive listener uses just
|
||||
`discovery: true` to observe the network without announcing itself.
|
||||
`listen: true` to observe the network without announcing itself.
|
||||
|
||||
### WiFi Compatibility
|
||||
|
||||
WiFi interfaces in infrastructure (managed) mode work transparently for
|
||||
unicast — the mac80211 subsystem handles frame translation between 802.11
|
||||
and 802.3. Broadcast beacon discovery is unreliable in managed mode because
|
||||
and 802.3. Broadcast neighbor detection is unreliable in managed mode because
|
||||
access points commonly isolate clients from each other's broadcast traffic.
|
||||
|
||||
Startup logging:
|
||||
@@ -599,6 +601,120 @@ SOCKS5-level errors, MTU rejections, accepted/rejected inbound
|
||||
connections, and Tor control-port errors. The full counter table
|
||||
lives in [../reference/transports.md](../reference/transports.md).
|
||||
|
||||
## Nym: The Mixnet Transport
|
||||
|
||||
The Nym transport routes FIPS traffic through the Nym mixnet, providing
|
||||
network-level anonymity via Sphinx packet routing and timing
|
||||
obfuscation. It uses the "mixnet-as-proxy" pattern: a node connects
|
||||
outbound through a local `nym-socks5-client` SOCKS5 proxy, which carries
|
||||
the traffic into the mixnet. The `nym-socks5-client` runs as a separate
|
||||
process alongside the fips daemon and must be started independently.
|
||||
|
||||
Like Tor, Nym is a privacy-oriented deployment mode chosen for the
|
||||
anonymity properties of the mixnet, not a failover for other transports.
|
||||
Like TCP and Tor, it is connection-oriented and reliable; the same
|
||||
TCP-over-TCP considerations apply, and cost-based parent selection
|
||||
naturally deprioritizes the high-latency Nym links.
|
||||
|
||||
### Architecture
|
||||
|
||||
The Nym transport is a separate `NymTransport` implementation. It reuses
|
||||
the FMP header-based stream reader (`tcp/stream.rs`) for packet framing
|
||||
on the underlying byte stream, and follows the same connection-pool
|
||||
pattern as the TCP and Tor transports.
|
||||
|
||||
It maintains two pools: a `ConnectingPool` for background SOCKS5
|
||||
connection attempts, and an established pool of `NymConnection` entries.
|
||||
Each `NymConnection` holds a write half, a per-connection receive task,
|
||||
the configured MTU, and a connection timestamp.
|
||||
|
||||
| Property | Value |
|
||||
| -------- | ----- |
|
||||
| Addressing | IP:port or hostname:port |
|
||||
| Default MTU | 1400 bytes |
|
||||
| Framing | FMP header-based (shared with TCP) |
|
||||
| Connection model | Outbound-only, non-blocking connect through SOCKS5 |
|
||||
| Platform | Cross-platform (requires external nym-socks5-client) |
|
||||
|
||||
### Outbound-Only
|
||||
|
||||
The Nym transport is strictly outbound. It supports no inbound service:
|
||||
`accept_connections()` returns `false` and `discover()` returns no
|
||||
peers. A node using the Nym transport can initiate links to remote peers
|
||||
through the mixnet, but cannot accept inbound connections over Nym. (A
|
||||
node can still accept inbound links over other transports it runs.)
|
||||
|
||||
### Address Types
|
||||
|
||||
The Nym transport accepts two address formats, parsed into an internal
|
||||
target address:
|
||||
|
||||
- **IP:port** — a numeric IP and port, sent to the SOCKS5 proxy as a
|
||||
numeric target.
|
||||
- **Hostname:port** — the hostname is passed through SOCKS5 so it is
|
||||
resolved on the exit side rather than locally.
|
||||
|
||||
Both forms are routed through the same SOCKS5 proxy.
|
||||
|
||||
### Connection Establishment
|
||||
|
||||
Connection setup follows the same non-blocking pattern as the TCP and
|
||||
Tor transports. When FMP needs to reach a peer, the node initiates a
|
||||
background connect (`connect_async`). The transport spawns a background
|
||||
tokio task that opens a SOCKS5 connection through the local
|
||||
`nym-socks5-client`, configures the socket (including TCP keepalive),
|
||||
splits the stream, and spawns a per-connection receive loop using the
|
||||
shared FMP stream reader. The call returns immediately while the connect
|
||||
proceeds in the background.
|
||||
|
||||
SOCKS5 connection setup through the mixnet can take much longer than a
|
||||
direct TCP connection because each connection traverses multiple mix
|
||||
nodes with timing obfuscation. Accordingly the connect timeout defaults
|
||||
to 300 seconds (`connect_timeout_ms`). Non-blocking connect is essential
|
||||
here — a blocking connect would stall the FMP event loop for the
|
||||
duration of mixnet setup. As a fallback, `send_async(addr, data)`
|
||||
performs a connect-on-send if no connection to the address yet exists.
|
||||
|
||||
Each outbound packet is checked against the configured MTU before being
|
||||
written; an oversized packet is rejected with an MTU-exceeded error
|
||||
rather than being sent.
|
||||
|
||||
### Startup Readiness
|
||||
|
||||
At startup the transport validates the configured `socks5_addr` and then
|
||||
probes the SOCKS5 port to wait for `nym-socks5-client` to become ready,
|
||||
using exponential backoff (starting at 1 second, capped at 10 seconds
|
||||
between attempts) up to `startup_timeout_secs` (default 120 seconds). If
|
||||
the proxy does not become reachable within that window, the transport
|
||||
logs a warning and starts anyway; outbound connections then fail until
|
||||
the `nym-socks5-client` becomes available.
|
||||
|
||||
### Session Independence
|
||||
|
||||
Same as TCP and Tor: loss of a Nym connection does **not** tear down the
|
||||
FIPS peer. Noise keys, MMP state, and FSP sessions survive reconnection.
|
||||
|
||||
### Configuration
|
||||
|
||||
The Nym transport block (`transports.nym.*`) has the following fields:
|
||||
|
||||
| Field | Default | Description |
|
||||
| ----- | ------- | ----------- |
|
||||
| `socks5_addr` | `127.0.0.1:1080` | Address (host:port) of the local nym-socks5-client SOCKS5 proxy |
|
||||
| `connect_timeout_ms` | `300000` | Outbound SOCKS5 connect timeout in milliseconds (300s) |
|
||||
| `mtu` | `1400` | Maximum FIPS packet size for Nym connections, in bytes |
|
||||
| `startup_timeout_secs` | `120` | Seconds to wait for nym-socks5-client to become ready at startup |
|
||||
|
||||
The Nym transport requires an external `nym-socks5-client`. Named
|
||||
instances are supported for multiple proxy endpoints. Unknown
|
||||
configuration keys are rejected.
|
||||
|
||||
### Statistics
|
||||
|
||||
The Nym transport exposes per-instance counters covering successful
|
||||
send/receive, send/receive errors, connection establishment, SOCKS5-level
|
||||
errors, connect timeouts, and MTU rejections.
|
||||
|
||||
## Discovery
|
||||
|
||||
Discovery determines that a FIPS-capable endpoint is reachable at a given
|
||||
@@ -725,7 +841,8 @@ TransportType {
|
||||
}
|
||||
```
|
||||
|
||||
Predefined types exist for UDP, TCP, Ethernet, WiFi, Tor, and Serial.
|
||||
Predefined types exist for UDP, TCP, Ethernet, WiFi, Tor, Nym, BLE, and
|
||||
Serial.
|
||||
|
||||
### Congestion Reporting
|
||||
|
||||
@@ -750,6 +867,7 @@ on all forwarded datagrams.
|
||||
| UDP | `SO_RXQ_OVFL` kernel drop counter | `recvmsg()` ancillary data on every packet |
|
||||
| TCP | Not implemented | Returns `None` (TCP handles congestion internally) |
|
||||
| Tor | Not implemented | Returns `None` (TCP handles congestion internally) |
|
||||
| Nym | Not implemented | Returns `None` (TCP handles congestion internally) |
|
||||
| Ethernet | Not implemented | Returns `None` |
|
||||
|
||||
### Transport Addresses
|
||||
@@ -777,9 +895,10 @@ transitions through `Starting` to `Up` (operational). `stop()` moves to
|
||||
| --------- | ------ | ----- |
|
||||
| UDP/IP | **Implemented** | Primary transport, AsyncFd/recvmsg, SO_RXQ_OVFL kernel drop detection |
|
||||
| TCP/IP | **Implemented** | FMP header-based framing, non-blocking connect, per-connection MSS MTU |
|
||||
| Ethernet | **Implemented** | AF_PACKET SOCK_DGRAM, EtherType 0x2121, beacon discovery, Linux only |
|
||||
| Ethernet | **Implemented** | AF_PACKET SOCK_DGRAM, EtherType 0x2121, neighbor beacons, Linux only |
|
||||
| WiFi | **Implemented** (via Ethernet transport, infrastructure mode) | mac80211 translates 802.11↔802.3; broadcast beacons unreliable through APs |
|
||||
| Tor | **Implemented** | Outbound SOCKS5, inbound via onion service, .onion and clearnet addressing |
|
||||
| Nym | **Implemented** | Outbound-only SOCKS5 through nym-socks5-client, mixnet anonymity, IP/hostname addressing |
|
||||
| BLE | **Implemented** (Linux/glibc only; experimental) | L2CAP CoC, ATT_MTU negotiation, per-link MTU; musl/macOS/Windows skip |
|
||||
| Radio | Future direction | Constrained MTU (51–222 bytes) |
|
||||
| Serial | Future direction | SLIP/COBS framing, point-to-point |
|
||||
|
||||
@@ -526,7 +526,7 @@ own.
|
||||
|
||||
| Failure | Symptom | Mitigation |
|
||||
| --- | --- | --- |
|
||||
| Symmetric NAT (one side) | Punch timeout | Retry with port-prediction heuristics; otherwise fall back to a relay or different transport |
|
||||
| Symmetric NAT (one side) | Punch timeout | Retry with port-prediction heuristics; otherwise fall back to an application-level relay |
|
||||
| Symmetric NAT (both sides) | Punch timeout | Application-level relay required |
|
||||
| Relay latency > 60 s | Stale reflexive address | Use low-latency relays; consider self-hosted relay |
|
||||
| Relay does not support ephemeral kinds | Signaling events persist | Use NIP-40 expiration + NIP-09 deletion as fallback |
|
||||
|
||||
@@ -88,6 +88,23 @@ See [packaging/README.md](../packaging/README.md) for per-format
|
||||
build details, cross-target options, and the full `make` target
|
||||
list.
|
||||
|
||||
### With Nix (flake)
|
||||
|
||||
On Nix/NixOS, a [flake](../flake.nix) at the project root builds the
|
||||
binaries from source with the pinned toolchain and no manual
|
||||
prerequisite install:
|
||||
|
||||
```sh
|
||||
nix build .#fips # all four binaries, into ./result/bin
|
||||
nix develop # dev shell with the toolchain + build deps
|
||||
```
|
||||
|
||||
This path produces binaries only — it does not run the installer, so
|
||||
there are no systemd units, no `fips` group, and no default `fips.yaml`.
|
||||
On NixOS, wire the daemon in through your system configuration using the
|
||||
flake's `packages.<system>.fips` output instead. See the Nix / NixOS
|
||||
section of [packaging/README.md](../packaging/README.md).
|
||||
|
||||
## What's installed and running
|
||||
|
||||
Here's what the installer leaves on your machine, what's
|
||||
|
||||
@@ -18,10 +18,12 @@ X" to "X is done".
|
||||
| [enable-nostr-discovery.md](enable-nostr-discovery.md) | Turn on Nostr-mediated discovery (3 capabilities — resolve, advertise, open — across 5 scenarios) |
|
||||
| [deploy-tor-onion.md](deploy-tor-onion.md) | Run a Tor onion service for inbound FIPS connections |
|
||||
| [tune-udp-buffers.md](tune-udp-buffers.md) | Set host sysctls so FIPS UDP sockets don't get clamped |
|
||||
| [tune-file-descriptors.md](tune-file-descriptors.md) | Raise `RLIMIT_NOFILE` so a busy node doesn't exhaust file descriptors (`EMFILE`) as peer count grows |
|
||||
| [run-as-unprivileged-user.md](run-as-unprivileged-user.md) | Run the daemon under a dedicated unprivileged service account (drops the default-root posture) |
|
||||
| [deploy-gateway.md](deploy-gateway.md) | Manually deploy `fips-gateway` on a non-OpenWrt Linux host (LAN-to-mesh outbound + mesh-to-LAN inbound port-forwards). For the OpenWrt path, see the gateway tutorial. |
|
||||
| [troubleshoot-gateway.md](troubleshoot-gateway.md) | Diagnostic recipes for the gateway, organised by half (outbound, inbound, common) |
|
||||
| [persistent-identity.md](persistent-identity.md) | Provision a stable Nostr keypair so the node keeps the same npub across restarts |
|
||||
| [host-aliases.md](host-aliases.md) | Use shortnames (`test-us01.fips`, `my-laptop.fips`) instead of full npubs by editing `/etc/fips/hosts` or setting peer aliases |
|
||||
| [set-up-bluetooth-peer.md](set-up-bluetooth-peer.md) | Configure a Bluetooth Low Energy peer link |
|
||||
| [set-up-80211s-mesh-backhaul.md](set-up-80211s-mesh-backhaul.md) | Link OpenWrt FIPS routers over an open 802.11s radio backhaul (FIPS provides encryption, authentication, and routing) |
|
||||
| [diagnose-mtu-issues.md](diagnose-mtu-issues.md) | Triage MTU-shaped failures and rule out their imposters (bufferbloat, transport saturation) |
|
||||
|
||||
@@ -284,6 +284,7 @@ transports:
|
||||
tor:
|
||||
mode: directory
|
||||
socks5_addr: "127.0.0.1:9050"
|
||||
advertised_port: 8443
|
||||
directory_service:
|
||||
hostname_file: "/var/lib/tor/fips/hostname"
|
||||
bind_addr: "127.0.0.1:8444"
|
||||
|
||||
@@ -169,7 +169,7 @@ sudo usermod -aG fips $USER
|
||||
Then:
|
||||
|
||||
```sh
|
||||
fipsctl show node
|
||||
fipsctl show status
|
||||
```
|
||||
|
||||
## Caveats
|
||||
|
||||
247
docs/how-to/set-up-80211s-mesh-backhaul.md
Normal file
247
docs/how-to/set-up-80211s-mesh-backhaul.md
Normal file
@@ -0,0 +1,247 @@
|
||||
# Set Up an 802.11s Mesh Backhaul (OpenWrt)
|
||||
|
||||
Link FIPS routers over radio — no cables, no APs, no shared
|
||||
infrastructure — by running the Ethernet transport on an open 802.11s
|
||||
mesh interface. The radio layer provides nothing but L2 frames to
|
||||
direct neighbors; FIPS provides everything else: encryption and
|
||||
authentication (Noise IK), peer discovery (Ethernet beacons), and
|
||||
routing (the spanning tree).
|
||||
|
||||
For the transport design, see
|
||||
[../design/fips-transport-layer.md](../design/fips-transport-layer.md).
|
||||
For all `transports.ethernet.*` configuration keys, see
|
||||
[../reference/configuration.md](../reference/configuration.md).
|
||||
|
||||
## Why open, why forwarding off
|
||||
|
||||
Two deliberate choices distinguish this from a stock 802.11s setup:
|
||||
|
||||
- **`encryption none`** — the mesh is open on purpose. Every FIPS peer
|
||||
link is already authenticated and encrypted by the Noise IK
|
||||
handshake, so SAE at L2 would duplicate that work, add a shared
|
||||
credential to provision across routers, and (on ath10k) force the
|
||||
firmware into its slower raw Tx/Rx mode. A stranger can form an
|
||||
802.11s peering with your router, but their frames die at the FIPS
|
||||
handshake — the same security model as mDNS and BLE discovery, where
|
||||
the advert is only a hint and the handshake is the authentication.
|
||||
What you concede: L2 metadata (MAC addresses, frame sizes) is
|
||||
visible in the air, and a hostile radio can burn airtime — both true
|
||||
of any radio link regardless of L2 encryption.
|
||||
- **`mesh_fwding 0`** — disables 802.11s's own HWMP routing so each
|
||||
mesh link is a plain neighbor link. FIPS is the routing layer; two
|
||||
routing layers would fight, and broadcast discovery beacons would
|
||||
flood the whole mesh instead of reaching direct neighbors only.
|
||||
|
||||
The interface is **not** bridged into `br-lan` — the FIPS Ethernet
|
||||
transport binds it directly.
|
||||
|
||||
## When to use
|
||||
|
||||
- Two or more OpenWrt FIPS routers within radio range of each other,
|
||||
where running cable is impractical.
|
||||
- You want the mesh segment to keep working with zero shared
|
||||
credentials or per-site configuration ("flash and drop in").
|
||||
|
||||
It is **not** for connecting phones or laptops — client devices
|
||||
cannot join an 802.11s mesh. They enter the mesh through a normal AP
|
||||
on the same router (see constraints below), or over BLE.
|
||||
|
||||
## Requirements
|
||||
|
||||
- OpenWrt 22.03+ with the FIPS package installed.
|
||||
- A radio whose driver supports mesh point interfaces. Check with:
|
||||
|
||||
```sh
|
||||
iw list | grep -A 10 "Supported interface modes" | grep "mesh point"
|
||||
```
|
||||
|
||||
The mainstream OpenWrt chips (ath9k, ath10k, mt76) all qualify.
|
||||
- Ideally a dual- or tri-band router, so one band can be dedicated to
|
||||
the backhaul (see constraints).
|
||||
|
||||
## Step 1 — create the mesh interface(s)
|
||||
|
||||
On **each** router, run the helper once per radio you want in the
|
||||
backhaul:
|
||||
|
||||
```sh
|
||||
fips-mesh-setup radio1
|
||||
```
|
||||
|
||||
This creates an open 802.11s interface with mesh ID `fips-mesh` and
|
||||
HWMP forwarding off, attaches it to an unmanaged netifd interface (no
|
||||
IP configuration — none is needed), uncomments the matching `meshN`
|
||||
transport entry in `/etc/fips/fips.yaml` (see Step 2), and reloads the
|
||||
radio. Interfaces are named by radio index: `radio0` → `fips-mesh0`,
|
||||
`radio1` → `fips-mesh1`. Pass a second argument to use a different
|
||||
mesh ID.
|
||||
|
||||
Note: the helper runs `wifi reload`, which re-applies the whole
|
||||
wireless config and so briefly drops every client AP on all radios for
|
||||
a few seconds. `fips-mesh-setup remove` reloads the same way. Expect
|
||||
the blip if clients are connected.
|
||||
|
||||
On dual-band routers, meshing **both** bands is worth it: 2.4 GHz
|
||||
reaches further at lower rates, 5 GHz carries more over shorter
|
||||
links. Note this is **failover, not multipath**: FIPS keeps one
|
||||
active link per peer, so traffic uses one band at a time — the other
|
||||
is a standby that re-establishes the peer if the active link dies
|
||||
(detection via keepalive timeout, so a cutover takes seconds, not
|
||||
milliseconds):
|
||||
|
||||
```sh
|
||||
fips-mesh-setup radio0
|
||||
fips-mesh-setup radio1
|
||||
```
|
||||
|
||||
**Pin the same channel on every backhaul router, per band.** Mesh
|
||||
points only peer on the same channel, and the mesh inherits whatever
|
||||
the radio is set to — with `channel 'auto'` (the default on many
|
||||
devices) each router picks its own and the mesh silently never forms.
|
||||
The script prints the radio's current band and channel and warns on
|
||||
`auto`:
|
||||
|
||||
```sh
|
||||
uci set wireless.radio1.channel='36'
|
||||
uci commit wireless && wifi reload
|
||||
```
|
||||
|
||||
Prefer a non-DFS channel (36–48 on 5 GHz): on DFS channels the radio
|
||||
must wait ~60 s in CAC before transmitting after every reload.
|
||||
|
||||
Equivalent manual UCI (per radio), if you prefer to see what it does:
|
||||
|
||||
```sh
|
||||
uci batch <<'EOF'
|
||||
set wireless.fips_mesh_radio1=wifi-iface
|
||||
set wireless.fips_mesh_radio1.device='radio1'
|
||||
set wireless.fips_mesh_radio1.mode='mesh'
|
||||
set wireless.fips_mesh_radio1.mesh_id='fips-mesh'
|
||||
set wireless.fips_mesh_radio1.encryption='none'
|
||||
set wireless.fips_mesh_radio1.mesh_fwding='0'
|
||||
set wireless.fips_mesh_radio1.ifname='fips-mesh1'
|
||||
set wireless.fips_mesh_radio1.network='fips_mesh_radio1'
|
||||
set network.fips_mesh_radio1=interface
|
||||
set network.fips_mesh_radio1.proto='none'
|
||||
EOF
|
||||
uci commit
|
||||
wifi reload
|
||||
```
|
||||
|
||||
## Step 2 — check the FIPS transport binding
|
||||
|
||||
The `fips.yaml` shipped in the OpenWrt package carries one transport
|
||||
entry per radio, but **commented out** — so a stock install that never
|
||||
runs this helper logs no per-boot "interface missing" warning.
|
||||
`fips-mesh-setup` uncommented the matching `meshN` entry in Step 1, so
|
||||
there is normally nothing to do here. If you maintain your own config
|
||||
(or ran the manual UCI above instead of the helper), make sure the
|
||||
entries are present and uncommented:
|
||||
|
||||
```yaml
|
||||
transports:
|
||||
ethernet:
|
||||
mesh0:
|
||||
interface: "fips-mesh0"
|
||||
discovery: true
|
||||
announce: true
|
||||
auto_connect: true
|
||||
accept_connections: true
|
||||
mesh1:
|
||||
interface: "fips-mesh1"
|
||||
discovery: true
|
||||
announce: true
|
||||
auto_connect: true
|
||||
accept_connections: true
|
||||
```
|
||||
|
||||
## Step 3 — restart the daemon (order matters)
|
||||
|
||||
```sh
|
||||
/etc/init.d/fips restart
|
||||
```
|
||||
|
||||
Restart fips **after** the mesh interface is up. A transport whose
|
||||
interface is missing at startup is logged and skipped, not retried —
|
||||
so if the daemon comes up before the radio, the mesh transport stays
|
||||
dead until the next restart. (An interface that *vanishes and
|
||||
returns* after startup is recovered automatically; only the missing-
|
||||
at-startup case needs this ordering.)
|
||||
|
||||
## Verify
|
||||
|
||||
L2 first — the 802.11s peering, with a second configured router in
|
||||
range:
|
||||
|
||||
```sh
|
||||
iw dev fips-mesh0 station dump
|
||||
```
|
||||
|
||||
You should see one station entry per neighbor router, with signal
|
||||
levels. No entries means a radio problem, not a FIPS problem — triage
|
||||
in this order:
|
||||
|
||||
1. **Channel mismatch** (the most common cause): compare
|
||||
`iw dev fips-mesh0 info` on both routers — mesh ID *and* channel
|
||||
must match exactly.
|
||||
2. **The mesh interface never joined** — `iw dev fips-meshX info`
|
||||
shows `type mesh point` but **no channel line**, and `station dump`
|
||||
is empty. Usual cause: a client (`sta`) interface on the same
|
||||
radio. A STA must follow its upstream AP's channel, the whole
|
||||
radio follows the STA, and a mesh pinned to a different channel
|
||||
silently stays down. Check for a STA sharing the radio
|
||||
(`iw dev`, look for `type managed` on the same phy), compare
|
||||
`iw dev <sta-iface> info | grep channel`, and re-pin the mesh
|
||||
channel to match — on every backhaul router.
|
||||
3. **Is the other router transmitting at all?**
|
||||
|
||||
```sh
|
||||
iw dev fips-mesh0 scan | grep -i -B4 "MESH ID"
|
||||
```
|
||||
|
||||
Its mesh ID visible → transmission works, peering is failing
|
||||
(mesh ID typo, or one side has encryption set). Nothing visible →
|
||||
check `wifi status` on the other router, remember the ~60 s DFS
|
||||
CAC wait, and confirm the country code is set
|
||||
(`uci get wireless.radio1.country`) — an unset regdomain can
|
||||
block channels entirely.
|
||||
4. `logread | grep -iE "mesh|fips-mesh0"` on both sides.
|
||||
|
||||
Then the FIPS layer on top:
|
||||
|
||||
```sh
|
||||
logread | grep -i beacon # beacons flowing on the new transport
|
||||
fipsctl show peers # neighbor authenticated and connected
|
||||
fipsctl show links # link on the 'ethernet' transport
|
||||
```
|
||||
|
||||
Discovery is automatic: each node beacons its pubkey every few
|
||||
seconds, and `auto_connect` initiates the Noise handshake on first
|
||||
sight.
|
||||
|
||||
## Constraints
|
||||
|
||||
- **Airtime is shared per radio.** All virtual interfaces on one
|
||||
radio (AP + mesh) share one channel, and multi-hop forwarding on a
|
||||
single radio roughly halves throughput per hop. On dual/tri-band
|
||||
hardware, dedicate one band to `fips-mesh0` and serve clients on
|
||||
the others.
|
||||
- **AP + mesh coexistence is driver-dependent.** It works on the
|
||||
mainstream chips (this is the standard Freifunk/Gluon setup), but
|
||||
check `iw list` under "valid interface combinations" for your
|
||||
hardware.
|
||||
- **Clients can't join.** Phones and laptops reach the mesh through
|
||||
the router's normal AP or via BLE — never through the 802.11s
|
||||
interface.
|
||||
- **Radio links are lossy.** A neighbor at the edge of range will
|
||||
form an 802.11s peering yet deliver a fraction of its frames.
|
||||
Expect link-quality effects that don't exist on wired Ethernet.
|
||||
- **A client (STA) uplink on the same radio owns the channel.** The
|
||||
STA must follow whatever channel its upstream AP uses; every other
|
||||
interface on that radio follows the STA. A mesh pinned to a
|
||||
different channel silently never joins, and it does **not** recover
|
||||
when the STA disconnects — a `wifi reload` (plus a fips restart) is
|
||||
needed. A *roaming* uplink (travel-router / hotspot-chasing setups)
|
||||
is fundamentally incompatible with a fixed-channel mesh on the same
|
||||
radio: dedicate the mesh to the radio the STA never uses, and treat
|
||||
any mesh sharing a STA radio as best-effort.
|
||||
@@ -151,7 +151,7 @@ rather than `flush ruleset`, which destroys every table on the host.
|
||||
Symptom: `nc -U /run/fips/gateway.sock` fails with "Permission
|
||||
denied" or "No such file or directory".
|
||||
|
||||
The socket is owned by root with mode `0660` (group `fips`). Either
|
||||
The socket is owned by root with mode `0770` (group `fips`). Either
|
||||
run `nc` as root (`sudo nc -U ...`) or add your user to the `fips`
|
||||
group and re-login. If the file does not exist at all, the gateway
|
||||
either failed to start (check `journalctl -u fips-gateway`) or
|
||||
|
||||
144
docs/how-to/tune-file-descriptors.md
Normal file
144
docs/how-to/tune-file-descriptors.md
Normal file
@@ -0,0 +1,144 @@
|
||||
# Tune the File-Descriptor Limit for FIPS
|
||||
|
||||
A busy FIPS node opens many file descriptors, and the count grows with
|
||||
the number of peers it serves. On most systemd distributions the daemon
|
||||
inherits a soft `RLIMIT_NOFILE` of 1024, which a well-connected node can
|
||||
exhaust — at which point peer admission, handshakes, and discovery start
|
||||
failing with `EMFILE` ("Too many open files").
|
||||
|
||||
This guide explains the FD budget, shows how to raise the limit on
|
||||
systemd and on OpenWrt, and how to verify the result.
|
||||
|
||||
## Why FIPS is FD-hungry
|
||||
|
||||
Unlike a service that multiplexes all traffic over one socket, the FIPS
|
||||
data plane allocates descriptors **per peer**. The dominant term is:
|
||||
|
||||
```text
|
||||
fds ≈ 3·P + fixed overhead (~30)
|
||||
```
|
||||
|
||||
where `P` is the number of established UDP peers. Each such peer consumes
|
||||
**3 file descriptors**:
|
||||
|
||||
- one `connect()`-ed UDP socket dedicated to that peer, plus
|
||||
- a 2-FD self-pipe owned by that peer's receive-drain worker (used to
|
||||
wake and stop the worker cleanly).
|
||||
|
||||
The remaining consumers are bounded and do not scale with peer count:
|
||||
|
||||
- the TUN device (one descriptor, process-lifetime),
|
||||
- the wildcard UDP listen socket(s) (one per bound UDP transport),
|
||||
- TCP and Tor transport listeners and the Tor control socket,
|
||||
- Nostr relay websockets (one per configured discovery relay),
|
||||
- the control socket (one `UnixListener`, plus short-lived per-request
|
||||
client connections for `fipsctl` / `fipstop`),
|
||||
- and base runtime descriptors (epoll, eventfd, logs).
|
||||
|
||||
Together these add a roughly flat overhead of about 30 descriptors. The
|
||||
per-peer term is what drives the daemon toward the FD ceiling.
|
||||
|
||||
## The symptom
|
||||
|
||||
The systemd and distro default **soft** `RLIMIT_NOFILE` is **1024**.
|
||||
With the `3·P` budget above, that ceiling is reached near **~320 peers**
|
||||
(3 × 320 ≈ 960, plus the fixed overhead). Once the process is out of
|
||||
descriptors, every syscall that allocates one — `socket()`, `accept()`,
|
||||
`open()`, `pipe()` — fails with `EMFILE`, which surfaces as:
|
||||
|
||||
- failed peer admission (new peers cannot be accepted),
|
||||
- failed handshakes (the daemon cannot open the per-peer socket), and
|
||||
- dropped discovery (relay or probe sockets cannot be created).
|
||||
|
||||
These symptoms appear only under load, once the node has accumulated
|
||||
enough peers to cross the ceiling, so they can be easy to misattribute.
|
||||
|
||||
## Raise the limit on systemd
|
||||
|
||||
Create a drop-in override for the service:
|
||||
|
||||
```sh
|
||||
sudo systemctl edit fips.service
|
||||
```
|
||||
|
||||
Add:
|
||||
|
||||
```ini
|
||||
[Service]
|
||||
LimitNOFILE=65535
|
||||
```
|
||||
|
||||
A single `LimitNOFILE=` value sets **both** the soft and the hard limit,
|
||||
so no separate soft/hard syntax is needed here.
|
||||
|
||||
Reload systemd and restart the daemon so the new limit takes effect:
|
||||
|
||||
```sh
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl restart fips
|
||||
```
|
||||
|
||||
`65535` (2¹⁶ − 1) is the conventional headroom value for network
|
||||
daemons. With the `3·P` budget, it supports roughly **~21,800 peers**
|
||||
before the FD ceiling binds — well beyond any plausible single-node FIPS
|
||||
mesh degree. Past that point other limits (threads, memory, CPU) bind
|
||||
first, so raising `LimitNOFILE` higher buys nothing.
|
||||
|
||||
## Raise the limit on OpenWrt
|
||||
|
||||
OpenWrt uses procd, not systemd, so `LimitNOFILE` does not apply.
|
||||
Set the equivalent limit in the init script at `/etc/init.d/fips`,
|
||||
inside the block that starts the service:
|
||||
|
||||
```sh
|
||||
procd_set_param limits nofile="65535 65535"
|
||||
```
|
||||
|
||||
The two values are the soft and hard limits respectively; setting them
|
||||
equal mirrors the single-value systemd behaviour above.
|
||||
|
||||
Restart the service to apply:
|
||||
|
||||
```sh
|
||||
/etc/init.d/fips restart
|
||||
```
|
||||
|
||||
## Verify
|
||||
|
||||
Compare the live descriptor count against the established peer count:
|
||||
|
||||
```sh
|
||||
ls /proc/$(pidof fips)/fd | wc -l
|
||||
fipsctl show peers | wc -l
|
||||
```
|
||||
|
||||
At steady state, expect a stable ratio of about **3 descriptors per
|
||||
peer** plus the flat ~30-descriptor overhead. A ratio that holds steady
|
||||
as peers come and go confirms healthy, bounded scaling.
|
||||
|
||||
If the descriptor count climbs steadily while the peer count stays flat,
|
||||
that would indicate a descriptor leak rather than legitimate scaling —
|
||||
the limit bump would only delay the wall. The current data plane has
|
||||
been audited as leak-free (every per-peer descriptor has a guaranteed
|
||||
close on every teardown path), so a climbing ratio at fixed peer count
|
||||
would be a regression worth investigating.
|
||||
|
||||
## A note on deployment lines
|
||||
|
||||
The per-peer connected-UDP socket — the amplifier behind the
|
||||
`3·P` term — is present on the master and next data planes. It is **not
|
||||
yet present on the maintenance line**. On maintenance-only deployments
|
||||
the 3-descriptor-per-peer term does not apply, and FD pressure comes
|
||||
only from the fixed consumers listed above. Raising `LimitNOFILE` there
|
||||
is still worthwhile as forward-looking headroom, and harmless where the
|
||||
amplifier is absent.
|
||||
|
||||
## See also
|
||||
|
||||
- [tune-udp-buffers.md](tune-udp-buffers.md) — host sysctls so FIPS UDP
|
||||
sockets don't get clamped
|
||||
- [run-as-unprivileged-user.md](run-as-unprivileged-user.md) — run the
|
||||
daemon under a dedicated service account
|
||||
- [../reference/configuration.md](../reference/configuration.md) —
|
||||
transport and discovery configuration that influences the fixed FD
|
||||
overhead
|
||||
@@ -28,7 +28,7 @@ controlled through the standard service control manager.
|
||||
| Flag | Argument | Description |
|
||||
| ---- | -------- | ----------- |
|
||||
| `-c`, `--config` | `FILE` | Use `FILE` as the configuration. Skips the default search paths. |
|
||||
| `-V` | — | Print the short version (e.g. `0.3.0-dev (rev abcdef1)`). |
|
||||
| `-V` | — | Print the short version (e.g. `0.4.0 (rev abcdef1)`). |
|
||||
| `--version` | — | Print the long version: short version plus build target triple. |
|
||||
| `-h`, `--help` | — | Print usage and exit. |
|
||||
| `--install-service` | — | (Windows only) Install `fips` as a Windows service. Requires Administrator. |
|
||||
|
||||
@@ -69,6 +69,7 @@ Time-series metrics from the in-process history rings.
|
||||
| Subcommand | Control-socket command | Description |
|
||||
| ---------- | ---------------------- | ----------- |
|
||||
| `stats list` | `show_stats_list` | Enumerate available metrics, their units, and the per-ring retention windows. |
|
||||
| `stats metrics` | `show_metrics` | Dump current counter values for every protocol metric family (`forwarding`, `discovery`, `tree`, `bloom`, `congestion`, `errors`). |
|
||||
| `stats peers` | `show_stats_peers` | List peers tracked in stats history (active or recently active). |
|
||||
| `stats history <metric> [options]` | `show_stats_history` | Fetch a time-series window for one metric. |
|
||||
|
||||
@@ -104,7 +105,7 @@ Tell the daemon to dial a peer over a specific transport.
|
||||
| -------- | ----------- |
|
||||
| `peer` | npub (bech32) or hostname from `/etc/fips/hosts`. |
|
||||
| `address` | Transport endpoint, e.g. `192.168.1.10:2121`, `[2001:db8::1]:2121`, or a Tor onion. FIPS-mesh ULAs (`fd00::/8`) are rejected for the IP-based transports (udp, tcp, ethernet). |
|
||||
| `transport` | One of `udp`, `tcp`, `tor`, `ethernet`. |
|
||||
| `transport` | One of `udp`, `tcp`, `tor`, `nym`, `ethernet`. The named transport must be configured and running. |
|
||||
|
||||
### `disconnect <peer>`
|
||||
|
||||
|
||||
@@ -15,8 +15,11 @@ socket, polls a small set of `show_*` queries on a timer, and renders
|
||||
the state in a tabbed full-screen UI. A separate poll runs against the
|
||||
gateway control socket when the Gateway tab is active.
|
||||
|
||||
`fipstop` is read-only — it cannot mutate daemon state. Use
|
||||
[`fipsctl`](cli-fipsctl.md) for `connect` / `disconnect` and friends.
|
||||
`fipstop` is almost entirely read-only: the only state-mutating action
|
||||
it offers is disconnecting a peer (`Del` on a selected Peers row, with
|
||||
a confirmation prompt — see [Keybindings](#keybindings)). For
|
||||
`connect` and other mutating commands, use
|
||||
[`fipsctl`](cli-fipsctl.md).
|
||||
|
||||
## Options
|
||||
|
||||
@@ -86,6 +89,11 @@ empty list and the panel hides.
|
||||
|
||||
## Keybindings
|
||||
|
||||
Press `?` at any time for an in-app help overlay. The overlay and the
|
||||
status-bar hint footer both read from a single keybinding registry
|
||||
keyed by `(tab, mode)`, so the always-visible hints describe exactly
|
||||
the keys the current context accepts.
|
||||
|
||||
### Global
|
||||
|
||||
| Key | Action |
|
||||
@@ -94,7 +102,8 @@ empty list and the panel hides.
|
||||
| `Tab` | Next tab. |
|
||||
| `Shift-Tab` | Previous tab. |
|
||||
| `g` | Jump to the Graphs tab. |
|
||||
| `Esc` | Close detail view (if open). |
|
||||
| `?` | Toggle the help overlay. |
|
||||
| `Esc` | Close an open detail view; otherwise deselect the active table row. |
|
||||
|
||||
### Table tabs (Peers, Sessions, Transports, Gateway)
|
||||
|
||||
@@ -102,6 +111,13 @@ empty list and the panel hides.
|
||||
| --- | ------ |
|
||||
| `Up`, `Down` | Move row selection. |
|
||||
| `Enter` | Open detail view for the selected row. |
|
||||
| `Esc` | Deselect the row (return to the tab's overview state). |
|
||||
|
||||
### Peers tab (extra)
|
||||
|
||||
| Key | Action |
|
||||
| --- | ------ |
|
||||
| `Del` | Disconnect the selected peer. Opens a `Y`/`N` confirmation modal first; this is the only state-mutating action in `fipstop`. |
|
||||
|
||||
### Transports tab (extra)
|
||||
|
||||
@@ -112,16 +128,43 @@ empty list and the panel hides.
|
||||
| `e` | Expand all transports. |
|
||||
| `c` | Collapse all transports. |
|
||||
|
||||
### Multi-pane scrolling tabs (Tree, Filters, Routing)
|
||||
|
||||
Each lays out stacked panes that scroll independently.
|
||||
|
||||
| Key | Action |
|
||||
| --- | ------ |
|
||||
| `f` | Move focus to the next pane. |
|
||||
| `Up`, `Down` | Scroll the focused pane by one row. |
|
||||
| `PageUp`, `PageDown` | Scroll the focused pane by ten rows. |
|
||||
| `Home`, `End` | Jump to the top / bottom of the focused pane. |
|
||||
|
||||
### Performance tab (extra)
|
||||
|
||||
The Performance tab lays out two panes (Link MMP, Session MMP).
|
||||
|
||||
| Key | Action |
|
||||
| --- | ------ |
|
||||
| `f` | Move focus between the Link and Session MMP panes. |
|
||||
| `Up`, `Down` | Scroll the focused pane. |
|
||||
| `PageUp`, `PageDown` | Scroll the focused pane by ten rows. |
|
||||
| `Home`, `End` | Jump to the top / bottom of the focused pane. |
|
||||
| `s` | Cycle the sort column of the focused pane. |
|
||||
| `Shift-S` | Toggle the sort direction of the focused pane. |
|
||||
|
||||
### Graphs tab (extra)
|
||||
|
||||
| Key | Action |
|
||||
| --- | ------ |
|
||||
| `Up`, `Down` | Scroll within the stacked plots. |
|
||||
| `Up`, `Down` | Scroll the stacked plots; in `MetricByPeer` mode, move the by-peer selection (and follow it when the by-peer detail is open). |
|
||||
| `Right`, `Space` | Next time window. Cycles `1m / 1s` → `10m / 1s` → `1h / 1s` → `24h / 1m`. |
|
||||
| `Left` | Previous time window. |
|
||||
| `Enter` | In `MetricByPeer` mode, expand the selected peer summary into a full-pane plot. |
|
||||
| `m` | Cycle view mode: `Node` (stacked node metrics) → `MetricByPeer` (one per-peer metric across all peers) → `PeerByMetric` (all per-peer metrics for one peer). |
|
||||
| `n` | Next selector (next per-peer metric in MetricByPeer; next peer in PeerByMetric). |
|
||||
| `Shift-N` | Previous selector. |
|
||||
| `s` | Cycle the sort column of the by-peer summary list. |
|
||||
| `Shift-S` | Toggle the sort direction of the by-peer summary list. |
|
||||
|
||||
## Exit Codes
|
||||
|
||||
|
||||
@@ -235,6 +235,29 @@ addresses for the punch socket port.
|
||||
During punching, compatible private-subnet candidates and reflexive candidates
|
||||
are attempted in parallel; the first successful path wins.
|
||||
|
||||
#### LAN Discovery (`node.discovery.lan.*`)
|
||||
|
||||
Peer discovery on the local link via mDNS / DNS-SD (RFC 6762 / RFC
|
||||
6763). When enabled, the node publishes a `_fips._udp.local.` service
|
||||
advert carrying its `npub` (and optional scope) and concurrently
|
||||
browses for the same service type to learn same-broadcast-domain peers.
|
||||
The result is sub-second peer pairing with no Nostr-relay roundtrip,
|
||||
STUN observation, or NAT traversal: the observed endpoint is by
|
||||
construction routable from the consumer's LAN.
|
||||
|
||||
mDNS adverts are unauthenticated, so a LAN advert is treated only as a
|
||||
routing hint. Identity is still proven end-to-end by the Noise XX
|
||||
handshake the node initiates against the observed endpoint; a spoofed
|
||||
advert carrying another peer's npub fails the handshake and is dropped.
|
||||
LAN discovery requires an active UDP transport (peers dial the
|
||||
advertised UDP port to begin the handshake).
|
||||
|
||||
| Parameter | Type | Default | Description |
|
||||
|-----------|------|---------|-------------|
|
||||
| `node.discovery.lan.enabled` | bool | `false` | Master switch. Opt-in: enable for sub-second same-LAN pairing. Default-off avoids reintroducing a per-LAN identity broadcast on nodes that have deliberately disabled other discovery channels |
|
||||
| `node.discovery.lan.service_type` | string | `"_fips._udp.local."` | DNS-SD service type. Primarily an override for integration tests running multiple isolated services on one loopback interface; leave at the default in production |
|
||||
| `node.discovery.lan.scope` | string | *(none)* | Optional application/network scope carried in a `scope=<name>` TXT entry. Browsers with a scope set only surface peers advertising the same scope, so nodes on the same physical LAN configured for different mesh networks do not cross-feed. Intentionally separate from `node.discovery.nostr.app` so relay-visible adverts can stay generic while LAN discovery is isolated per private network |
|
||||
|
||||
### Spanning Tree (`node.tree.*`)
|
||||
|
||||
Controls tree construction and parent selection.
|
||||
@@ -254,7 +277,7 @@ Controls tree construction and parent selection.
|
||||
| Parameter | Type | Default | Description |
|
||||
|-----------|------|---------|-------------|
|
||||
| `node.bloom.update_debounce_ms` | u64 | `500` | Debounce interval for filter update propagation |
|
||||
| `node.bloom.max_inbound_fpr` | f64 | `0.05` | Antipoison cap: reject inbound `FilterAnnounce` frames whose advertised false-positive rate exceeds this value. Valid range `(0.0, 1.0)`. The default `0.05` corresponds to fill 0.549 at k=5 (≈3,200 entries on the 1 KB filter) |
|
||||
| `node.bloom.max_inbound_fpr` | f64 | `0.20` | Antipoison cap: reject inbound `FilterAnnounce` frames whose advertised false-positive rate exceeds this value. Valid range `(0.0, 1.0)`. The default `0.20` corresponds to fill 0.7248 at k=5 (≈2,114 entries on the 1 KB filter); a saturated/poisoned filter is still ~100% FPR and rejected |
|
||||
|
||||
Bloom filter size (1 KB), hash count (5), and size classes are protocol
|
||||
constants and not configurable.
|
||||
@@ -413,7 +436,7 @@ Requires `CAP_NET_RAW` or running as root. Linux only.
|
||||
| `mtu` | u16 | *(auto)* | Override MTU. Default: interface MTU minus 3 (for frame type + length prefix) |
|
||||
| `recv_buf_size` | usize | `2097152` | Socket receive buffer size in bytes (2 MB) |
|
||||
| `send_buf_size` | usize | `2097152` | Socket send buffer size in bytes (2 MB) |
|
||||
| `discovery` | bool | `true` | Listen for discovery beacons from other nodes |
|
||||
| `listen` | bool | `true` | Listen for neighbor beacons from other nodes |
|
||||
| `announce` | bool | `false` | Broadcast announcement beacons on the LAN |
|
||||
| `auto_connect` | bool | `false` | Auto-connect to discovered peers |
|
||||
| `accept_connections` | bool | `false` | Accept incoming connection attempts from discovered peers |
|
||||
@@ -427,7 +450,7 @@ transports:
|
||||
ethernet:
|
||||
lan:
|
||||
interface: "eth0"
|
||||
discovery: true
|
||||
listen: true
|
||||
announce: true
|
||||
backbone:
|
||||
interface: "eth1"
|
||||
@@ -435,7 +458,7 @@ transports:
|
||||
```
|
||||
|
||||
Each named instance operates independently with its own socket and
|
||||
discovery state. The instance name is used in log messages and the
|
||||
neighbor state. The instance name is used in log messages and the
|
||||
`name()` method on the Transport trait.
|
||||
|
||||
### TCP (`transports.tcp.*`)
|
||||
@@ -576,6 +599,25 @@ HiddenServiceDir /var/lib/tor/fips
|
||||
HiddenServicePort 8443 127.0.0.1:8444
|
||||
```
|
||||
|
||||
### Nym (`transports.nym.*`)
|
||||
|
||||
Nym transport routes FIPS traffic through the Nym mixnet for
|
||||
metadata-resistant anonymity. Outbound-only: connections are made
|
||||
through a `nym-socks5-client` SOCKS5 proxy that must be running
|
||||
separately (e.g. as a service running alongside the fips daemon or as a
|
||||
container). There is no inbound listener — a Nym-only node initiates
|
||||
outbound links but is not reachable for unsolicited inbound handshakes.
|
||||
|
||||
| Parameter | Type | Default | Description |
|
||||
|-----------|------|---------|-------------|
|
||||
| `transports.nym.socks5_addr` | string | `"127.0.0.1:1080"` | `nym-socks5-client` SOCKS5 proxy address (host:port) |
|
||||
| `transports.nym.connect_timeout_ms` | u64 | `300000` | Outbound connect timeout in milliseconds. Mixnet SOCKS5 connections traverse 3 mix nodes with timing obfuscation and can take several minutes, so this is generous (300s). |
|
||||
| `transports.nym.mtu` | u16 | `1400` | Default MTU |
|
||||
| `transports.nym.startup_timeout_secs` | u64 | `120` | Seconds to wait for `nym-socks5-client` to become ready at startup before giving up |
|
||||
|
||||
**Named instances.** Like other transports, multiple Nym instances can
|
||||
be configured with named sub-keys for different SOCKS5 proxy endpoints.
|
||||
|
||||
### BLE (`transports.ble.*`)
|
||||
|
||||
Bluetooth Low Energy transport using L2CAP Connection-Oriented Channels.
|
||||
@@ -798,7 +840,7 @@ peers:
|
||||
### Mixed UDP + Ethernet Example
|
||||
|
||||
A node bridging internet peers (UDP) and a local Ethernet segment with
|
||||
beacon discovery:
|
||||
neighbor beacons:
|
||||
|
||||
```yaml
|
||||
node:
|
||||
@@ -814,7 +856,7 @@ transports:
|
||||
mtu: 1472
|
||||
ethernet:
|
||||
interface: "eth0"
|
||||
discovery: true
|
||||
listen: true
|
||||
announce: true
|
||||
auto_connect: true
|
||||
accept_connections: true
|
||||
@@ -889,6 +931,9 @@ node:
|
||||
backoff_base_secs: 0
|
||||
backoff_max_secs: 0
|
||||
forward_min_interval_secs: 2
|
||||
# lan: # uncomment to enable mDNS LAN discovery
|
||||
# enabled: true # opt-in, default false
|
||||
# scope: "my-mesh" # optional per-network scope filter
|
||||
tree:
|
||||
announce_min_interval_ms: 500
|
||||
parent_hysteresis: 0.2 # cost improvement fraction for parent switch
|
||||
@@ -899,7 +944,7 @@ node:
|
||||
flap_dampening_secs: 120 # extended hold-down on flap
|
||||
bloom:
|
||||
update_debounce_ms: 500
|
||||
max_inbound_fpr: 0.05 # antipoison cap on inbound FilterAnnounce FPR
|
||||
max_inbound_fpr: 0.20 # antipoison cap on inbound FilterAnnounce FPR
|
||||
session:
|
||||
default_ttl: 64
|
||||
pending_packets_per_dest: 16
|
||||
@@ -954,7 +999,7 @@ transports:
|
||||
# mtu: null # null = interface MTU - 3 (typically 1497)
|
||||
# recv_buf_size: 2097152 # 2 MB
|
||||
# send_buf_size: 2097152 # 2 MB
|
||||
# discovery: true # listen for beacons
|
||||
# listen: true # listen for beacons
|
||||
# announce: false # broadcast beacons
|
||||
# auto_connect: false # connect to discovered peers
|
||||
# accept_connections: false # accept inbound handshakes
|
||||
@@ -983,6 +1028,11 @@ transports:
|
||||
# # bind_addr: "127.0.0.1:8443"
|
||||
# # max_inbound_connections: 64
|
||||
# # advertised_port: 443 # public-facing onion port for Nostr adverts
|
||||
# nym: # uncomment to enable Nym mixnet transport (outbound-only)
|
||||
# socks5_addr: "127.0.0.1:1080" # nym-socks5-client SOCKS5 proxy address
|
||||
# connect_timeout_ms: 300000 # connect timeout (300s for mixnet)
|
||||
# mtu: 1400 # default MTU
|
||||
# startup_timeout_secs: 120 # wait for nym-socks5-client to be ready
|
||||
# ble: # uncomment to enable BLE transport (Linux only, requires BlueZ)
|
||||
# adapter: "hci0" # HCI adapter name
|
||||
# psm: 0x0085 # L2CAP PSM (133)
|
||||
|
||||
@@ -100,21 +100,22 @@ table below lists every command currently registered.
|
||||
|
||||
| Command | Params | `data` shape (top-level keys) |
|
||||
| ------- | ------ | ----------------------------- |
|
||||
| `show_status` | — | `version`, `npub`, `node_addr`, `ipv6_addr`, `state`, `is_leaf_only`, `peer_count`, `session_count`, `link_count`, `transport_count`, `connection_count`, `tun_state`, `tun_name`, `effective_ipv6_mtu`, `control_socket`, `pid`, `exe_path`, `uptime_secs`, `estimated_mesh_size`, `forwarding`, `sparklines`. |
|
||||
| `show_status` | — | `version`, `npub`, `node_addr`, `ipv6_addr`, `state`, `is_leaf_only`, `is_root` (bool — this node is the spanning-tree root), `root` (hex node-addr of the current tree root), `persistent` (bool — identity is persisted, i.e. `persistent` set or an `nsec` configured), `peer_count`, `session_count`, `link_count`, `transport_count`, `connection_count`, `transport_peer_counts` (object mapping transport-type name to its connected-peer count; configured transports appear with `0`), `tun_state`, `tun_name`, `effective_ipv6_mtu`, `control_socket`, `pid`, `exe_path`, `uptime_secs`, `estimated_mesh_size`, `forwarding`, `sparklines`. |
|
||||
| `show_acl` | — | `allow_file`, `deny_file`, `enforcement_active`, `effective_mode`, `default_decision`, `allow_all`, `deny_all`, `allow_file_entries`, `deny_file_entries`, `allow_entries`, `deny_entries`. |
|
||||
| `show_peers` | — | `peers[]` — per-peer object: `node_addr`, `npub`, `display_name`, `ipv6_addr`, `connectivity`, `link_id`, `direction`, `transport_addr`, `transport_type`, `is_parent`, `is_child`, `tree_depth`, `stats`, `noise`, `current_k_bit`, `mmp`, plus optional `nostr_traversal`, `rekey_in_progress`, `rekey_draining`. |
|
||||
| `show_peers` | — | `peers[]` — per-peer object: `node_addr`, `npub`, `display_name`, `ipv6_addr`, `connectivity`, `link_id`, `direction`, `transport_addr`, `transport_type`, `is_parent`, `is_child`, `tree_depth`, `effective_depth` (`tree_depth + link_cost` — the metric `evaluate_parent` ranks on; `null` when the peer has no coords, or is unmeasured while another peer has an SRTT sample, per the cold-start gate), `stats`, `noise`, `current_k_bit`, `mmp`, plus optional `nostr_traversal`, `rekey_in_progress`, `rekey_draining`. |
|
||||
| `show_links` | — | `links[]` — `link_id`, `transport_id`, `remote_addr`, `direction`, `state`, `created_at_ms`, `stats`. |
|
||||
| `show_tree` | — | `my_node_addr`, `root`, `is_root`, `depth`, `my_coords[]`, `parent`, `parent_display_name`, `declaration_sequence`, `declaration_signed`, `peer_tree_count`, `peers[]`, `stats`. |
|
||||
| `show_tree` | — | `my_node_addr`, `root`, `root_npub` (bech32 npub of the current tree root), `is_root`, `depth`, `my_coords[]`, `parent`, `parent_display_name`, `declaration_sequence`, `declaration_signed`, `peer_tree_count`, `peers[]`, `stats`. |
|
||||
| `show_sessions` | — | `sessions[]` — `remote_addr`, `npub`, `display_name`, `state` (`established`, `initiating`, `awaiting_msg3`, `unknown`), `is_initiator`, `last_activity_ms`, `stats`, optional `mmp`, `current_k_bit`, `is_draining`. |
|
||||
| `show_bloom` | — | `own_node_addr`, `is_leaf_only`, `sequence`, `leaf_dependent_count`, `leaf_dependents[]`, `peer_filters[]`, `stats`. |
|
||||
| `show_bloom` | — | `own_node_addr`, `is_leaf_only`, `sequence`, `leaf_dependent_count`, `leaf_dependents[]`, `peer_filters[]`, `uptree_fill_ratio` (fill ratio of the last filter actually sent to the tree parent), `uptree_estimated_count` (cardinality estimate of that uptree filter — this node's whole subtree under split-horizon, not the mesh; both are `null` for a root node or before the first announce), `stats`. |
|
||||
| `show_mmp` | — | `peers[]` (link-layer per peer), `sessions[]` (session-layer per session). Each entry includes loss/RTT/ETX/goodput, smoothed values, trends. |
|
||||
| `show_cache` | — | `count`, `max_entries`, `fill_ratio`, `default_ttl_ms`, `expired`, `avg_age_ms`, `entries[]` — per-destination coords, depth, age, last-used, optional `path_mtu`. |
|
||||
| `show_connections` | — | `connections[]` — pending handshakes: `link_id`, `direction`, `handshake_state`, `started_at_ms`, `idle_ms`, `resend_count`, optional `expected_peer`. |
|
||||
| `show_transports` | — | `transports[]` — `transport_id`, `type`, `state`, `mtu`, `name`, `local_addr`, optional `tor_mode`, `onion_address`, `tor_monitoring`, `stats`. |
|
||||
| `show_routing` | — | `coord_cache_entries`, `identity_cache_entries`, `pending_lookups[]`, `pending_tun_destinations`, `pending_tun_packets`, `recent_requests`, `retries[]`, `forwarding`, `discovery`, `error_signals`, `congestion`. |
|
||||
| `show_routing` | — | `coord_cache_entries`, `identity_cache_entries`, `pending_lookups[]`, `pending_tun_destinations`, `pending_tun_packets`, `recent_requests`, `retries[]`, `forwarding`, `discovery` (request/response sub-counters; includes `req_deduplicated` — requests suppressed as recent duplicates — and `req_dedup_cache_full` — requests admitted because the dedup cache was full), `error_signals`, `congestion`. |
|
||||
| `show_identity_cache` | — | `entries[]`, `count`, `max_entries`. Each entry: `node_addr`, `npub`, `display_name`, `ipv6_addr`, `last_seen_ms`, `age_ms`. |
|
||||
| `show_listening_sockets` | — | `fips0_addr`, `firewall_active` (bool — `inet fips` table loaded), `sockets[]`. Each entry: `proto` (`tcp` / `udp`), `local_addr` (`::` or the node's fd00::/8 address), `port`, `pid` (nullable), `process` (nullable), `wildcard_bind` (bool — `local_addr == ::`), `filter` (`accept` / `drop` / `unknown` / `no_firewall`). Linux-only; returns an empty `sockets[]` on other platforms. |
|
||||
| `show_stats_list` | — | `metrics[]` (each with `name`, `unit`, `scope`), `fast_ring_seconds`, `slow_ring_minutes`, `peer_retention_seconds`. |
|
||||
| `show_metrics` | — | Flat snapshot of every counter family in the metrics registry: `forwarding`, `discovery`, `tree`, `bloom`, `congestion`, `errors`. Each value is that family's counter snapshot object. Counter-only — gauges/histograms that need the live node are excluded. Served off the main loop. Silent-rejection sites classify their reason as a typed `RejectReason` and increment the matching per-family counter exposed here — see [Rejection reasons](#rejection-reasons). |
|
||||
| `show_stats_history` | `metric` (req), `peer` (req for per-peer metrics), `window` (`<N>s` / `<N>m` / `<N>h`, default `10m`), `granularity` (`1s` / `1m`, default `1s`) | A single `Series`: `metric`, `unit`, `granularity_seconds`, `values[]`. |
|
||||
| `show_stats_all_history` | `peer` (optional npub), `window`, `granularity` | `granularity_seconds`, `window_seconds`, `peer`, `series[]` (one per metric). |
|
||||
| `show_stats_peers` | — | `peers[]`, `count`. Each entry: `npub`, `node_addr`, `display_name`, `is_active`, `first_seen_secs_ago`, `last_contact_secs_ago`. |
|
||||
@@ -124,11 +125,35 @@ The schema of each query response is pinned by snapshot tests in
|
||||
`src/control/snapshots/`; intentional schema changes regenerate those
|
||||
fixtures.
|
||||
|
||||
### Rejection reasons
|
||||
|
||||
Silent-rejection paths across the node classify why a message was
|
||||
dropped via a typed `RejectReason` rather than only logging it, so the
|
||||
*what* of a rejection is visible in the counter snapshots above. The
|
||||
top-level reason set has eight families, mirroring the protocol-layer /
|
||||
subsystem split of the metrics:
|
||||
|
||||
- **Tree** — spanning-tree `TreeAnnounce` processing rejections.
|
||||
- **Bloom** — bloom-filter `FilterAnnounce` processing rejections.
|
||||
- **Discovery** — discovery request / response processing rejections.
|
||||
- **Handshake** — Noise handshake state-machine rejections.
|
||||
- **Session** — FSP session state-machine rejections.
|
||||
- **Mmp** — MMP link-layer rejections.
|
||||
- **Forwarding** — forwarding-path rejections (no-route, TTL, MTU).
|
||||
- **Transport** — transport-layer rejections (admission caps, framing).
|
||||
|
||||
Each rejection increments the corresponding counter in its family's
|
||||
stats, surfaced through `show_metrics` (the `tree`, `bloom`,
|
||||
`discovery`, and `forwarding` families carry their own counters; the
|
||||
`errors` family and the remaining subsystem counters carry the rest).
|
||||
The full per-family variant list lives in `src/node/reject.rs`; it is
|
||||
not reproduced here to avoid duplicating the source.
|
||||
|
||||
### Mutating commands
|
||||
|
||||
| Command | Required params | Behaviour |
|
||||
| ------- | --------------- | --------- |
|
||||
| `connect` | `npub` (bech32), `address` (transport endpoint), `transport` (`udp`, `tcp`, `tor`, `ethernet`) | Asks the node to dial the peer over the named transport. Returns the API result on success or an error string on failure. |
|
||||
| `connect` | `npub` (bech32), `address` (transport endpoint), `transport` (`udp`, `tcp`, `tor`, `nym`, `ethernet`) | Asks the node to dial the peer over the named transport. The named transport must be configured and running. Returns the API result on success or an error string on failure. |
|
||||
| `disconnect` | `npub` (bech32) | Asks the node to drop the link to the named peer. |
|
||||
|
||||
Both commands run on the daemon's main task and may block briefly
|
||||
|
||||
@@ -209,7 +209,7 @@ for the metadata-privacy model and the rejection of onion routing.
|
||||
| --------- | --------------- | ------------ | ------ |
|
||||
| UDP | None until `bind_addr` set | `0.0.0.0:2121` typical | Operator sets `transports.udp.bind_addr` |
|
||||
| TCP | None until `bind_addr` set | None — outbound-only without bind | Operator sets `transports.tcp.bind_addr` |
|
||||
| Ethernet | Listens on configured interface (raw `AF_PACKET`) | EtherType 0x2121 on selected interface | Per-flag `discovery`, `announce`, `auto_connect`, `accept_connections` |
|
||||
| Ethernet | Listens on configured interface (raw `AF_PACKET`) | EtherType 0x2121 on selected interface | Per-flag `listen`, `announce`, `auto_connect`, `accept_connections` |
|
||||
| Tor | None until `directory_service` configured | `127.0.0.1:8443` (loopback only) | Operator sets `transports.tor.directory_service` and configures `HiddenServiceDir` in `torrc` |
|
||||
| BLE | Off by default | n/a | Operator enables `transports.ble.*` |
|
||||
| Nostr discovery | Off by default | n/a (relay client, not a listener) | Operator sets `node.discovery.nostr.enabled: true` |
|
||||
|
||||
@@ -34,6 +34,8 @@ module.
|
||||
| `connections_rejected` | Rejected inbound connections (limit exceeded) |
|
||||
| `connect_timeouts` | Connection timeout count |
|
||||
| `connect_refused` | Connection refused count |
|
||||
| `pool_inbound` | Current inbound connections held in the connection pool (gauge) |
|
||||
| `pool_outbound` | Current outbound connections held in the connection pool (gauge) |
|
||||
|
||||
## Ethernet
|
||||
|
||||
@@ -61,6 +63,23 @@ module.
|
||||
| `connections_accepted` | Accepted inbound connections via onion service |
|
||||
| `connections_rejected` | Rejected inbound connections (limit exceeded) |
|
||||
| `control_errors` | Tor control port errors |
|
||||
| `pool_inbound` | Current inbound connections held in the connection pool (gauge) |
|
||||
| `pool_outbound` | Current outbound connections held in the connection pool (gauge) |
|
||||
|
||||
## Nym
|
||||
|
||||
| Counter | Description |
|
||||
| ------- | ----------- |
|
||||
| `packets_sent` / `bytes_sent` | Successful sends |
|
||||
| `packets_recv` / `bytes_recv` | Successful receives |
|
||||
| `send_errors` / `recv_errors` | Send/receive failures |
|
||||
| `mtu_exceeded` | Packets rejected for MTU violation |
|
||||
| `connections_established` | Successful SOCKS5 connections through `nym-socks5-client` |
|
||||
| `connect_timeouts` | Connection timeout count |
|
||||
| `socks5_errors` | SOCKS5 protocol errors |
|
||||
|
||||
Nym is outbound-only (no inbound listener), so there are no
|
||||
`connections_accepted` / `connections_rejected` counters.
|
||||
|
||||
## Bluetooth
|
||||
|
||||
|
||||
321
docs/releases/release-notes-v0.4.0.md
Normal file
321
docs/releases/release-notes-v0.4.0.md
Normal file
@@ -0,0 +1,321 @@
|
||||
# FIPS v0.4.0
|
||||
|
||||
**Released**: 2026-06-27
|
||||
|
||||
v0.4.0 is the throughput-and-observability release on the v0.3.x wire
|
||||
format. It adds two new ways for nodes to find and reach each other (the
|
||||
Nym mixnet transport and opt-in mDNS LAN discovery), overhauls the data
|
||||
plane for higher single-node throughput and lower per-packet CPU, moves
|
||||
the entire operator read surface off the data-plane hot path so
|
||||
observability stays responsive under load, ships a reworked `fipstop`
|
||||
TUI, and hardens FMP and FSP rekey to be hitless under packet loss in
|
||||
both directions. It also folds in the accumulated mesh-convergence,
|
||||
admission-control, and packaging fixes from the maintenance line.
|
||||
|
||||
v0.4.0 is wire-compatible with v0.3.0. Mixed meshes interoperate; there
|
||||
is no flag-day upgrade. A deployed v0.3.0 node and an upgraded v0.4.0
|
||||
node peer, rekey, and route normally, so you can roll the upgrade out
|
||||
across a mesh in any order.
|
||||
|
||||
## At a glance
|
||||
|
||||
- New outbound Nym mixnet transport with a single-container demo and a
|
||||
new mixnet-relay example.
|
||||
- Opt-in mDNS / DNS-SD discovery on the local link.
|
||||
- Data-plane overhaul: off-task encrypt and decrypt worker pools, GSO,
|
||||
connected-UDP send path, copy-avoidance on receive, batched macOS
|
||||
receive.
|
||||
- The full `show_*` read surface now serves off the receive loop, so
|
||||
`fipsctl` and `fipstop` stay responsive on loaded nodes; a new
|
||||
counter-only `show_metrics` query enables a Prometheus scraper at no
|
||||
hot-path cost.
|
||||
- Reworked `fipstop` TUI on a machine-verified render-snapshot base.
|
||||
- Rekey is now hitless under loss and reordering in both directions.
|
||||
- New packaging targets: an OpenWrt `.apk` for OpenWrt 25+ and a Nix
|
||||
flake for reproducible from-source builds on Nix/NixOS.
|
||||
- Six route-class transit counters partition forwarded traffic by its
|
||||
tree relationship to the next hop, visible via `show_routing` and
|
||||
`show_status`.
|
||||
|
||||
## What's new
|
||||
|
||||
### Nym mixnet transport
|
||||
|
||||
FIPS can now peer over the [Nym](https://nymtech.net/) mixnet for
|
||||
metadata-resistant connectivity. The new `transports.nym` transport
|
||||
makes outbound connections through a `nym-socks5-client` SOCKS5 proxy
|
||||
that you run alongside the daemon (for example as a service running
|
||||
alongside the fips daemon, or as a sidecar container). The transport
|
||||
waits at startup for the nym-socks5-client to become ready before giving
|
||||
up.
|
||||
|
||||
This is a privacy and anonymity deployment mode chosen for its own
|
||||
properties. It mixes your FIPS traffic into the Nym cover-traffic
|
||||
network so that link-level observers cannot correlate which mesh peers
|
||||
are talking. A new `examples/sidecar-nostr-mixnet-relay/` demonstrates a
|
||||
FIPS-reachable Nostr relay peered across the mixnet end to end, and a
|
||||
single-container demo ships with the transport.
|
||||
|
||||
Enable it by adding a `transports.nym` instance and pointing it at your
|
||||
running nym-socks5-client. See the transports reference for the field
|
||||
set.
|
||||
|
||||
### mDNS LAN discovery
|
||||
|
||||
Nodes on a shared local link can now find each other with zero address
|
||||
configuration. The opt-in `node.discovery.lan` path runs an mDNS /
|
||||
DNS-SD responder and browser: each node advertises a FIPS service record
|
||||
on the link and adopts the peers it discovers. This complements the
|
||||
existing Nostr-mediated overlay discovery for the common case where the
|
||||
peers are simply on the same LAN.
|
||||
|
||||
Turn it on with `node.discovery.lan.enabled: true`. `service_type` and
|
||||
`scope` tune the advertised service record and which interfaces
|
||||
participate. Discovery on the local link needs no relay and no STUN.
|
||||
|
||||
### Data-plane throughput overhaul
|
||||
|
||||
The receive and send paths were reworked for higher single-node
|
||||
throughput and lower per-packet CPU, building on the v0.3.0
|
||||
crypto-backend swap:
|
||||
|
||||
- **Off-task encrypt and decrypt.** Per-peer encrypt and decrypt now run
|
||||
on dedicated worker tasks rather than inline on the receive loop, so a
|
||||
single busy peer no longer serializes the whole node's crypto.
|
||||
- **GSO and connected-UDP send.** The Linux send path uses generic
|
||||
segmentation offload and a connected-UDP socket where available,
|
||||
cutting syscall overhead on bulk flows.
|
||||
- **Copy-avoidance on receive.** The receive hot path avoids buffer
|
||||
copies it previously made per packet.
|
||||
- **Batched macOS receive.** macOS gains a `recvmsg_x` batched receive,
|
||||
mirroring the Linux `recvmmsg` batching from v0.3.0.
|
||||
- **Shared immutable-state context and an atomic metric registry.**
|
||||
Immutable per-node state moved into a single shared context, and
|
||||
counters live in an atomic metric registry that the new `show_metrics`
|
||||
query reads without touching the hot path.
|
||||
|
||||
These are all internal to the data plane and require no operator action.
|
||||
|
||||
### Observability off the hot path
|
||||
|
||||
Every read-only control query now renders from a snapshot published once
|
||||
per tick into a lock-free `ArcSwap`, served from the control accept task
|
||||
instead of round-tripping the data-plane receive loop. This covers
|
||||
`show_status`, `show_stats_*`, `show_peers`, `show_sessions`,
|
||||
`show_links`, `show_connections`, `show_transports`, `show_mmp`,
|
||||
`show_tree`, `show_bloom`, `show_cache`, `show_routing`,
|
||||
`show_identity_cache`, `show_acl`, `show_listening_sockets`, and the new
|
||||
`show_metrics`. Only the mutating `connect` and `disconnect` commands
|
||||
still reach the loop.
|
||||
|
||||
The practical effect: on a loaded node where the receive loop was busy,
|
||||
`fipsctl` and `fipstop` queries previously stalled or timed out (the
|
||||
five-second query pattern operators saw). They now answer promptly
|
||||
regardless of data-plane load. Per-entity snapshots reuse unchanged rows
|
||||
by pointer, so the per-tick publish cost stays bounded as peer and
|
||||
session counts grow.
|
||||
|
||||
A new **`show_metrics`** query (surfaced as `fipsctl stats metrics`)
|
||||
returns a counter-only snapshot of every metric family. It is the
|
||||
enabler for a Prometheus scraper that pulls node counters at no hot-path
|
||||
cost.
|
||||
|
||||
Six **route-class transit counters** partition transit-forwarded packets
|
||||
by their tree relationship to the chosen next hop — tree-up, tree-down,
|
||||
tree-down-cross, cross-link descend, cross-link ascend, and direct-peer
|
||||
— and the six classes sum to `forwarded_packets`. They surface through
|
||||
`show_routing` and `show_status`, and the `fipstop` routing tab is
|
||||
reorganized so its two columns separate own/endpoint traffic from
|
||||
forwarded/transit traffic with the tree-down-cross line visually flagged.
|
||||
|
||||
### Reworked fipstop TUI
|
||||
|
||||
`fipstop` gets a rendering, navigation, and read-surface overhaul on a
|
||||
machine-verified base: a render-snapshot harness asserts the exact text
|
||||
grid and per-cell style of every view against canned control-socket
|
||||
output. New daemon-resolved fields surface through the snapshots,
|
||||
including effective persistence, root and is-root state, a
|
||||
per-transport-type peer-count map, per-peer effective depth, the root
|
||||
npub, and the last-sent uptree filter fill ratio with the subtree size
|
||||
estimate.
|
||||
|
||||
A separate fix clears a garbled-screen problem on startup and stray
|
||||
bytes on quit, most visible over SSH and inside tmux: startup now forces
|
||||
a full repaint before the first draw, and quit stops and joins the
|
||||
stdin-poll thread before restoring the terminal, so post-raw-mode
|
||||
keystrokes no longer echo onto the restored screen.
|
||||
|
||||
### Rekey reliability
|
||||
|
||||
FMP and FSP session rekey are now hitless under packet loss and
|
||||
reordering in both directions:
|
||||
|
||||
- Inbound frames are authenticated against the pending session before
|
||||
the K-bit cutover promotes it, so a spoofed or stale frame cannot
|
||||
derail a rekey in progress.
|
||||
- Rekey message-1 retransmission is bounded, and the link-dead heartbeat
|
||||
is rekey-aware so an in-flight rekey is not mistaken for a dead link.
|
||||
- FSP session rekey holds connectivity across the rekey window under
|
||||
loss and reordering.
|
||||
- Dual-initiation races (both peers starting a rekey at once on a
|
||||
high-latency link) are desynchronized with symmetric jitter so the two
|
||||
sides converge on one session rather than fighting.
|
||||
- An exhausted retransmission-budget abort, an expected and self-limiting
|
||||
outcome on lossy or high-latency links, is logged at debug rather than
|
||||
warn.
|
||||
|
||||
The net operator takeaway: rekey completes cleanly without dropping
|
||||
traffic, even on lossy or high-latency links, and the log no longer
|
||||
cries wolf when a rekey gives up and retries.
|
||||
|
||||
### New packaging targets
|
||||
|
||||
- **OpenWrt `.apk`.** A new `.apk` package targets OpenWrt 25+, where
|
||||
apk-tools is the mandatory package manager; the existing `.ipk`
|
||||
continues to cover OpenWrt 24.x and earlier. It is built SDK-free,
|
||||
reusing the `.ipk` cross-compile and installed-filesystem payload, and
|
||||
releases publish `.apk` artifacts and checksums alongside `.ipk`. Like
|
||||
the `.ipk`, the package is unsigned and installed with
|
||||
`apk add --allow-untrusted`.
|
||||
- **Nix flake.** A `flake.nix` at the project root builds all four
|
||||
binaries (`fips`, `fipsctl`, `fips-gateway`, `fipstop`) from source on
|
||||
Nix/NixOS, pinning the exact toolchain and wiring the native build
|
||||
dependencies so no host setup is needed beyond Nix with flakes
|
||||
enabled. It exposes `nix build`, `nix run`, a `nix develop` dev shell,
|
||||
and `nix flake check`, with `flake.lock` committed for reproducibility.
|
||||
|
||||
## Behavior changes worth flagging
|
||||
|
||||
These affect operators on upgrade.
|
||||
|
||||
- **Bloom filter antipoison cap raised.** `node.bloom.max_inbound_fpr`
|
||||
moves from 0.05 to 0.10, accepting filters with a higher derived
|
||||
false-positive rate before rejecting them. This reduces spurious
|
||||
filter rejections on larger meshes while keeping the antipoison
|
||||
protection in place.
|
||||
- **TCP inbound cap honors `max_connections`.** The TCP inbound accept
|
||||
ceiling now resolves from explicit per-transport
|
||||
`max_inbound_connections`, then node-wide
|
||||
`node.limits.max_connections`, then the built-in default of 256.
|
||||
Previously the TCP inbound ceiling was hardwired to 256 and ignored
|
||||
`max_connections`, so raising it had no effect on inbound TCP.
|
||||
- **Static host aliases hot-reload.** `/etc/fips/hosts` now reloads on
|
||||
mtime change once per tick rather than only at startup, so display
|
||||
names in `fipsctl` and `fipstop` reflect edits without a daemon
|
||||
restart. The peer ACL reloads through the same lock-free snapshot
|
||||
mechanism.
|
||||
- **Quieter logs on busy public-mesh nodes.** Routine per-peer
|
||||
connection-lifecycle and capacity-cap events, no-route session-datagram
|
||||
drops, and exhausted rekey-budget aborts are demoted to debug, so
|
||||
genuinely notable info and warn lines are no longer drowned out.
|
||||
- **More visible drops.** Receive-path silent rejections now flow
|
||||
through typed reject-reason counters, and discovery counts requests
|
||||
dropped when the dedup cache is full (`req_dedup_cache_full`, visible
|
||||
via `show_routing`). Drops that were previously silent are now
|
||||
countable.
|
||||
- **Tor connect-refused accounting.** The Tor transport increments its
|
||||
`connect_refused` statistic (the "Refused" line in `fipstop`) on an
|
||||
actively-refused SOCKS5 connect, instead of recording every connect
|
||||
failure as a generic SOCKS5 error.
|
||||
|
||||
## Notable bug fixes
|
||||
|
||||
The CHANGELOG has the exhaustive list. This is the operator-relevant
|
||||
subset of fixes for behavior that shipped in v0.3.0.
|
||||
|
||||
- **Symmetric peer teardown on manual disconnect.** A manual
|
||||
`fipsctl disconnect` now sends the peer a scoped Disconnect so both
|
||||
ends tear down and re-handshake cleanly. Previously a manual
|
||||
disconnect tore down only the local side, leaving the peer with a
|
||||
stale session that was never re-adopted as a child and whose bloom
|
||||
filter was never re-recorded.
|
||||
- **Gateway holds long-lived and DNS-cached mappings.** `fips-gateway`
|
||||
no longer drops a virtual-IP mapping while traffic is still flowing.
|
||||
The mapping TTL clock previously advanced only on DNS re-query, so a
|
||||
busy long-lived or DNS-cached client could have its mapping reclaimed
|
||||
mid-flow. The tick now refreshes the mapping whenever conntrack reports
|
||||
active sessions and recovers a draining mapping to active when traffic
|
||||
resumes; only genuinely idle mappings drain.
|
||||
- **Accurate mesh-size estimate under filter overlap.** The mesh-size
|
||||
estimator now estimates the cardinality of the OR-union of self plus
|
||||
every connected peer's inbound filter, instead of summing per-filter
|
||||
cardinalities of tree peers. Summing assumed the filters were disjoint,
|
||||
so a stale or oversized parent filter or a routing loop inflated the
|
||||
reported mesh size and a tree rebalance flapped the count. OR-union
|
||||
deduplicates overlap, equals the old result in the disjoint case, and
|
||||
removes the estimate's dependence on tree-declaration cache freshness.
|
||||
- **Single-uplink node reattaches within a round-trip.** A node with one
|
||||
tree peer, which has periodic parent re-evaluation disabled, was left
|
||||
self-rooted and unreachable if its one-shot attaching TreeAnnounce was
|
||||
lost, until the next periodic re-broadcast. Tree-position exchange is
|
||||
now self-healing on the receive path: a node that hears an announce
|
||||
advertising a strictly worse root echoes its own declaration back,
|
||||
provoking the better-rooted peer to re-push its real position
|
||||
immediately.
|
||||
- **macOS self-connections work end to end (#117).** Traffic a macOS
|
||||
node sends to its own `<npub>.fips` address is now delivered locally
|
||||
for full TCP/UDP, not just `ping6`. The point-to-point `utun` egresses
|
||||
self-addressed packets into the daemon with an unfinished transport
|
||||
checksum (macOS offloads it on the `lo0` loopback route), so
|
||||
re-injecting them verbatim made the local stack drop every segment the
|
||||
MSS-clamp rewrite did not happen to fix and self-connections
|
||||
half-opened and hung. The hairpin path now recomputes the TCP/UDP
|
||||
checksum before re-injection. Linux was unaffected.
|
||||
|
||||
## Upgrade notes
|
||||
|
||||
Operator-actionable items moving from v0.3.0 to v0.4.0:
|
||||
|
||||
- **Wire-compatible, no flag day.** v0.4.0 peers with v0.3.0. Upgrade
|
||||
nodes in any order. During a rolling upgrade you may see some log lines
|
||||
on the upgraded side as it interacts with not-yet-upgraded peers;
|
||||
behavior is correct, log noise only.
|
||||
- **Bloom antipoison cap default changed.** `node.bloom.max_inbound_fpr`
|
||||
now defaults to 0.10 (was 0.05). If you set this explicitly, review
|
||||
whether you still want the old value.
|
||||
- **New optional config surfaces.** `transports.nym` (outbound Nym
|
||||
mixnet) and `node.discovery.lan` (mDNS LAN discovery) are both opt-in
|
||||
and off by default. Adding them is the only way to turn the new paths
|
||||
on.
|
||||
- **TCP inbound cap.** If you relied on the old hardwired 256 inbound-TCP
|
||||
ceiling, note it now honors `max_inbound_connections` then
|
||||
`node.limits.max_connections` then 256.
|
||||
- **New observability query.** `fipsctl stats metrics` (the
|
||||
`show_metrics` control query) returns a counter-only snapshot suitable
|
||||
for a scraper.
|
||||
|
||||
## Getting v0.4.0
|
||||
|
||||
- **Linux x86_64 / aarch64**: `.deb` and tarball at the
|
||||
[v0.4.0 release page](https://github.com/jmcorgan/fips/releases/tag/v0.4.0).
|
||||
- **Arch Linux**: `fips` from the AUR.
|
||||
- **macOS**: `.pkg` at the v0.4.0 release page.
|
||||
- **Windows**: ZIP at the v0.4.0 release page.
|
||||
- **OpenWrt**: `.ipk` (OpenWrt 24.x and earlier) or `.apk` (OpenWrt 25+)
|
||||
at the v0.4.0 release page.
|
||||
- **From source**: `cargo build --release` from a checkout of the v0.4.0
|
||||
tag (Rust 1.94.1 per `rust-toolchain.toml`; `libclang-dev` is a
|
||||
required Linux build prerequisite).
|
||||
- **Nix / NixOS**: `nix build .#fips` from a checkout of the v0.4.0 tag
|
||||
builds the binaries from source with the pinned toolchain and no manual
|
||||
prerequisites (see the Nix section of `packaging/README.md`).
|
||||
|
||||
The full per-commit changelog lives in
|
||||
[`CHANGELOG.md`](../../CHANGELOG.md). Issues and discussion at
|
||||
[github.com/jmcorgan/fips](https://github.com/jmcorgan/fips).
|
||||
|
||||
## Contributors
|
||||
|
||||
Thanks to everyone who contributed code, packaging work, bug reports, or
|
||||
reviews to this release.
|
||||
|
||||
- [@jcorgan](https://github.com/jmcorgan): release shepherd, high-level
|
||||
design, control read plane, rekey hardening, admission, bug fixes,
|
||||
testing, packaging, PR coordination, and issue resolution.
|
||||
- [@mmalmi](https://github.com/mmalmi): opt-in mDNS LAN discovery and
|
||||
data-plane performance work.
|
||||
- [@Origami74](https://github.com/Origami74): macOS packaging and
|
||||
website coordination.
|
||||
- [@dskvr](https://github.com/dskvr): AUR packaging.
|
||||
- [@oleksky](https://github.com/oleksky): Nym mixnet transport and the
|
||||
single-container mixnet demo.
|
||||
146
docs/releases/release-notes-v0.4.1.md
Normal file
146
docs/releases/release-notes-v0.4.1.md
Normal file
@@ -0,0 +1,146 @@
|
||||
# FIPS v0.4.1
|
||||
|
||||
**Released**: 2026-07-19
|
||||
|
||||
v0.4.1 is a maintenance release on the v0.4.x line. It raises the default
|
||||
antipoison cap on inbound bloom filter announcements, removes a redundant
|
||||
spanning-tree metric counter, fixes two convergence and path-MTU bugs, and
|
||||
cuts per-packet CPU in the bloom and identity paths. There is no wire
|
||||
format change and no new feature surface.
|
||||
|
||||
v0.4.1 is wire-compatible with v0.4.0. Nodes can be upgraded one at a time
|
||||
with no coordinated restart, though one behavior change below is worth
|
||||
reading before you start a rolling upgrade.
|
||||
|
||||
## At a glance
|
||||
|
||||
- `node.bloom.max_inbound_fpr` default moves from `0.10` to `0.20`.
|
||||
- The `parent_switched` metric counter is gone. Use `parent_switches`.
|
||||
- Spanning tree no longer serves stale coordinates after a parent link is
|
||||
lost through peer removal.
|
||||
- Discovery no longer loosens a path MTU clamp it had correctly tightened.
|
||||
- Bloom probing and identity operations do measurably less work per call,
|
||||
with identical results.
|
||||
|
||||
## Behavior changes worth flagging
|
||||
|
||||
### The inbound filter FPR cap default doubles again
|
||||
|
||||
`node.bloom.max_inbound_fpr` goes from `0.10` to `0.20`. The cap rejects
|
||||
inbound `FilterAnnounce` frames whose advertised false positive rate
|
||||
exceeds it. On the fixed 1 KB, k=5 filter, `0.10` corresponds to a fill of
|
||||
0.631 and roughly 1,630 reachable entries, and the busiest nodes'
|
||||
aggregates had started reaching that ceiling as the mesh grew. `0.20`
|
||||
corresponds to a fill of 0.7248 and roughly 2,114 entries.
|
||||
|
||||
Be aware that this is the second time in two releases that this default
|
||||
has doubled, for the same reason both times. That is worth stating plainly
|
||||
rather than repeating the previous release's framing: raising the cap buys
|
||||
headroom, it does not fix anything. The real constraint is the fixed 1 KB
|
||||
filter size, which is a protocol constant. The structural remedy is the v2
|
||||
filter work, where filter capacity scales with the mesh instead of being
|
||||
pinned. This release is an interim step to keep legitimate aggregates from
|
||||
being rejected until that lands. It is not the start of a pattern of
|
||||
raising the cap once per release, and if you are sizing capacity planning
|
||||
around this number, plan against the v2 work rather than against a third
|
||||
raise.
|
||||
|
||||
The antipoison property the cap exists for is preserved. A saturated or
|
||||
deliberately poisoned filter still presents an FPR near 100% and is still
|
||||
rejected.
|
||||
|
||||
**This matters during a rolling upgrade.** A v0.4.1 node accepts a
|
||||
`FilterAnnounce` with a derived FPR between 0.10 and 0.20; a v0.4.0 node
|
||||
drops the same frame, and the drop is silent on the wire with no NACK. The
|
||||
cap also gates the mesh size estimator, which declines to produce a value
|
||||
when any contributing filter is over the cap. So while a mesh is partly
|
||||
upgraded, upgraded and not-yet-upgraded nodes can legitimately report
|
||||
different mesh sizes, or one can report a size while the other reports
|
||||
unknown. This resolves once every node is on v0.4.1. If you want to avoid
|
||||
the window entirely, set `node.bloom.max_inbound_fpr: 0.10` explicitly in
|
||||
your config before upgrading and remove it after the last node is done.
|
||||
|
||||
### The `parent_switched` counter is removed
|
||||
|
||||
`parent_switched` was incremented on the line immediately before
|
||||
`parent_switches` at every site and never independently, so the two
|
||||
counters always held the same value. `parent_switched` is now gone from
|
||||
the tree metrics, the control socket snapshot, and the `fipstop` tree
|
||||
view. `parent_switches` remains and is unchanged.
|
||||
|
||||
If you scrape the control socket, or have dashboards or alerts referencing
|
||||
`parent_switched`, point them at `parent_switches`. Anything still asking
|
||||
for `parent_switched` will find nothing rather than a zero.
|
||||
|
||||
## Notable bug fixes
|
||||
|
||||
### Stale coordinates after losing a parent through peer removal
|
||||
|
||||
When a node's parent link dropped via peer removal, the node correctly
|
||||
reparented or self-rooted, but skipped the coordinate cache invalidation
|
||||
that every other position-change path performs. Cached entries for
|
||||
downstream destinations kept the node's old coordinate prefix. This did
|
||||
not self-correct the way a stale cache entry normally would: routing
|
||||
access refreshes an entry's TTL, so an entry that was actively being
|
||||
routed through never expired, and was only fixed by an unrelated fresh
|
||||
insert. Both invalidation classes now run on this path, matching the
|
||||
loop-detection branch.
|
||||
|
||||
### Discovery could loosen a tightened path MTU clamp
|
||||
|
||||
An originator handling a `LookupResponse` overwrote its cached path MTU
|
||||
unconditionally. If a reactive `MtuExceeded` or `PathMtuNotification` had
|
||||
already taught it a tighter value, a later, looser discovery estimate
|
||||
would clobber that and re-loosen the clamp, risking a return to dropped
|
||||
oversized packets. The cached and received values are now compared and the
|
||||
tighter one is kept.
|
||||
|
||||
## Upgrade notes
|
||||
|
||||
This is a drop-in upgrade from v0.4.0 with no wire format change, no
|
||||
config migration, and no coordinated restart. Upgrade nodes in whatever
|
||||
order you like.
|
||||
|
||||
Two things to do rather than assume:
|
||||
|
||||
1. If you monitor `parent_switched`, move to `parent_switches` before
|
||||
upgrading, or your dashboards will go blank rather than error.
|
||||
2. During the rolling window, expect upgraded and not-yet-upgraded nodes
|
||||
to potentially disagree about mesh size, per the FPR cap section above.
|
||||
This is expected and self-resolves. Do not chase it as a bug unless it
|
||||
persists after every node reports `0.4.1`.
|
||||
|
||||
If you have pinned `node.bloom.max_inbound_fpr` explicitly in your config,
|
||||
your setting is honored and nothing changes for you. The change only
|
||||
affects nodes taking the default.
|
||||
|
||||
Downgrading to v0.4.0 is supported and needs no special handling.
|
||||
|
||||
## Getting v0.4.1
|
||||
|
||||
- **Linux x86_64 / aarch64**: `.deb` and tarball at the
|
||||
[v0.4.1 release page](https://github.com/jmcorgan/fips/releases/tag/v0.4.1).
|
||||
- **Arch Linux**: `fips` from the AUR.
|
||||
- **macOS**: `.pkg` at the v0.4.1 release page.
|
||||
- **Windows**: ZIP at the v0.4.1 release page.
|
||||
- **OpenWrt**: `.ipk` (OpenWrt 24.x and earlier) or `.apk` (OpenWrt 25+)
|
||||
at the v0.4.1 release page.
|
||||
- **From source**: `cargo build --release` from a checkout of the v0.4.1
|
||||
tag (Rust 1.94.1 per `rust-toolchain.toml`; `libclang-dev` is a
|
||||
required Linux build prerequisite).
|
||||
- **Nix / NixOS**: `nix build .#fips` from a checkout of the v0.4.1 tag
|
||||
builds the binaries from source with the pinned toolchain and no manual
|
||||
prerequisites (see the Nix section of `packaging/README.md`).
|
||||
|
||||
The full per-commit changelog lives in
|
||||
[`CHANGELOG.md`](../../CHANGELOG.md). Issues and discussion at
|
||||
[github.com/jmcorgan/fips](https://github.com/jmcorgan/fips).
|
||||
|
||||
## Contributors
|
||||
|
||||
Thanks to everyone who contributed code, packaging work, bug reports, or
|
||||
reviews to this release.
|
||||
|
||||
- [@jcorgan](https://github.com/jmcorgan): release shepherd, spanning-tree
|
||||
and discovery fixes, bloom and identity performance work, antipoison cap
|
||||
change, and testing.
|
||||
@@ -71,7 +71,7 @@ supplies the rest:
|
||||
- **Addressing**: the `fips0` adapter takes an `fd97:...` ULA
|
||||
derived from the npub. No DHCP. No SLAAC. The address is
|
||||
cryptographically tied to the identity.
|
||||
- **Discovery**: each daemon broadcasts a small beacon on the
|
||||
- **Neighbor detection**: each daemon broadcasts a small beacon on the
|
||||
link advertising its npub; the other daemon's listener picks
|
||||
it up and dials in over the same link.
|
||||
- **Routing**: the FIPS mesh layer builds its own spanning tree
|
||||
@@ -177,7 +177,7 @@ different interface names — that is normal.
|
||||
|
||||
Edit `/etc/fips/fips.yaml` on **both** nodes. Under
|
||||
`transports:`, add an `ethernet:` block. The key settings are
|
||||
the four discovery flags — both nodes must opt in to all four,
|
||||
the four neighbor flags — both nodes must opt in to all four,
|
||||
and they default to off:
|
||||
|
||||
```yaml
|
||||
@@ -185,7 +185,7 @@ transports:
|
||||
ethernet:
|
||||
interface: "<eth>" # the name from Step 1
|
||||
announce: true # broadcast our beacon on the link
|
||||
discovery: true # listen for beacons (default; shown for clarity)
|
||||
listen: true # listen for beacons (default; shown for clarity)
|
||||
auto_connect: true # dial peers we discover
|
||||
accept_connections: true # accept dial-ins from peers we discover
|
||||
```
|
||||
@@ -194,7 +194,7 @@ Each flag does one thing:
|
||||
|
||||
- `announce: true` — emit a small beacon every
|
||||
`beacon_interval_secs` (default 30s) carrying our npub.
|
||||
- `discovery: true` — listen for incoming beacons; populate a
|
||||
- `listen: true` — listen for incoming beacons; populate a
|
||||
candidate-peer list keyed by source MAC and observed npub.
|
||||
- `auto_connect: true` — when we see a beacon from an npub
|
||||
we have not yet peered with, initiate the outbound Noise
|
||||
@@ -218,7 +218,7 @@ is "all four flags on both ends."
|
||||
> lan:
|
||||
> interface: "eth0"
|
||||
> announce: true
|
||||
> discovery: true
|
||||
> listen: true
|
||||
> auto_connect: true
|
||||
> accept_connections: true
|
||||
> dongle:
|
||||
@@ -227,7 +227,7 @@ is "all four flags on both ends."
|
||||
> # ...
|
||||
> ```
|
||||
>
|
||||
> Each named instance runs its own socket and discovery state.
|
||||
> Each named instance runs its own socket and neighbor state.
|
||||
> A single ground-up link only needs the flat form shown
|
||||
> first; named instances become useful when the same node
|
||||
> bridges multiple physical segments.
|
||||
@@ -390,7 +390,7 @@ What you do need on the AP side:
|
||||
networks and "secure" enterprise APs ship with it on.
|
||||
When client isolation is on, the AP refuses to forward
|
||||
station-to-station frames — the broadcast beacons never
|
||||
arrive at the other node, and discovery fails silently.
|
||||
arrive at the other node, and neighbor detection fails silently.
|
||||
If beacons aren't crossing, this is the first thing to
|
||||
check.
|
||||
|
||||
@@ -401,7 +401,7 @@ adapter name.
|
||||
### Bluetooth LE (experimental but works)
|
||||
|
||||
BLE is a separate transport (`transports.ble.*`) with its own
|
||||
discovery model — L2CAP advertisements rather than raw L2
|
||||
neighbor-detection model — L2CAP advertisements rather than raw L2
|
||||
broadcasts. The shape of the tutorial is the same (advertise +
|
||||
scan + auto-connect + accept), but the prerequisites are
|
||||
different: BlueZ, `bluetoothd`, an HCI adapter, and the
|
||||
@@ -424,7 +424,7 @@ Windows builds skip it.
|
||||
a radio link), `CAP_NET_RAW`, and a few config flags on each
|
||||
end are sufficient. The mesh supplies its own identity,
|
||||
addressing, discovery, and routing.
|
||||
- **Discovery is a four-flag opt-in.** `announce`, `discovery`,
|
||||
- **Neighbor detection is a four-flag opt-in.** `announce`, `listen`,
|
||||
`auto_connect`, and `accept_connections` each control one
|
||||
thing; both ends must agree before a link will form.
|
||||
- **The two modes coexist.** Overlay peers and ground-up peers
|
||||
|
||||
@@ -98,11 +98,10 @@ is what you want.
|
||||
Or via the daemon:
|
||||
|
||||
```sh
|
||||
sudo fipsctl show identities
|
||||
sudo fipsctl show status
|
||||
```
|
||||
|
||||
The first JSON entry has `local: true` and a `ula` field — that
|
||||
is your address.
|
||||
The JSON has an `ipv6_addr` field — that is your address.
|
||||
|
||||
For the rest of this tutorial we will write the address as
|
||||
`<your-fips0-addr>`. Substitute the actual `fd97:...` value
|
||||
|
||||
@@ -63,9 +63,11 @@ mesh address:
|
||||
dig npub1qmc3cvfz0yu2hx96nq3gp55zdan2qclealn7xshgr448d3nh6lks7zel98.fips AAAA +short
|
||||
```
|
||||
|
||||
You should see one AAAA record returning a `fd97:...` address.
|
||||
The prefix is the FIPS ULA range (`fd00::/8`, with `fd97:...`
|
||||
covering the address space derived from npubs).
|
||||
You should see one AAAA record returning an address such as
|
||||
`fd97:...`. The prefix is the FIPS ULA range (`fd00::/8`): only
|
||||
the leading `fd` byte is fixed, and everything after it is hash
|
||||
output derived from the npub, so the digits beyond `fd` vary per
|
||||
node.
|
||||
|
||||
The query went through `systemd-resolved` (or your platform
|
||||
equivalent), which routed `.fips` queries to the daemon's local
|
||||
|
||||
@@ -56,8 +56,11 @@ hostname on the public internet. There is no separate
|
||||
the tool takes a hostname, it accepts a `.fips` hostname.
|
||||
|
||||
> **Where the address comes from.** Every FIPS node's mesh
|
||||
> address is the SHA-256 of its public key, truncated to the
|
||||
> bottom 64 bits and prepended with `fd97:`. Names of the form
|
||||
> address is the first 16 bytes of SHA-256 of its public key,
|
||||
> with the leading byte replaced by `0xfd` (the `fd00::/8` ULA
|
||||
> prefix). The remaining bytes are hash output, so an address
|
||||
> like `fd97:...` is per-node — the `97` is part of the hash,
|
||||
> not a fixed prefix shared across nodes. Names of the form
|
||||
> `<npub>.fips` and any shortname mapped in `/etc/fips/hosts`
|
||||
> are aliases for that address. The daemon's local DNS
|
||||
> responder hands the answer back to your kernel without ever
|
||||
|
||||
@@ -178,7 +178,7 @@ The resolution itself happens at debug-log level, so you will
|
||||
not see it in the default-level journal. The user-facing way to
|
||||
confirm everything worked is `fipsctl show peers` in the next
|
||||
step. (To watch the resolution in the journal, run the daemon
|
||||
manually with `RUST_LOG=fips::discovery::nostr=debug`; not
|
||||
manually with `RUST_LOG=fips::nostr=debug`; not
|
||||
necessary for this tutorial.)
|
||||
|
||||
## Step 5: Verify the resolved endpoint
|
||||
|
||||
@@ -39,7 +39,7 @@ fips sidecar:
|
||||
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
|
||||
`fips0`. DNS for `<npub>.fips` names resolves to `fd00::/8` addresses via the
|
||||
dnsmasq → FIPS daemon pipeline.
|
||||
|
||||
```text
|
||||
|
||||
37
examples/sidecar-nostr-mixnet-relay/.env
Normal file
37
examples/sidecar-nostr-mixnet-relay/.env
Normal file
@@ -0,0 +1,37 @@
|
||||
# FIPS-over-Nym-mixnet demo 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 FIPS_PEER_NPUB empty for standalone operation).
|
||||
# The peer MUST expose a TCP endpoint in nym mode — the Nym SOCKS5 proxy
|
||||
# tunnels TCP streams. Find more public peers at https://join.fips.network/
|
||||
# Default peer test-us03 exposes tcp:54.183.70.180:443 and udp:…:2121.
|
||||
# For udp mode (see FIPS_PEER_TRANSPORT below) change FIPS_PEER_ADDR to
|
||||
# 54.183.70.180:2121.
|
||||
# The alias doubles as the peer's .fips hostname (<alias>.fips), so it
|
||||
# must be a plain hostname label — no dots.
|
||||
FIPS_PEER_NPUB=npub136yqae6na688fs75g95ppps3lxe07fvxefj77938zf47uhm6074sxw8ctm
|
||||
FIPS_PEER_ADDR=54.183.70.180:443
|
||||
FIPS_PEER_ALIAS=test-us03
|
||||
|
||||
# Transport — THE switch that selects mixnet vs. direct: nym | tcp | udp
|
||||
# nym : peer traffic goes through the Nym mixnet via the in-container
|
||||
# nym-socks5-client (started automatically, before FIPS). DEFAULT.
|
||||
# tcp : direct TCP to FIPS_PEER_ADDR; the nym client is NOT started.
|
||||
# udp : direct UDP — also set FIPS_PEER_ADDR to the peer's :2121 endpoint.
|
||||
# To go back to a direct link, just set this to tcp (or udp) and re-run
|
||||
# `docker compose up`. Nothing else needs to change for tcp.
|
||||
FIPS_PEER_TRANSPORT=nym
|
||||
|
||||
# ----- Nym mixnet (only used when FIPS_PEER_TRANSPORT=nym) -----
|
||||
# Network-requester service provider. Leave empty to auto-discover the
|
||||
# best-scored provider from https://harbourmaster.nymtech.net/ at startup.
|
||||
NYM_SERVICE_PROVIDER=
|
||||
NYM_CLIENT_ID=fips-nym-client
|
||||
FIPS_NYM_SOCKS5_ADDR=127.0.0.1:1080
|
||||
|
||||
# Logging
|
||||
RUST_LOG=info
|
||||
1
examples/sidecar-nostr-mixnet-relay/.gitignore
vendored
Normal file
1
examples/sidecar-nostr-mixnet-relay/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
.env.local
|
||||
151
examples/sidecar-nostr-mixnet-relay/Dockerfile
Normal file
151
examples/sidecar-nostr-mixnet-relay/Dockerfile
Normal file
@@ -0,0 +1,151 @@
|
||||
# Single-container FIPS-over-Nym-mixnet demo.
|
||||
#
|
||||
# Everything runs in ONE container: the FIPS daemon, the nym-socks5-client
|
||||
# mixnet proxy, the strfry Nostr relay, nginx, and dnsmasq. The entrypoint
|
||||
# starts them in strict order so the SOCKS5 proxy is up before FIPS dials
|
||||
# its peer through the mixnet.
|
||||
#
|
||||
# Platform: the image builds NATIVE for the host. The FIPS daemon must NOT
|
||||
# run under emulation — Rosetta mis-translates the amd64 ChaCha20-Poly1305
|
||||
# assembly (ring/BoringSSL), silently failing AEAD on larger frames (bloom
|
||||
# filter announces are the first casualty). fips is therefore always
|
||||
# compiled for the native arch.
|
||||
#
|
||||
# nym-socks5-client is the official prebuilt amd64 binary (Nym ships no
|
||||
# other arch). On amd64 hosts it runs natively; on arm64 (Apple Silicon)
|
||||
# it runs via Docker Desktop's binfmt/Rosetta handler, with the x86-64
|
||||
# loader + glibc copied in from an amd64 stage. The nym client tolerates
|
||||
# emulation; fips does not.
|
||||
|
||||
# ── Build stage: compile FIPS from source ──
|
||||
FROM rust:1.94-slim-trixie AS builder
|
||||
|
||||
# bluer (BLE) and rustables (nftables) are unconditional dependencies on
|
||||
# glibc Linux: bluer needs the dbus headers, rustables runs bindgen
|
||||
# (libclang) against the libnftnl headers.
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends \
|
||||
pkg-config libdbus-1-dev libnftnl-dev libclang-dev clang && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /build
|
||||
COPY Cargo.toml Cargo.lock rust-toolchain.toml build.rs ./
|
||||
COPY src ./src
|
||||
|
||||
RUN cargo build --release && \
|
||||
cp target/release/fips target/release/fipsctl target/release/fipstop /usr/local/bin/
|
||||
|
||||
# ── strfry stage: collect the binary and its musl runtime ──
|
||||
# The official strfry image is alpine (musl) based; the runtime stage below
|
||||
# is debian (glibc), so the musl dynamic loader and the exact set of shared
|
||||
# libraries strfry links against must come along.
|
||||
FROM ghcr.io/hoytech/strfry:latest AS strfry
|
||||
RUN mkdir -p /strfry-libs && \
|
||||
ldd /app/strfry | awk '$3 ~ /^\// {print $3}' | xargs -I{} cp {} /strfry-libs/ && \
|
||||
cp /lib/ld-musl-*.so.1 /strfry-libs/
|
||||
|
||||
# ── nym stage: official prebuilt amd64 binary + its glibc runtime ──
|
||||
# Pinned amd64-only stage regardless of host arch. Collects the x86-64
|
||||
# dynamic loader and the binary's library closure so the binary can run
|
||||
# inside the native (possibly arm64) runtime image via binfmt/Rosetta.
|
||||
FROM --platform=linux/amd64 debian:trixie-slim AS nym
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends ca-certificates curl && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
# Bumping the version = edit the tag in this URL.
|
||||
RUN curl -sSL --retry 3 \
|
||||
"https://github.com/nymtech/nym/releases/download/nym-binaries-v2026.11-xynomizithra/nym-socks5-client" \
|
||||
-o /nym-socks5-client && \
|
||||
chmod +x /nym-socks5-client
|
||||
RUN mkdir -p /nym-rt/lib64 /nym-rt/libs && \
|
||||
cp /lib64/ld-linux-x86-64.so.2 /nym-rt/lib64/ && \
|
||||
ldd /nym-socks5-client | awk '$3 ~ /^\// {print $3}' | xargs -I{} cp {} /nym-rt/libs/
|
||||
|
||||
# ── Runtime stage ──
|
||||
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 jq iperf3 nginx && \
|
||||
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
|
||||
|
||||
# nym-socks5-client: prebuilt amd64 binary plus its x86-64 loader/glibc.
|
||||
# On amd64 these COPYs overwrite identical files; on arm64 they add the
|
||||
# x86-64 runtime alongside the native one (paths don't collide). The
|
||||
# version check doubles as a binfmt/Rosetta smoke test on arm64 hosts.
|
||||
COPY --from=nym /nym-socks5-client /usr/local/bin/nym-socks5-client
|
||||
COPY --from=nym /nym-rt/lib64/ /lib64/
|
||||
COPY --from=nym /nym-rt/libs/ /lib/x86_64-linux-gnu/
|
||||
RUN echo "Installed:" && /usr/local/bin/nym-socks5-client --version
|
||||
|
||||
# Setup SSH server with no authentication (test only!)
|
||||
RUN mkdir -p /var/run/sshd && \
|
||||
ssh-keygen -A && \
|
||||
sed -i 's/#PermitRootLogin prohibit-password/PermitRootLogin yes/' /etc/ssh/sshd_config && \
|
||||
sed -i 's/#PermitEmptyPasswords no/PermitEmptyPasswords yes/' /etc/ssh/sshd_config && \
|
||||
sed -i 's/UsePAM yes/UsePAM no/' /etc/ssh/sshd_config && \
|
||||
passwd -d root
|
||||
|
||||
# dnsmasq: forward .fips to FIPS daemon, everything else to Docker DNS
|
||||
RUN printf '%s\n' \
|
||||
'port=53' \
|
||||
'listen-address=127.0.0.1' \
|
||||
'bind-interfaces' \
|
||||
'server=/fips/127.0.0.1#5354' \
|
||||
'server=127.0.0.11' \
|
||||
'no-resolv' \
|
||||
>> /etc/dnsmasq.conf
|
||||
|
||||
# strfry: binary, musl loader, and its libraries in a private directory.
|
||||
# /etc/ld-musl-<arch>.path tells the musl loader where to search, keeping
|
||||
# the musl libraries invisible to the system glibc loader.
|
||||
COPY --from=strfry /app/strfry /usr/local/bin/strfry
|
||||
COPY --from=strfry /strfry-libs/ /opt/strfry-libs/
|
||||
RUN mv /opt/strfry-libs/ld-musl-*.so.1 /lib/ && \
|
||||
echo "/opt/strfry-libs" > /etc/ld-musl-$(uname -m).path && \
|
||||
mkdir -p /usr/src/app/strfry-db && \
|
||||
strfry --version
|
||||
|
||||
# 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/conf.d/nostr-relay.conf && \
|
||||
rm -f /etc/nginx/sites-enabled/default
|
||||
|
||||
COPY --from=builder /usr/local/bin/fips /usr/local/bin/fipsctl /usr/local/bin/fipstop /usr/local/bin/
|
||||
|
||||
COPY examples/sidecar-nostr-mixnet-relay/entrypoint.sh /entrypoint.sh
|
||||
|
||||
ENTRYPOINT ["/entrypoint.sh"]
|
||||
@@ -0,0 +1,7 @@
|
||||
target/
|
||||
target-*/
|
||||
.git/
|
||||
.github/
|
||||
testing/
|
||||
examples/
|
||||
!examples/sidecar-nostr-mixnet-relay/
|
||||
173
examples/sidecar-nostr-mixnet-relay/README.md
Normal file
173
examples/sidecar-nostr-mixnet-relay/README.md
Normal file
@@ -0,0 +1,173 @@
|
||||
# FIPS over a Mixnet — Single-Container Demo (Nym)
|
||||
|
||||
An isolated environment demonstrating how FIPS peer traffic can travel
|
||||
through a **mixnet** — a network that hides traffic patterns by routing
|
||||
each packet through several relays with cover traffic and timing
|
||||
obfuscation. The mixnet here is [Nym](https://nym.com/), but the FIPS side
|
||||
is transport-agnostic: it just sees a SOCKS5 proxy, so any mixnet exposing
|
||||
one would slot in the same way.
|
||||
|
||||
**Everything runs in one Docker container**: the FIPS daemon, the mixnet
|
||||
proxy (`nym-socks5-client`), a [strfry](https://github.com/hoytech/strfry)
|
||||
Nostr relay behind nginx, and dnsmasq.
|
||||
|
||||
```
|
||||
┌────────────────────────── one container ───────────────────────────┐
|
||||
│ │
|
||||
│ nginx :80 ──► strfry :7777 (Nostr relay, fips0-only) │
|
||||
│ │
|
||||
│ fips daemon ── transports.nym ──► nym-socks5-client :1080 │
|
||||
│ │ │ │
|
||||
│ ▼ ▼ Sphinx packets │
|
||||
│ fips0 (TUN, fd00::/8) Nym gateway ► 3 mix hops ► │
|
||||
│ network requester ► peer (TCP) │
|
||||
│ │
|
||||
│ iptables: direct route to the peer is DROPped — the FIPS link │
|
||||
│ can only exist through the mixnet. │
|
||||
└────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
How the pieces interlock:
|
||||
|
||||
- The FIPS **nym transport** dials peers through a local SOCKS5 proxy; the
|
||||
proxy routes each TCP stream through the mixnet (gateway → 3 mix hops →
|
||||
network requester), which performs the final TCP connection to the peer.
|
||||
The peer address must therefore be a **TCP endpoint** — find public peers
|
||||
at <https://join.fips.network/>.
|
||||
- The `nym-socks5-client` is started by the entrypoint **only when the
|
||||
generated FIPS config contains a `transports.nym` block**
|
||||
(`FIPS_PEER_TRANSPORT=nym`), and always **before** the FIPS daemon, so
|
||||
the proxy is listening by the time FIPS dials.
|
||||
- In nym mode, iptables **drops the direct route to the peer**: if the peer
|
||||
handshake completes, the traffic provably went through the mixnet.
|
||||
|
||||
## Quick start
|
||||
|
||||
```bash
|
||||
# 1. Generate a node identity (any machine with fipsctl, or reuse one):
|
||||
fipsctl keygen
|
||||
|
||||
# 2. Put the nsec into the environment:
|
||||
export FIPS_NSEC=<your-nsec>
|
||||
|
||||
# 3. Build and run (native image; FIPS compiles for your host's arch):
|
||||
docker compose up --build
|
||||
```
|
||||
|
||||
Watch the logs: the entrypoint auto-discovers a Nym service provider,
|
||||
bootstraps the SOCKS5 client (`Nym SOCKS5 proxy ready …`), and only then
|
||||
starts FIPS. After the mixnet handshake completes (can take 30–120 s):
|
||||
|
||||
```bash
|
||||
docker compose exec fips fipsctl show transports # nym transport: up
|
||||
docker compose exec fips fipsctl show peers # test-us03: active
|
||||
```
|
||||
|
||||
## Switching transport: mixnet ↔ direct (TCP/UDP)
|
||||
|
||||
The single knob is `FIPS_PEER_TRANSPORT` in `.env` (or an inline override).
|
||||
It selects how FIPS reaches the peer **and** whether the mixnet proxy runs
|
||||
at all — the two are always in sync.
|
||||
|
||||
```bash
|
||||
# Default — through the Nym mixnet (anonymized, ~1-2 s RTT):
|
||||
FIPS_PEER_TRANSPORT=nym docker compose up -d # or just `docker compose up -d`
|
||||
|
||||
# Direct TCP (no mixnet, ~50-300 ms RTT). The nym client is NOT started:
|
||||
FIPS_PEER_TRANSPORT=tcp docker compose up -d
|
||||
|
||||
# Direct UDP — also point FIPS_PEER_ADDR at the peer's UDP endpoint:
|
||||
FIPS_PEER_TRANSPORT=udp FIPS_PEER_ADDR=54.183.70.180:2121 docker compose up -d
|
||||
```
|
||||
|
||||
What changes under the hood for each value:
|
||||
|
||||
| `FIPS_PEER_TRANSPORT` | nym client | FIPS config block | peer endpoint used | direct route to peer |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `nym` (default) | started, before FIPS | `transports.nym` | `FIPS_PEER_ADDR` (TCP) via SOCKS5 | **firewalled off** |
|
||||
| `tcp` | not started | `transports.tcp` | `FIPS_PEER_ADDR` (TCP) direct | allowed |
|
||||
| `udp` | not started | `transports.udp` | `FIPS_PEER_ADDR` (UDP `:2121`) direct | allowed |
|
||||
|
||||
To **switch back to a direct link**, set the value to `tcp` (no other change)
|
||||
or `udp` (and swap `FIPS_PEER_ADDR` to the `:2121` endpoint), then re-run
|
||||
`docker compose up -d`. To **return to the mixnet**, set it back to `nym`.
|
||||
Persist your choice by editing `.env` instead of prefixing the command.
|
||||
The same node can be compared both ways — direct shows ~50-300 ms RTT,
|
||||
the mixnet ~1-2 s, which is the visible signature that traffic is routing
|
||||
through the Sphinx mix hops.
|
||||
|
||||
## Verifying the traffic really crosses the mixnet
|
||||
|
||||
```bash
|
||||
# The direct route to the peer is dropped — the only way packets reach
|
||||
# the peer is via the nym-socks5-client:
|
||||
docker compose exec fips iptables -L OUTPUT -v -n # DROP rule for peer IP
|
||||
|
||||
# Mixnet activity (Sphinx packet flow) in the nym client output:
|
||||
docker compose logs fips | grep -i nym
|
||||
|
||||
# End-to-end data plane across the mesh. FIPS addresses every node by its
|
||||
# key as <npub>.fips (each npub maps into fd00::/8); short names like
|
||||
# `test-us03` are only local aliases for the peer you configured. Pick a
|
||||
# node you are NOT directly linked to — grab a current npub from
|
||||
# https://join.fips.network/ — so the ICMPv6 echo routes over the mixnet
|
||||
# to your peer and then hop-by-hop across the mesh to the target:
|
||||
docker compose exec fips ping6 -c3 <peer-npub>.fips
|
||||
|
||||
# A reply while the direct route is DROPped proves the traffic crossed the
|
||||
# mixnet; the seconds-range RTT is the Sphinx path's signature, and a few
|
||||
# extra hundred ms over reaching your own peer is the added mesh hops (a
|
||||
# direct, non-mixnet connection would be ~30 ms).
|
||||
```
|
||||
|
||||
The Nostr relay answers only over the FIPS mesh (fd00::/8) and on the
|
||||
container's loopback — inbound eth0 traffic, including the host's port-80
|
||||
mapping, is dropped by the isolation rules. Check it from inside:
|
||||
|
||||
```bash
|
||||
docker compose exec fips curl -s -H "Accept: application/nostr+json" http://127.0.0.1/
|
||||
```
|
||||
|
||||
## Configuration (.env)
|
||||
|
||||
| Variable | Default | Meaning |
|
||||
| --- | --- | --- |
|
||||
| `FIPS_NSEC` | *(required)* | Node identity, `fipsctl keygen` |
|
||||
| `FIPS_PEER_NPUB` | test-us03's npub | Peer to dial; empty = standalone |
|
||||
| `FIPS_PEER_ADDR` | `54.183.70.180:443` | **TCP** endpoint in nym/tcp mode (use `:2121` for udp) |
|
||||
| `FIPS_PEER_TRANSPORT` | `nym` | `nym` \| `tcp` \| `udp` — see "Switching transport" above |
|
||||
| `NYM_SERVICE_PROVIDER` | *(auto)* | Network requester; empty = pick the best-scored from [harbourmaster](https://harbourmaster.nymtech.net/) |
|
||||
| `NYM_CLIENT_ID` | `fips-nym-client` | Nym client identity (kept in the `nym-data` volume) |
|
||||
|
||||
With `FIPS_PEER_TRANSPORT=tcp` or `udp` the nym client is **not started at
|
||||
all** and FIPS connects directly — useful as a baseline comparison.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **`could not auto-discover a Nym service provider`** — the harbourmaster
|
||||
API was unreachable or returned no providers; pick one manually from
|
||||
<https://harbourmaster.nymtech.net/> and set `NYM_SERVICE_PROVIDER`.
|
||||
- **Slow or failing mixnet bootstrap** — service providers and gateways
|
||||
vary in quality. Delete the client state and retry with another provider:
|
||||
`docker compose down -v && NYM_SERVICE_PROVIDER=<other> docker compose up`.
|
||||
(The provider is baked into the client state at init; changing it
|
||||
requires wiping the `nym-data` volume.)
|
||||
- **Peer never becomes active** — confirm the peer's TCP endpoint is
|
||||
reachable from the open internet (the network requester dials it from
|
||||
the Nym exit side, not from your machine).
|
||||
- **Never run this image under emulation** — the image builds native for
|
||||
a reason: under Rosetta/qemu, the FIPS daemon's ChaCha20-Poly1305
|
||||
assembly (ring/BoringSSL) silently fails AEAD on larger frames; bloom
|
||||
filter announces are dropped and multi-hop routing never converges,
|
||||
while small control traffic keeps working — a maddeningly subtle
|
||||
failure mode. Only the embedded amd64 `nym-socks5-client` (Nym ships no
|
||||
other arch) runs emulated on Apple Silicon, which it tolerates.
|
||||
|
||||
## Notes
|
||||
|
||||
- The container's lifecycle follows the FIPS daemon; strfry, nginx and the
|
||||
nym client run as background processes inside the same container and are
|
||||
restarted with it (`restart: unless-stopped`).
|
||||
- SSH (port 22, no auth) and tools like `tcpdump`, `nak`, `iperf3` are
|
||||
inside the image for poking around — this is a demo image, do not expose
|
||||
it beyond your machine.
|
||||
55
examples/sidecar-nostr-mixnet-relay/docker-compose.yml
Normal file
55
examples/sidecar-nostr-mixnet-relay/docker-compose.yml
Normal file
@@ -0,0 +1,55 @@
|
||||
networks:
|
||||
fips-net:
|
||||
name: ${FIPS_NETWORK:-fips-mixnet-net}
|
||||
driver: bridge
|
||||
ipam:
|
||||
config:
|
||||
- subnet: ${FIPS_SUBNET:-172.20.2.0/24}
|
||||
|
||||
services:
|
||||
# Single container running ALL services: fips daemon, nym-socks5-client,
|
||||
# strfry Nostr relay, nginx, dnsmasq. See entrypoint.sh for start order.
|
||||
fips:
|
||||
# Builds NATIVE for the host — fips must not run under emulation
|
||||
# (Rosetta breaks its AEAD on larger frames). Only the embedded
|
||||
# amd64 nym-socks5-client is emulated on Apple Silicon.
|
||||
build:
|
||||
context: ../..
|
||||
dockerfile: examples/sidecar-nostr-mixnet-relay/Dockerfile
|
||||
hostname: fips-mixnet
|
||||
cap_add:
|
||||
- NET_ADMIN
|
||||
devices:
|
||||
- /dev/net/tun:/dev/net/tun
|
||||
sysctls:
|
||||
- net.ipv6.conf.all.disable_ipv6=0
|
||||
restart: unless-stopped
|
||||
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_PEER_TRANSPORT=${FIPS_PEER_TRANSPORT:-nym}
|
||||
- FIPS_UDP_BIND=${FIPS_UDP_BIND:-0.0.0.0:2121}
|
||||
- FIPS_TUN_MTU=${FIPS_TUN_MTU:-1280}
|
||||
- FIPS_UDP_MTU=${FIPS_UDP_MTU:-1472}
|
||||
- FIPS_NYM_SOCKS5_ADDR=${FIPS_NYM_SOCKS5_ADDR:-127.0.0.1:1080}
|
||||
- NYM_CLIENT_ID=${NYM_CLIENT_ID:-fips-nym-client}
|
||||
- NYM_SERVICE_PROVIDER=${NYM_SERVICE_PROVIDER:-}
|
||||
volumes:
|
||||
- ./resolv.conf:/etc/resolv.conf:ro
|
||||
- ./relay/strfry.conf:/usr/src/app/strfry.conf:ro
|
||||
- relay-data:/usr/src/app/strfry-db
|
||||
- nym-data:/root/.nym
|
||||
networks:
|
||||
fips-net:
|
||||
ipv4_address: ${FIPS_IPV4:-172.20.2.20}
|
||||
|
||||
volumes:
|
||||
relay-data:
|
||||
nym-data:
|
||||
221
examples/sidecar-nostr-mixnet-relay/entrypoint.sh
Executable file
221
examples/sidecar-nostr-mixnet-relay/entrypoint.sh
Executable file
@@ -0,0 +1,221 @@
|
||||
#!/bin/bash
|
||||
# Single-container entrypoint: generate the FIPS config, apply iptables
|
||||
# isolation, start the Nostr relay (strfry + nginx), start the Nym SOCKS5
|
||||
# client (only when the FIPS config enables the nym transport), and launch
|
||||
# FIPS last — so the mixnet proxy is provably up before FIPS dials its peer.
|
||||
set -e
|
||||
|
||||
# --- Generate FIPS config from environment variables ---
|
||||
|
||||
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_UDP_MTU="${FIPS_UDP_MTU:-1472}"
|
||||
FIPS_PEER_TRANSPORT="${FIPS_PEER_TRANSPORT:-nym}"
|
||||
FIPS_NYM_SOCKS5_ADDR="${FIPS_NYM_SOCKS5_ADDR:-127.0.0.1:1080}"
|
||||
NYM_CLIENT_ID="${NYM_CLIENT_ID:-fips-nym-client}"
|
||||
NYM_STARTUP_TIMEOUT="${NYM_STARTUP_TIMEOUT:-180}"
|
||||
|
||||
mkdir -p /etc/fips
|
||||
|
||||
# Build peers section
|
||||
PEERS_SECTION=""
|
||||
if [ -n "$FIPS_PEER_NPUB" ] && [ -n "$FIPS_PEER_ADDR" ]; then
|
||||
FIPS_PEER_ALIAS="${FIPS_PEER_ALIAS:-peer}"
|
||||
PEERS_SECTION=" - npub: \"${FIPS_PEER_NPUB}\"
|
||||
alias: \"${FIPS_PEER_ALIAS}\"
|
||||
addresses:
|
||||
- transport: ${FIPS_PEER_TRANSPORT}
|
||||
addr: \"${FIPS_PEER_ADDR}\"
|
||||
connect_policy: auto_connect"
|
||||
fi
|
||||
|
||||
# The nym transport block is emitted only in nym mode; the SOCKS5 client
|
||||
# below starts only when this block is present in the config.
|
||||
NYM_SECTION=""
|
||||
if [ "$FIPS_PEER_TRANSPORT" = "nym" ]; then
|
||||
NYM_SECTION=" nym:
|
||||
socks5_addr: \"${FIPS_NYM_SOCKS5_ADDR}\"
|
||||
startup_timeout_secs: 120"
|
||||
fi
|
||||
|
||||
cat > /etc/fips/fips.yaml <<EOF
|
||||
node:
|
||||
identity:
|
||||
nsec: "${FIPS_NSEC}"
|
||||
|
||||
tun:
|
||||
enabled: true
|
||||
name: fips0
|
||||
mtu: ${FIPS_TUN_MTU}
|
||||
|
||||
dns:
|
||||
enabled: true
|
||||
bind_addr: "127.0.0.1"
|
||||
|
||||
transports:
|
||||
udp:
|
||||
bind_addr: "${FIPS_UDP_BIND}"
|
||||
# 1472 = Docker bridge IPv4 max (1500 MTU - 8 UDP - 20 IPv4 header).
|
||||
# Override with FIPS_UDP_MTU=1280 for IPv6-min-safe deploys.
|
||||
mtu: ${FIPS_UDP_MTU}
|
||||
tcp:
|
||||
bind_addr: "${FIPS_TCP_BIND}"
|
||||
${NYM_SECTION}
|
||||
|
||||
peers:
|
||||
${PEERS_SECTION:- []}
|
||||
EOF
|
||||
|
||||
echo "Generated /etc/fips/fips.yaml"
|
||||
|
||||
# --- Start local DNS first ---
|
||||
# resolv.conf points at 127.0.0.1; dnsmasq must be up before anything
|
||||
# below (peer-IP resolution, harbourmaster query, nym gateway lookup).
|
||||
dnsmasq
|
||||
|
||||
# --- Apply iptables rules for strict network isolation ---
|
||||
#
|
||||
# Goal: only FIPS transport traffic may use eth0. All other eth0 traffic is
|
||||
# dropped. fips0 and loopback are unrestricted, so the relay (sharing this
|
||||
# namespace) is reachable only over the FIPS mesh.
|
||||
#
|
||||
# In nym mode the direct route to the peer is explicitly DROPped before the
|
||||
# general TCP accept for the mixnet gateways: if the peer comes up, the
|
||||
# connection can only have travelled through the mixnet.
|
||||
|
||||
# IPv4: allow only FIPS transport on eth0
|
||||
iptables -A OUTPUT -o lo -j ACCEPT
|
||||
iptables -A INPUT -i lo -j ACCEPT
|
||||
iptables -A OUTPUT -o eth0 -p udp --dport 2121 -j ACCEPT
|
||||
iptables -A OUTPUT -o eth0 -p udp --sport 2121 -j ACCEPT
|
||||
iptables -A INPUT -i eth0 -p udp --dport 2121 -j ACCEPT
|
||||
iptables -A INPUT -i eth0 -p udp --sport 2121 -j ACCEPT
|
||||
iptables -A OUTPUT -o eth0 -p tcp --dport 443 -j ACCEPT
|
||||
iptables -A INPUT -i eth0 -p tcp --sport 443 -j ACCEPT
|
||||
|
||||
PEER_HOST="${FIPS_PEER_ADDR%:*}"
|
||||
PEER_PORT="${FIPS_PEER_ADDR##*:}"
|
||||
case "$FIPS_PEER_TRANSPORT" in
|
||||
nym)
|
||||
# Block the direct path to the peer (mixnet-only proof). IPv4 only:
|
||||
# eth0 IPv6 is dropped wholesale by the ip6tables rules below.
|
||||
if [ -n "$FIPS_PEER_ADDR" ]; then
|
||||
PEER_IP=$(getent ahostsv4 "$PEER_HOST" | awk '{print $1; exit}' || true)
|
||||
if [ -n "$PEER_IP" ]; then
|
||||
iptables -A OUTPUT -o eth0 -p tcp -d "$PEER_IP" --dport "$PEER_PORT" -j DROP
|
||||
echo "Direct path to peer ${PEER_HOST} (${PEER_IP}:${PEER_PORT}) blocked — mixnet only"
|
||||
fi
|
||||
fi
|
||||
# … then allow outbound TCP for the nym client's gateway connections.
|
||||
iptables -A OUTPUT -o eth0 -p tcp -j ACCEPT
|
||||
iptables -A INPUT -i eth0 -p tcp -m state --state ESTABLISHED,RELATED -j ACCEPT
|
||||
;;
|
||||
tcp)
|
||||
# Allow dialing the peer's TCP endpoint directly, and inbound FIPS TCP.
|
||||
if [ -n "$FIPS_PEER_ADDR" ]; then
|
||||
iptables -A OUTPUT -o eth0 -p tcp --dport "$PEER_PORT" -j ACCEPT
|
||||
iptables -A INPUT -i eth0 -p tcp --sport "$PEER_PORT" -m state --state ESTABLISHED,RELATED -j ACCEPT
|
||||
fi
|
||||
iptables -A INPUT -i eth0 -p tcp --dport "${FIPS_TCP_BIND##*:}" -j ACCEPT
|
||||
iptables -A OUTPUT -o eth0 -p tcp --sport "${FIPS_TCP_BIND##*:}" -j ACCEPT
|
||||
;;
|
||||
esac
|
||||
|
||||
iptables -A OUTPUT -o eth0 -j DROP
|
||||
iptables -A INPUT -i eth0 -j DROP
|
||||
|
||||
# IPv6: allow fips0 and loopback, block eth0
|
||||
ip6tables -A OUTPUT -o lo -j ACCEPT
|
||||
ip6tables -A INPUT -i lo -j ACCEPT
|
||||
ip6tables -A OUTPUT -o fips0 -j ACCEPT
|
||||
ip6tables -A INPUT -i fips0 -j ACCEPT
|
||||
ip6tables -A OUTPUT -o eth0 -j DROP
|
||||
ip6tables -A INPUT -i eth0 -j DROP
|
||||
|
||||
echo "iptables isolation rules applied"
|
||||
|
||||
# --- Start the Nostr relay app (strfry + nginx) ---
|
||||
|
||||
(cd /usr/src/app && exec strfry relay) &
|
||||
nginx
|
||||
echo "Nostr relay started (strfry on 127.0.0.1:7777, nginx on :80)"
|
||||
|
||||
# --- Start the Nym SOCKS5 client (only if the config enables nym) ---
|
||||
|
||||
if [ "$FIPS_PEER_TRANSPORT" = "nym" ]; then
|
||||
NYM_HOST="${FIPS_NYM_SOCKS5_ADDR%:*}"
|
||||
NYM_PORT="${FIPS_NYM_SOCKS5_ADDR##*:}"
|
||||
|
||||
# Auto-discover a network-requester service provider when none is set.
|
||||
if [ ! -d "${HOME}/.nym/socks5-clients/${NYM_CLIENT_ID}" ]; then
|
||||
# The provider is only consulted at init time, so auto-discovery
|
||||
# runs only when a fresh client must be initialized. Failures
|
||||
# (harbourmaster down or serving HTML) must not crash the
|
||||
# container with a bare jq error — hence the `|| true` guards
|
||||
# and the explicit empty-check below.
|
||||
if [ -z "$NYM_SERVICE_PROVIDER" ]; then
|
||||
echo "NYM_SERVICE_PROVIDER not set — querying harbourmaster.nymtech.net …"
|
||||
NYM_SERVICE_PROVIDER=$(curl -fsSL --retry 3 \
|
||||
"https://harbourmaster.nymtech.net/v2/services?order_by=routing_score&order_direction=desc&size=100" \
|
||||
2>/dev/null \
|
||||
| jq -r '[.items[] | select(.routing_score == 1.0)]
|
||||
| sort_by(.last_updated_utc) | last
|
||||
| .service_provider_client_id // empty' 2>/dev/null \
|
||||
|| true)
|
||||
if [ -z "$NYM_SERVICE_PROVIDER" ]; then
|
||||
# Last resort: a provider known to work at the time of
|
||||
# writing (2026-06). Providers are volatile community
|
||||
# infra — if the mixnet connects but no traffic flows
|
||||
# ('no node with identity … is known' warnings), this
|
||||
# fallback has gone stale: pick a current one from
|
||||
# https://harbourmaster.nymtech.net/ and set it in .env.
|
||||
NYM_SERVICE_PROVIDER="${NYM_FALLBACK_PROVIDER:-7sfw3sEtSPwhWLmEasVmPXKxqioCo4GaXRkm9bW6yWGZ.CkhMoH85wfNcV2fwoBjc6QDbcaFZHzKqFFvXWfYMw19y@4ScsM6AVowhKTMWaH98NLntKDwbu2ZMEycUk4mZiZppG}"
|
||||
echo "WARNING: harbourmaster auto-discovery failed — using the" >&2
|
||||
echo "baked-in fallback provider (may be stale; see .env):" >&2
|
||||
echo " ${NYM_SERVICE_PROVIDER}" >&2
|
||||
else
|
||||
echo "Auto-selected service provider: ${NYM_SERVICE_PROVIDER}"
|
||||
fi
|
||||
fi
|
||||
echo "Initializing Nym SOCKS5 client '${NYM_CLIENT_ID}' …"
|
||||
nym-socks5-client init \
|
||||
--id "${NYM_CLIENT_ID}" \
|
||||
--provider "${NYM_SERVICE_PROVIDER}" \
|
||||
--port "${NYM_PORT}" \
|
||||
--host "${NYM_HOST}"
|
||||
else
|
||||
# The provider is baked into the client state at init time — a value
|
||||
# set or discovered now does NOT apply to an existing client. Surface
|
||||
# the one actually in effect so a stale/dead provider isn't chased
|
||||
# silently (symptom: 'no node with identity … is known' warnings).
|
||||
STORED_PROVIDER=$(grep -m1 -oE '[1-9A-HJ-NP-Za-km-z]{20,}\.[1-9A-HJ-NP-Za-km-z]{20,}@[1-9A-HJ-NP-Za-km-z]{20,}' \
|
||||
"${HOME}/.nym/socks5-clients/${NYM_CLIENT_ID}/config/config.toml" 2>/dev/null || true)
|
||||
echo "Reusing existing Nym client state (provider: ${STORED_PROVIDER:-unknown})."
|
||||
echo "To switch provider, remove the nym-data volume: docker compose down -v"
|
||||
fi
|
||||
|
||||
echo "Starting Nym SOCKS5 client (mixnet bootstrap may take a minute) …"
|
||||
nym-socks5-client run \
|
||||
--id "${NYM_CLIENT_ID}" \
|
||||
--port "${NYM_PORT}" \
|
||||
--host "${NYM_HOST}" &
|
||||
|
||||
# FIPS must not start dialing before the proxy accepts connections.
|
||||
elapsed=0
|
||||
until nc -z "$NYM_HOST" "$NYM_PORT" 2>/dev/null; do
|
||||
if [ "$elapsed" -ge "$NYM_STARTUP_TIMEOUT" ]; then
|
||||
echo "ERROR: Nym SOCKS5 proxy not ready after ${NYM_STARTUP_TIMEOUT}s" >&2
|
||||
exit 1
|
||||
fi
|
||||
sleep 2
|
||||
elapsed=$((elapsed + 2))
|
||||
done
|
||||
echo "Nym SOCKS5 proxy ready at ${FIPS_NYM_SOCKS5_ADDR} (after ~${elapsed}s)"
|
||||
fi
|
||||
|
||||
# --- Launch FIPS (container lifecycle follows the daemon) ---
|
||||
|
||||
echo "Starting FIPS daemon..."
|
||||
exec fips --config /etc/fips/fips.yaml
|
||||
71
examples/sidecar-nostr-mixnet-relay/relay/strfry.conf
Normal file
71
examples/sidecar-nostr-mixnet-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
|
||||
}
|
||||
}
|
||||
1
examples/sidecar-nostr-mixnet-relay/resolv.conf
Normal file
1
examples/sidecar-nostr-mixnet-relay/resolv.conf
Normal file
@@ -0,0 +1 @@
|
||||
nameserver 127.0.0.1
|
||||
@@ -73,13 +73,14 @@ ws://npub1xxxx.fips:80
|
||||
|
||||
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
|
||||
- **No IPv4 access**: iptables blocks all eth0 traffic except the FIPS UDP
|
||||
transport (port 2121), the local FIPS TCP listener (port 8443), and
|
||||
outbound TCP to peers' published endpoints (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
|
||||
(`fd00::/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.
|
||||
@@ -105,7 +106,7 @@ with the transport layer directly.
|
||||
│ Interfaces: │
|
||||
│ lo — loopback (unrestricted) │
|
||||
│ eth0 — Docker bridge (iptables: FIPS only) │
|
||||
│ fips0 — FIPS TUN (fd::/8, unrestricted) │
|
||||
│ fips0 — FIPS TUN (fd00::/8, unrestricted) │
|
||||
└───────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
@@ -118,7 +119,8 @@ before launching the FIPS daemon:
|
||||
|
||||
- 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)
|
||||
- ACCEPT TCP dport 443 / sport 443 on `eth0` (outbound to peers' TCP endpoints)
|
||||
- ACCEPT TCP dport/sport 8443 on `eth0` (local FIPS TCP listener, `FIPS_TCP_BIND`)
|
||||
- DROP everything else on `eth0`
|
||||
|
||||
**IPv6 rules** (ip6tables):
|
||||
@@ -132,7 +134,7 @@ before launching the FIPS daemon:
|
||||
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
|
||||
(127.0.0.1:5354), which resolves npub-based names to `fd00::/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,
|
||||
@@ -165,7 +167,8 @@ From the app container:
|
||||
# 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:
|
||||
# Fetch a web page from some other mesh node over FIPS
|
||||
# (:8000 is a stand-in for that node's own service; this relay serves :80):
|
||||
docker exec sidecar-nostr-relay-app-1 curl -6 "http://npub1xxxx.fips:8000/"
|
||||
|
||||
# Docker bridge is blocked — this should fail:
|
||||
@@ -187,6 +190,7 @@ docker exec sidecar-nostr-relay-app-1 ping -c1 127.0.0.1
|
||||
| `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_UDP_MTU` | `1472` | UDP transport MTU (default is Docker bridge IPv4 max; set to `1280` for IPv6-min-safe deploys) |
|
||||
| `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 |
|
||||
|
||||
@@ -31,6 +31,7 @@ services:
|
||||
- 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_UDP_MTU=${FIPS_UDP_MTU:-1472}
|
||||
- FIPS_PEER_TRANSPORT=${FIPS_PEER_TRANSPORT:-udp}
|
||||
volumes:
|
||||
- ./resolv.conf:/etc/resolv.conf:ro
|
||||
|
||||
@@ -8,6 +8,7 @@ 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_UDP_MTU="${FIPS_UDP_MTU:-1472}"
|
||||
FIPS_PEER_TRANSPORT="${FIPS_PEER_TRANSPORT:-udp}"
|
||||
|
||||
mkdir -p /etc/fips
|
||||
@@ -41,7 +42,9 @@ dns:
|
||||
transports:
|
||||
udp:
|
||||
bind_addr: "${FIPS_UDP_BIND}"
|
||||
mtu: 1472
|
||||
# 1472 = Docker bridge IPv4 max (1500 MTU - 8 UDP - 20 IPv4 header).
|
||||
# Override with FIPS_UDP_MTU=1280 for IPv6-min-safe deploys.
|
||||
mtu: ${FIPS_UDP_MTU}
|
||||
tcp:
|
||||
bind_addr: "${FIPS_TCP_BIND}"
|
||||
|
||||
@@ -67,6 +70,8 @@ iptables -A INPUT -i eth0 -p udp --dport 2121 -j ACCEPT
|
||||
iptables -A INPUT -i eth0 -p udp --sport 2121 -j ACCEPT
|
||||
iptables -A OUTPUT -o eth0 -p tcp --dport 443 -j ACCEPT
|
||||
iptables -A INPUT -i eth0 -p tcp --sport 443 -j ACCEPT
|
||||
iptables -A INPUT -i eth0 -p tcp --dport "${FIPS_TCP_BIND##*:}" -j ACCEPT
|
||||
iptables -A OUTPUT -o eth0 -p tcp --sport "${FIPS_TCP_BIND##*:}" -j ACCEPT
|
||||
iptables -A OUTPUT -o eth0 -j DROP
|
||||
iptables -A INPUT -i eth0 -j DROP
|
||||
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
# 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
|
||||
@@ -20,7 +20,9 @@ transports:
|
||||
|
||||
peers:
|
||||
- npub: "npub1zv58cn7v83mxvttl70w5fwjwuclfmntv9cnmv5wmz2nzz88u5urqvdx96n"
|
||||
alias: "fips.v0l.io"
|
||||
# alias becomes the peer's .fips hostname (<alias>.fips), so it must be a
|
||||
# plain label with no dots — the connection address below is the real host.
|
||||
alias: "v0l"
|
||||
addresses:
|
||||
- transport: tcp
|
||||
addr: "fips.v0l.io:8443"
|
||||
|
||||
100
flake.lock
generated
Normal file
100
flake.lock
generated
Normal file
@@ -0,0 +1,100 @@
|
||||
{
|
||||
"nodes": {
|
||||
"fenix": {
|
||||
"inputs": {
|
||||
"nixpkgs": [
|
||||
"nixpkgs"
|
||||
],
|
||||
"rust-analyzer-src": "rust-analyzer-src"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1781527054,
|
||||
"narHash": "sha256-1fX9ev2Fh5QoKQ41G9dYutjo5j/jywu6tZse5Eb1Ck4=",
|
||||
"owner": "nix-community",
|
||||
"repo": "fenix",
|
||||
"rev": "8c2e51dffefc040a21975da7abf6f252c8c9b783",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nix-community",
|
||||
"repo": "fenix",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"flake-utils": {
|
||||
"inputs": {
|
||||
"systems": "systems"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1731533236,
|
||||
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1781074563,
|
||||
"narHash": "sha256-md8WlXOlfnIeHeOScMTTHFyf2d6iaTwPl2apR5EQ3P4=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "9ae611a455b90cf061d8f332b977e387bda8e1ca",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"ref": "nixos-unstable",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"fenix": "fenix",
|
||||
"flake-utils": "flake-utils",
|
||||
"nixpkgs": "nixpkgs"
|
||||
}
|
||||
},
|
||||
"rust-analyzer-src": {
|
||||
"flake": false,
|
||||
"locked": {
|
||||
"lastModified": 1781453968,
|
||||
"narHash": "sha256-+V3nK4pCngbmgyVGXY6Kkrlevp4ocPkJJLf2aqwkDNA=",
|
||||
"owner": "rust-lang",
|
||||
"repo": "rust-analyzer",
|
||||
"rev": "cc272809a173c2c11d0e479d639c811c1eacf049",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "rust-lang",
|
||||
"ref": "nightly",
|
||||
"repo": "rust-analyzer",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"systems": {
|
||||
"locked": {
|
||||
"lastModified": 1681028828,
|
||||
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"type": "github"
|
||||
}
|
||||
}
|
||||
},
|
||||
"root": "root",
|
||||
"version": 7
|
||||
}
|
||||
128
flake.nix
Normal file
128
flake.nix
Normal file
@@ -0,0 +1,128 @@
|
||||
{
|
||||
description = "FIPS — a distributed, decentralized network routing protocol for mesh nodes connecting over arbitrary transports";
|
||||
|
||||
inputs = {
|
||||
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||
flake-utils.url = "github:numtide/flake-utils";
|
||||
fenix = {
|
||||
url = "github:nix-community/fenix";
|
||||
inputs.nixpkgs.follows = "nixpkgs";
|
||||
};
|
||||
};
|
||||
|
||||
outputs =
|
||||
{
|
||||
self,
|
||||
nixpkgs,
|
||||
flake-utils,
|
||||
fenix,
|
||||
}:
|
||||
flake-utils.lib.eachDefaultSystem (
|
||||
system:
|
||||
let
|
||||
pkgs = import nixpkgs { inherit system; };
|
||||
|
||||
# Honor the toolchain the repo pins in rust-toolchain.toml
|
||||
# (channel 1.94.1 + rustfmt, clippy) so Nix builds match CI and the
|
||||
# AUR/Debian packaging exactly, including the edition-2024 frontend.
|
||||
rustToolchain = fenix.packages.${system}.fromToolchainFile {
|
||||
file = ./rust-toolchain.toml;
|
||||
sha256 = "sha256-zC8E38iDVJ1oPIzCqTk/Ujo9+9kx9dXq7wAwPMpkpg0=";
|
||||
};
|
||||
|
||||
rustPlatform = pkgs.makeRustPlatform {
|
||||
cargo = rustToolchain;
|
||||
rustc = rustToolchain;
|
||||
};
|
||||
|
||||
cargoToml = pkgs.lib.importTOML ./Cargo.toml;
|
||||
|
||||
# libdbus-sys (pulled in transitively by `bluer`, Linux/glibc only)
|
||||
# runs `bindgen` against the system D-Bus headers at build time.
|
||||
nativeBuildInputs = [
|
||||
pkgs.pkg-config
|
||||
rustPlatform.bindgenHook # sets LIBCLANG_PATH + clang for bindgen
|
||||
];
|
||||
|
||||
buildInputs = pkgs.lib.optionals pkgs.stdenv.isLinux [
|
||||
pkgs.dbus # libdbus-1.so.3, linked via bluer→libdbus-sys
|
||||
pkgs.stdenv.cc.cc.lib # libgcc_s.so.1, needed by every Rust binary
|
||||
];
|
||||
|
||||
fips = rustPlatform.buildRustPackage {
|
||||
pname = "fips";
|
||||
version = cargoToml.package.version;
|
||||
|
||||
src = pkgs.lib.cleanSourceWith {
|
||||
src = ./.;
|
||||
# Drop the build dir and the usual editor/VCS noise so the source
|
||||
# hash is stable and unrelated edits don't trigger rebuilds.
|
||||
filter =
|
||||
path: type:
|
||||
(pkgs.lib.cleanSourceFilter path type) && (baseNameOf path != "target");
|
||||
};
|
||||
|
||||
cargoLock.lockFile = ./Cargo.lock;
|
||||
|
||||
inherit buildInputs;
|
||||
|
||||
# autoPatchelfHook rewrites the RPATH of the built binaries so the
|
||||
# daemon finds libdbus-1.so.3 (linked via bluer→libdbus-sys) in the
|
||||
# Nix store at runtime — without it the `fips` binary fails to load
|
||||
# on NixOS where there is no global /usr/lib.
|
||||
nativeBuildInputs =
|
||||
nativeBuildInputs ++ pkgs.lib.optionals pkgs.stdenv.isLinux [ pkgs.autoPatchelfHook ];
|
||||
|
||||
# The test suite exercises TUN devices, raw sockets and mDNS, none of
|
||||
# which exist in the build sandbox. The AUR/Debian packaging likewise
|
||||
# ships the release binaries without running the integration tests
|
||||
# here, so keep the package build hermetic and skip them.
|
||||
doCheck = false;
|
||||
|
||||
meta = {
|
||||
description = cargoToml.package.description;
|
||||
homepage = cargoToml.package.homepage;
|
||||
license = pkgs.lib.licenses.mit;
|
||||
mainProgram = "fips";
|
||||
platforms = pkgs.lib.platforms.linux ++ pkgs.lib.platforms.darwin;
|
||||
};
|
||||
};
|
||||
|
||||
mkApp = name: {
|
||||
type = "app";
|
||||
program = "${fips}/bin/${name}";
|
||||
meta.description = "Run the ${name} binary from the FIPS package";
|
||||
};
|
||||
in
|
||||
{
|
||||
packages = {
|
||||
default = fips;
|
||||
fips = fips;
|
||||
};
|
||||
|
||||
apps = {
|
||||
default = mkApp "fips";
|
||||
fips = mkApp "fips";
|
||||
fipsctl = mkApp "fipsctl";
|
||||
fips-gateway = mkApp "fips-gateway";
|
||||
fipstop = mkApp "fipstop";
|
||||
};
|
||||
|
||||
# `nix flake check` builds the package (and thus validates the flake on
|
||||
# the current system).
|
||||
checks.fips = fips;
|
||||
|
||||
devShells.default = pkgs.mkShell {
|
||||
inherit buildInputs;
|
||||
nativeBuildInputs = nativeBuildInputs ++ [
|
||||
rustToolchain
|
||||
pkgs.cargo-edit
|
||||
];
|
||||
# Point rust-analyzer at the matching std sources.
|
||||
RUST_SRC_PATH = "${rustToolchain}/lib/rustlib/src/rust/library";
|
||||
};
|
||||
|
||||
formatter = pkgs.nixfmt;
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -6,7 +6,8 @@
|
||||
# Usage:
|
||||
# make deb Build a Debian/Ubuntu .deb package
|
||||
# make tarball Build a systemd install tarball
|
||||
# make ipk Build an OpenWrt .ipk package
|
||||
# make ipk Build an OpenWrt .ipk package (opkg, OpenWrt 24.x and earlier)
|
||||
# make apk Build an OpenWrt .apk package (apk-tools, mandatory on OpenWrt 25+)
|
||||
# make aur Build fips-git AUR package and validate with namcap
|
||||
# make pkg Build a macOS .pkg installer
|
||||
# make zip Build a Windows .zip package
|
||||
@@ -17,7 +18,7 @@ SHELL := /bin/bash
|
||||
PACKAGING_DIR := $(dir $(abspath $(lastword $(MAKEFILE_LIST))))
|
||||
PROJECT_ROOT := $(abspath $(PACKAGING_DIR)/..)
|
||||
|
||||
.PHONY: all deb tarball ipk aur pkg zip clean
|
||||
.PHONY: all deb tarball ipk apk aur pkg zip clean
|
||||
|
||||
all: deb tarball
|
||||
|
||||
@@ -30,6 +31,9 @@ tarball:
|
||||
ipk:
|
||||
@bash $(PACKAGING_DIR)/openwrt-ipk/build-ipk.sh
|
||||
|
||||
apk:
|
||||
@bash $(PACKAGING_DIR)/openwrt-apk/build-apk.sh
|
||||
|
||||
aur:
|
||||
@bash $(PACKAGING_DIR)/aur/build-aur.sh
|
||||
|
||||
|
||||
@@ -8,13 +8,36 @@ All build outputs go to `deploy/` at the project root.
|
||||
```sh
|
||||
make deb # Debian/Ubuntu .deb
|
||||
make tarball # systemd install tarball
|
||||
make ipk # OpenWrt .ipk
|
||||
make ipk # OpenWrt .ipk (opkg, OpenWrt 24.x and earlier)
|
||||
make apk # OpenWrt .apk (apk-tools, mandatory on OpenWrt 25+)
|
||||
make aur # Arch Linux AUR package (fips-git, local build + namcap)
|
||||
make pkg # macOS .pkg installer
|
||||
make zip # Windows .zip package
|
||||
make all # deb + tarball (default)
|
||||
```
|
||||
|
||||
## Build Prerequisites
|
||||
|
||||
These targets build FIPS from source, so the host needs a build
|
||||
environment in addition to a Rust toolchain (the version pinned in
|
||||
`rust-toolchain.toml` is auto-installed by rustup).
|
||||
|
||||
On Linux, `libclang` is **required**: the LAN gateway's nftables
|
||||
bindings are generated by `bindgen` at build time, which needs
|
||||
`libclang.so` on the build host. Without it the build fails inside the
|
||||
`rustables` crate with an "Unable to find libclang" error.
|
||||
|
||||
```sh
|
||||
sudo apt install libclang-dev # Debian / Ubuntu
|
||||
```
|
||||
|
||||
This is a build-time prerequisite only — it is not a runtime
|
||||
dependency, so hosts installing a pre-built `.deb` do not need it.
|
||||
|
||||
BLE support is optional and, when building with it, additionally needs
|
||||
`bluez`, `libdbus-1-dev`, and `pkg-config`; the build picks up BLE if
|
||||
those are present and skips it cleanly if not.
|
||||
|
||||
## Directory Structure
|
||||
|
||||
```text
|
||||
@@ -24,7 +47,8 @@ packaging/
|
||||
debian/ Debian/Ubuntu .deb packaging via cargo-deb
|
||||
macos/ macOS .pkg installer via pkgbuild
|
||||
systemd/ Generic Linux systemd tarball packaging
|
||||
openwrt/ OpenWrt .ipk packaging via cargo-zigbuild
|
||||
openwrt-ipk/ OpenWrt .ipk packaging via cargo-zigbuild (opkg)
|
||||
openwrt-apk/ OpenWrt .apk packaging via cargo-zigbuild + apk mkpkg
|
||||
windows/ Windows .zip package with service scripts
|
||||
```
|
||||
|
||||
@@ -33,10 +57,20 @@ packaging/
|
||||
### 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
|
||||
`fips`, `fipsctl`, and `fipstop` to `/usr/bin/`, and enables the systemd
|
||||
service.
|
||||
|
||||
The default configuration ships as an example at
|
||||
`/usr/share/fips/fips.yaml.example` and is **not** a dpkg conf-file.
|
||||
(It is deliberately **not** under `/usr/share/doc`, which minimal and
|
||||
container installs path-exclude, since the postinst reads it at install
|
||||
time.)
|
||||
On install, `postinst` seeds `/etc/fips/fips.yaml` (mode 600) from the
|
||||
example **only if it does not already exist**, so a configuration that
|
||||
was rendered by configuration management or edited by an operator is
|
||||
never prompted for or clobbered on upgrade. To reset to defaults, remove
|
||||
`/etc/fips/fips.yaml` and reinstall, or copy the example back manually.
|
||||
|
||||
```sh
|
||||
# Build
|
||||
make deb
|
||||
@@ -68,7 +102,7 @@ sudo ./fips-<version>-linux-<arch>/install.sh
|
||||
See [systemd/README.install.md](systemd/README.install.md) for full
|
||||
installation and configuration instructions.
|
||||
|
||||
### OpenWrt (`.ipk`)
|
||||
### OpenWrt (`.ipk`, opkg — OpenWrt 24.x and earlier)
|
||||
|
||||
Cross-compiled with cargo-zigbuild and assembled as a standard `.ipk`
|
||||
archive. Supports aarch64, mipsel, mips, arm, and x86\_64 targets.
|
||||
@@ -78,12 +112,33 @@ archive. Supports aarch64, mipsel, mips, arm, and x86\_64 targets.
|
||||
make ipk
|
||||
|
||||
# Build for a specific architecture
|
||||
bash packaging/openwrt/build-ipk.sh --arch mipsel
|
||||
bash packaging/openwrt-ipk/build-ipk.sh --arch mipsel
|
||||
```
|
||||
|
||||
See [openwrt/README.md](openwrt/README.md) for router-specific
|
||||
See [openwrt-ipk/README.md](openwrt-ipk/README.md) for router-specific
|
||||
installation instructions.
|
||||
|
||||
### OpenWrt (`.apk`, apk-tools — mandatory on OpenWrt 25+)
|
||||
|
||||
OpenWrt 25 makes apk-tools the mandatory package manager (it is opt-in on
|
||||
24.10). Same SDK-free approach
|
||||
(cargo-zigbuild), but the `.apk` container is assembled by `apk mkpkg`
|
||||
rather than hand-rolled, so the build additionally needs an apk-tools v3
|
||||
`apk` binary built from source. The installed-filesystem payload is shared
|
||||
with the `.ipk` package.
|
||||
|
||||
```sh
|
||||
# Build (default: aarch64; also x86_64)
|
||||
make apk
|
||||
|
||||
# Build for a specific architecture
|
||||
bash packaging/openwrt-apk/build-apk.sh --arch x86_64
|
||||
```
|
||||
|
||||
Packages are unsigned; install with `apk add --allow-untrusted`. See
|
||||
[openwrt-apk/README.md](openwrt-apk/README.md) for building apk-tools and
|
||||
router-specific installation.
|
||||
|
||||
### macOS (`.pkg`)
|
||||
|
||||
Built with `pkgbuild` (included with Xcode command-line tools). Installs
|
||||
@@ -145,6 +200,27 @@ yay -S fips # release build from latest tag
|
||||
See [aur/README.md](aur/README.md) for AUR publication instructions
|
||||
and maintainer guide.
|
||||
|
||||
### Nix / NixOS (flake)
|
||||
|
||||
A [flake](../flake.nix) at the project root builds all four binaries
|
||||
(`fips`, `fipsctl`, `fips-gateway`, `fipstop`) from source. It pins the
|
||||
exact toolchain from `rust-toolchain.toml` via
|
||||
[fenix](https://github.com/nix-community/fenix) and wires up the
|
||||
build-time native dependencies (`libclang` for `bindgen`, plus `dbus`
|
||||
and `pkg-config` for BLE), so it needs no system setup beyond Nix with
|
||||
flakes enabled.
|
||||
|
||||
```sh
|
||||
nix build .#fips # build the package (all four binaries)
|
||||
nix run .#fips -- --help # run a binary directly
|
||||
nix run .#fipsctl -- status
|
||||
nix develop # dev shell with the pinned toolchain + cargo-edit
|
||||
nix flake check # build + validate the flake
|
||||
```
|
||||
|
||||
Add to a NixOS configuration via the flake's `packages.<system>.fips`
|
||||
output, e.g. `environment.systemPackages = [ fips.packages.${system}.default ];`.
|
||||
|
||||
## Shared Assets
|
||||
|
||||
`common/` contains assets used across packaging formats:
|
||||
|
||||
@@ -7,9 +7,9 @@ url="https://github.com/jmcorgan/fips"
|
||||
license=('MIT')
|
||||
arch=('x86_64')
|
||||
depends=('gcc-libs' 'glibc')
|
||||
makedepends=('cargo')
|
||||
makedepends=('cargo' 'clang')
|
||||
optdepends=('systemd-resolved: .fips DNS resolution')
|
||||
conflicts=('fips-git')
|
||||
conflicts=('fips-git' 'fips-git-debug')
|
||||
backup=('etc/fips/fips.yaml' 'etc/fips/hosts' 'etc/fips/fips.nft')
|
||||
install=fips.install
|
||||
source=("$pkgname-$pkgver.tar.gz::https://github.com/jmcorgan/fips/archive/v$pkgver.tar.gz"
|
||||
@@ -18,7 +18,7 @@ source=("$pkgname-$pkgver.tar.gz::https://github.com/jmcorgan/fips/archive/v$pkg
|
||||
b2sums=('SKIP' # tarball hash computed by CI via updpkgsums on release
|
||||
'25a0552f3d67d12f48dfd40fe4776ad7c46afeeab76bd2674b48e234db3c145810a24569a8c1a7f4c186eb546f0fae2ebe1550080c0e91d8eb72ba9934c752a6'
|
||||
'844257cb8e09cd935d0d6345922d0f3ec777411daca20e24175b346a7b3cb95ebce12631a9466c4d94f1588ed8d62d92514ff24025ccfd0efb358e542b454b00')
|
||||
options=('!lto')
|
||||
options=('!lto' '!debug')
|
||||
|
||||
prepare() {
|
||||
cd "$pkgname-$pkgver"
|
||||
@@ -53,6 +53,10 @@ package() {
|
||||
install -Dm0644 packaging/debian/fips-gateway.service "$pkgdir/usr/lib/systemd/system/fips-gateway.service"
|
||||
install -Dm0644 packaging/debian/fips-firewall.service "$pkgdir/usr/lib/systemd/system/fips-firewall.service"
|
||||
|
||||
# DNS helper scripts referenced by fips-dns.service
|
||||
install -Dm0755 packaging/common/fips-dns-setup "$pkgdir/usr/lib/fips/fips-dns-setup"
|
||||
install -Dm0755 packaging/common/fips-dns-teardown "$pkgdir/usr/lib/fips/fips-dns-teardown"
|
||||
|
||||
# Config files (from packaging/common/)
|
||||
install -Dm0600 packaging/common/fips.yaml "$pkgdir/etc/fips/fips.yaml"
|
||||
install -Dm0644 packaging/common/hosts "$pkgdir/etc/fips/hosts"
|
||||
|
||||
@@ -7,10 +7,10 @@ url="https://github.com/jmcorgan/fips"
|
||||
license=('MIT')
|
||||
arch=('x86_64')
|
||||
depends=('dbus' 'gcc-libs' 'glibc')
|
||||
makedepends=('cargo' 'git')
|
||||
makedepends=('cargo' 'clang' 'git')
|
||||
optdepends=('systemd-resolved: .fips DNS resolution')
|
||||
provides=('fips')
|
||||
conflicts=('fips')
|
||||
conflicts=('fips' 'fips-debug')
|
||||
backup=('etc/fips/fips.yaml' 'etc/fips/hosts' 'etc/fips/fips.nft')
|
||||
install=fips.install
|
||||
source=("fips::git+https://github.com/jmcorgan/fips.git"
|
||||
@@ -60,6 +60,10 @@ package() {
|
||||
install -Dm0644 packaging/debian/fips-gateway.service "$pkgdir/usr/lib/systemd/system/fips-gateway.service"
|
||||
install -Dm0644 packaging/debian/fips-firewall.service "$pkgdir/usr/lib/systemd/system/fips-firewall.service"
|
||||
|
||||
# DNS helper scripts referenced by fips-dns.service
|
||||
install -Dm0755 packaging/common/fips-dns-setup "$pkgdir/usr/lib/fips/fips-dns-setup"
|
||||
install -Dm0755 packaging/common/fips-dns-teardown "$pkgdir/usr/lib/fips/fips-dns-teardown"
|
||||
|
||||
# Config files (from packaging/common/)
|
||||
install -Dm0600 packaging/common/fips.yaml "$pkgdir/etc/fips/fips.yaml"
|
||||
install -Dm0644 packaging/common/hosts "$pkgdir/etc/fips/hosts"
|
||||
|
||||
@@ -31,6 +31,8 @@ package:
|
||||
- Binaries: `fips`, `fipsctl`, `fipstop`, `fips-gateway`
|
||||
- Systemd units: `fips.service`, `fips-dns.service`, `fips-gateway.service`,
|
||||
`fips-firewall.service`
|
||||
- DNS helpers: `/usr/lib/fips/fips-dns-setup`,
|
||||
`/usr/lib/fips/fips-dns-teardown`
|
||||
- Config: `/etc/fips/fips.yaml`, `/etc/fips/hosts`, `/etc/fips/fips.nft`
|
||||
- sysusers/tmpfiles fragments for the `fips` group and `/run/fips/`
|
||||
|
||||
@@ -39,6 +41,11 @@ operator edits to the nftables ruleset survive package upgrades.
|
||||
`fips-firewall.service` is shipped disabled by default, matching the Debian
|
||||
package: operators opt in by enabling it explicitly.
|
||||
|
||||
Both PKGBUILDs opt out of makepkg's automatic `*-debug` split packages. The
|
||||
package metadata still conflicts with stale peer debug package names
|
||||
(`fips-debug` / `fips-git-debug`) so switching between release and development
|
||||
variants removes old debug-file owners cleanly.
|
||||
|
||||
## Local Build and Validation
|
||||
|
||||
Build and validate the `-git` package locally using the Makefile target:
|
||||
@@ -321,3 +328,8 @@ Push an update when a new version is tagged. The steps are:
|
||||
|
||||
Phase 4 CI automation will handle this workflow automatically on new GitHub
|
||||
releases.
|
||||
|
||||
For a packaging-only republish of an existing release tag, run the AUR Publish
|
||||
workflow manually with the existing tag and incremented `pkgrel` (for example,
|
||||
`tag=v0.3.0`, `pkgrel=2`). This keeps the upstream source tarball unchanged
|
||||
while forcing AUR helpers to rebuild with the corrected package metadata.
|
||||
|
||||
76
packaging/aur/patch-pkgbuild.sh
Executable file
76
packaging/aur/patch-pkgbuild.sh
Executable file
@@ -0,0 +1,76 @@
|
||||
#!/usr/bin/env bash
|
||||
# Patch packaging/aur/PKGBUILD in place with the release pkgver, pkgrel,
|
||||
# conflicts, options, and b2sums.
|
||||
#
|
||||
# Shared by the AUR publish workflow's real-publish job and its dry-run job
|
||||
# so the patching logic lives in exactly one place.
|
||||
#
|
||||
# Required environment variables:
|
||||
# TAG - release tag (e.g. v0.4.0 or v0.4.0-rc1)
|
||||
# VERSION - tag without the leading 'v' (e.g. 0.4.0)
|
||||
# PKGREL - AUR pkgrel (positive integer)
|
||||
#
|
||||
# Source of the tarball b2sum:
|
||||
# Default: fetch the GitHub source archive for $TAG and hash it.
|
||||
# Dry-run: set LOCAL_TARBALL to a path; its b2sum is used instead and the
|
||||
# PKGBUILD source= line is rewritten to point at that local file.
|
||||
# This lets a dry-run validate an rc tag that has no published
|
||||
# GitHub release archive yet (the archive URL would 404).
|
||||
#
|
||||
# Uses $GITHUB_REPOSITORY for the source URL (owner/repo).
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
: "${TAG:?TAG must be set}"
|
||||
: "${VERSION:?VERSION must be set}"
|
||||
: "${PKGREL:?PKGREL must be set}"
|
||||
|
||||
PKGBUILD="packaging/aur/PKGBUILD"
|
||||
|
||||
SYSUSERS_SUM=$(b2sum packaging/aur/fips.sysusers | awk '{print $1}')
|
||||
TMPFILES_SUM=$(b2sum packaging/aur/fips.tmpfiles | awk '{print $1}')
|
||||
|
||||
if [ -n "${LOCAL_TARBALL:-}" ]; then
|
||||
# Dry-run path: hash the locally-created tarball of the checkout.
|
||||
echo "Using local tarball $LOCAL_TARBALL for b2sum (dry-run)"
|
||||
SOURCE_SUM=$(b2sum "$LOCAL_TARBALL" | awk '{print $1}')
|
||||
else
|
||||
URL="https://github.com/${GITHUB_REPOSITORY:?GITHUB_REPOSITORY must be set}/archive/${TAG}.tar.gz"
|
||||
echo "Fetching $URL"
|
||||
SOURCE_SUM=$(curl -fsSL --retry 3 "$URL" | b2sum | awk '{print $1}')
|
||||
fi
|
||||
|
||||
for v in SOURCE_SUM SYSUSERS_SUM TMPFILES_SUM; do
|
||||
eval val=\$$v
|
||||
if [ -z "$val" ]; then echo "$v is empty"; exit 1; fi
|
||||
done
|
||||
|
||||
sed -i "s/^pkgver=.*/pkgver=${VERSION}/" "$PKGBUILD"
|
||||
sed -i "s/^pkgrel=.*/pkgrel=${PKGREL}/" "$PKGBUILD"
|
||||
sed -i "s/^conflicts=.*/conflicts=('fips-git' 'fips-git-debug')/" "$PKGBUILD"
|
||||
sed -i "s/^options=.*/options=('!lto' '!debug')/" "$PKGBUILD"
|
||||
|
||||
if [ -n "${LOCAL_TARBALL:-}" ]; then
|
||||
# Repoint the first source entry at the local tarball so makepkg builds the
|
||||
# checked-out tree instead of fetching the (possibly unpublished) GitHub
|
||||
# archive. makepkg resolves a bare filename source against $startdir.
|
||||
LOCAL_BASE=$(basename "$LOCAL_TARBALL")
|
||||
sed -i "s|^source=(\"\$pkgname-\$pkgver.tar.gz::[^\"]*\"|source=(\"\$pkgname-\$pkgver.tar.gz::${LOCAL_BASE}\"|" "$PKGBUILD"
|
||||
fi
|
||||
|
||||
sed -i "s|^b2sums=('SKIP'.*|b2sums=('${SOURCE_SUM}'|" "$PKGBUILD"
|
||||
awk -v s1="$SYSUSERS_SUM" -v s2="$TMPFILES_SUM" '
|
||||
/^b2sums=\(/ { in_block=1; count=0 }
|
||||
in_block {
|
||||
count++
|
||||
if (count == 2) sub(/[a-f0-9]{128}/, s1)
|
||||
if (count == 3) sub(/[a-f0-9]{128}/, s2)
|
||||
if ($0 ~ /\)/) in_block=0
|
||||
}
|
||||
{ print }
|
||||
' "$PKGBUILD" > "$PKGBUILD.new"
|
||||
mv "$PKGBUILD.new" "$PKGBUILD"
|
||||
|
||||
echo "Patched PKGBUILD:"
|
||||
grep -E "^(pkgver|pkgrel|conflicts|options|source)=" "$PKGBUILD"
|
||||
awk '/^b2sums=\(/,/\)$/' "$PKGBUILD"
|
||||
@@ -10,12 +10,21 @@ node:
|
||||
#
|
||||
# Or set an explicit key (overrides persistent):
|
||||
# nsec: "nsec1..."
|
||||
discovery:
|
||||
# Optional Nostr-mediated overlay endpoint discovery.
|
||||
# Mesh-lookup protocol (node.lookup.*): the overlay coordinate-lookup engine
|
||||
# (mesh address -> coordinates). Defaults shown; uncomment to override.
|
||||
# lookup:
|
||||
# ttl: 64
|
||||
# attempt_timeouts_secs: [1, 2, 4, 8]
|
||||
# recent_expiry_secs: 10
|
||||
# backoff_base_secs: 0
|
||||
# backoff_max_secs: 0
|
||||
# forward_min_interval_secs: 2
|
||||
rendezvous:
|
||||
# Optional Nostr-mediated overlay endpoint rendezvous.
|
||||
# nostr:
|
||||
# enabled: true
|
||||
# policy: configured_only # disabled | configured_only | open
|
||||
# open_discovery_max_pending: 64 # caps queued open-discovery retries
|
||||
# open_discovery_max_pending: 64 # caps queued open-rendezvous retries
|
||||
# app: "fips-overlay-v1"
|
||||
# advertise: true
|
||||
# advert_relays:
|
||||
@@ -33,6 +42,23 @@ node:
|
||||
# - "stun:stun.l.google.com:19302"
|
||||
# - "stun:stun.cloudflare.com:3478"
|
||||
# - "stun:global.stun.twilio.com:3478"
|
||||
#
|
||||
# Optional mDNS-based LAN rendezvous for sub-second same-LAN pairing.
|
||||
# Opt-in (default false): default-off avoids a per-LAN identity
|
||||
# broadcast on nodes that have deliberately disabled other rendezvous
|
||||
# channels, and avoids any multicast surprise on upgrade. Requires an
|
||||
# operational UDP transport (the advertised port is the one peers dial).
|
||||
# lan:
|
||||
# enabled: false
|
||||
# # Optional application/network scope carried in the LAN-only TXT
|
||||
# # record. Browsers that set a scope ignore adverts for other scopes.
|
||||
# # Kept separate from the Nostr rendezvous `app` tag so relay-visible
|
||||
# # adverts can stay generic while LAN rendezvous stays per-private-network.
|
||||
# # scope: "lab-floor-3"
|
||||
# # Advanced: overrides the mDNS service type. Leave unset in normal
|
||||
# # use — only needed to run multiple isolated services on one
|
||||
# # interface (e.g. test isolation on loopback).
|
||||
# # service_type: "_fips._udp.local."
|
||||
|
||||
tun:
|
||||
enabled: true
|
||||
@@ -72,7 +98,7 @@ transports:
|
||||
# Ethernet transport — uncomment and set your interface name.
|
||||
# ethernet:
|
||||
# interface: "eth0"
|
||||
# discovery: true
|
||||
# listen: true
|
||||
# announce: true
|
||||
# auto_connect: true
|
||||
# accept_connections: true
|
||||
@@ -86,6 +112,18 @@ transports:
|
||||
# auto_connect: true
|
||||
# accept_connections: true
|
||||
|
||||
# Nym transport — outbound-only connections through the Nym mixnet for
|
||||
# sender/receiver privacy. This is a privacy posture, not a NAT-traversal
|
||||
# or failover transport: it only dials out (no inbound listener) and is
|
||||
# chosen for its anonymity properties. Requires a nym-socks5-client
|
||||
# running separately as its own process; FIPS dials it over SOCKS5.
|
||||
# nym:
|
||||
# socks5_addr: "127.0.0.1:1080" # nym-socks5-client SOCKS5 address
|
||||
# connect_timeout_ms: 300000 # outbound connect timeout (300s);
|
||||
# # mixnet round-trips can take minutes
|
||||
# mtu: 1400 # per-connection MTU
|
||||
# startup_timeout_secs: 120 # wait for nym-socks5-client readiness
|
||||
|
||||
# Outbound LAN gateway. Allows non-FIPS hosts on the LAN to reach
|
||||
# mesh destinations via DNS-allocated virtual IPs and kernel NAT.
|
||||
# Requires: IPv6 forwarding enabled, fips daemon running with DNS.
|
||||
@@ -118,5 +156,5 @@ peers: []
|
||||
# - transport: udp
|
||||
# addr: "test-us01.fips.network:2121" # IP or hostname (e.g., "peer.example.com:2121")
|
||||
# - transport: udp
|
||||
# addr: "nat" # Use node.discovery.nostr for Nostr/STUN hole punching
|
||||
# addr: "nat" # Use node.rendezvous.nostr for Nostr/STUN hole punching
|
||||
# connect_policy: auto_connect
|
||||
|
||||
@@ -3,6 +3,11 @@ Description=FIPS Mesh Network Daemon
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
# The config file is no longer a packaged conf-file; postinst seeds it
|
||||
# if absent. Skip the unit (inactive, not failed) rather than crash-loop
|
||||
# if it is ever missing.
|
||||
ConditionPathExists=/etc/fips/fips.yaml
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart=/usr/bin/fips --config /etc/fips/fips.yaml
|
||||
|
||||
@@ -9,6 +9,16 @@ case "$1" in
|
||||
groupadd --system fips
|
||||
fi
|
||||
|
||||
# Seed /etc/fips/fips.yaml from the shipped example only if it
|
||||
# does not already exist. The live config is no longer a dpkg
|
||||
# conf-file; this copy-if-absent yields to any operator- or
|
||||
# configuration-management-rendered file and never clobbers it.
|
||||
if [ ! -e /etc/fips/fips.yaml ]; then
|
||||
install -m 600 -o root -g root \
|
||||
/usr/share/fips/fips.yaml.example \
|
||||
/etc/fips/fips.yaml
|
||||
fi
|
||||
|
||||
# Drop-in directory for operator nftables rules included by
|
||||
# /etc/fips/fips.nft. Empty by default; the include glob matches
|
||||
# nothing cleanly out of the box.
|
||||
|
||||
@@ -55,7 +55,23 @@ while [[ $# -gt 0 ]]; do
|
||||
done
|
||||
|
||||
VERSION="${VERSION_OVERRIDE:-$(grep '^version' "${PROJECT_ROOT}/Cargo.toml" | head -1 | sed 's/.*"\(.*\)"/\1/')}"
|
||||
ARCH="$(uname -m)"
|
||||
|
||||
# Derive the package architecture from the build target, not the build
|
||||
# host. When cross-compiling (for example building the x86_64 package on
|
||||
# an Apple-silicon machine) `uname -m` reports the host architecture and
|
||||
# would mislabel the package; the Rust target triple is authoritative.
|
||||
if [[ -n "${TARGET_TRIPLE}" ]]; then
|
||||
case "${TARGET_TRIPLE}" in
|
||||
aarch64-*) ARCH="arm64" ;;
|
||||
x86_64-*) ARCH="x86_64" ;;
|
||||
*)
|
||||
echo "Unsupported target triple: ${TARGET_TRIPLE}" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
else
|
||||
ARCH="$(uname -m)"
|
||||
fi
|
||||
PKG_NAME="fips-${VERSION}-macos-${ARCH}"
|
||||
DEPLOY_DIR="${PROJECT_ROOT}/deploy"
|
||||
STAGING_DIR="$(mktemp -d)"
|
||||
@@ -105,9 +121,9 @@ cp "${PACKAGING_DIR}/common/hosts" "${STAGING_DIR}/usr/local/etc/fips/hosts.defa
|
||||
# LaunchDaemon plist
|
||||
cp "${SCRIPT_DIR}/com.fips.daemon.plist" "${STAGING_DIR}/Library/LaunchDaemons/"
|
||||
|
||||
# DNS resolver
|
||||
# DNS resolver. Must match the daemon's dns.bind_addr (defaults to ::1).
|
||||
cat > "${STAGING_DIR}/etc/resolver/fips" <<EOF
|
||||
nameserver 127.0.0.1
|
||||
nameserver ::1
|
||||
port 5354
|
||||
EOF
|
||||
|
||||
|
||||
98
packaging/openwrt-apk/README.md
Normal file
98
packaging/openwrt-apk/README.md
Normal file
@@ -0,0 +1,98 @@
|
||||
# FIPS OpenWrt Package (apk)
|
||||
|
||||
Builds a FIPS `.apk` for **OpenWrt 25+**, where apk-tools is the mandatory
|
||||
package manager. apk is also available opt-in on **24.10** (where opkg remains
|
||||
the default). For OpenWrt 24.x and earlier, the `.ipk` package in
|
||||
[`../openwrt-ipk/`](../openwrt-ipk/) still works.
|
||||
|
||||
Like the `.ipk` build, this is **SDK-free**: it cross-compiles with
|
||||
`cargo-zigbuild` and assembles the package directly — no OpenWrt SDK image. The
|
||||
`.ipk` format is a plain tar.gz we can hand-roll, but the `.apk` (apk-tools v3
|
||||
ADB) container is not, so we drive the official `apk mkpkg` applet — the same
|
||||
tool OpenWrt's own [`include/package-pack.mk`](https://github.com/openwrt/openwrt/blob/main/include/package-pack.mk)
|
||||
calls. The only extra requirement over the `.ipk` build is the `apk` binary.
|
||||
|
||||
## Layout
|
||||
|
||||
| File | Purpose |
|
||||
|---|---|
|
||||
| `build-apk.sh` | Cross-compile + assemble the `.apk` via `apk mkpkg` |
|
||||
| `apk-version.sh` | Map a release tag / commit height to an apk-tools-valid version |
|
||||
| `apk-version.test.sh` | Case-table test for `apk-version.sh` (`sh apk-version.test.sh`) |
|
||||
|
||||
The installed-filesystem payload (init scripts, `fips.yaml`, sysctl drop-ins,
|
||||
hotplug, uci-defaults, …) is **shared** with the `.ipk` package — there is one
|
||||
canonical copy in [`../openwrt-ipk/files/`](../openwrt-ipk/files/). `build-apk.sh`
|
||||
stages from there, so the two packages always ship the same files. Keep the
|
||||
staging block in `build-apk.sh` in sync with `../openwrt-ipk/build-ipk.sh`.
|
||||
|
||||
## Versioning
|
||||
|
||||
apk-tools enforces a strict version grammar
|
||||
(`<digit>(.<digit>)*(_<suffix><digit>*)*(-r<N>)`). `apk-version.sh` builds a
|
||||
valid version from structured inputs rather than rewriting an already-flattened
|
||||
string:
|
||||
|
||||
| Input | apk version |
|
||||
|---|---|
|
||||
| `tag v1.2.3` | `1.2.3-r0` |
|
||||
| `tag v1.2.3-rc1` | `1.2.3_rc1-r0` |
|
||||
| `dev 1234` (commit height) | `0.0.0_git1234-r0` |
|
||||
|
||||
The human-readable version (`v1.2.3`, `master.123.abcdef0`) is still used for the
|
||||
artifact filename; only the metadata embedded in the package is normalized.
|
||||
|
||||
## Building
|
||||
|
||||
### Prerequisites
|
||||
|
||||
| Requirement | Notes |
|
||||
|---|---|
|
||||
| `cargo install cargo-zigbuild` + `zig` | Rust musl cross-compilation (as for `.ipk`) |
|
||||
| apk-tools v3 `apk` binary | Provides `apk mkpkg`; not packaged for most distros — build from source |
|
||||
| `fakeroot` | Optional; makes packaged files root-owned on an unprivileged build host |
|
||||
|
||||
apk-tools is not in Debian/Ubuntu repos, so build the pinned release from source.
|
||||
Pin the same commit the targeted OpenWrt release ships (see
|
||||
`package/system/apk/Makefile` upstream) so the `.apk` is readable by the device's
|
||||
`apk`. CI builds **3.0.5** (`b5a31c0d…`):
|
||||
|
||||
```bash
|
||||
sudo apt-get install -y build-essential meson ninja-build pkg-config \
|
||||
zlib1g-dev libssl-dev libzstd-dev liblzma-dev lua5.4-dev scdoc
|
||||
git clone https://gitlab.alpinelinux.org/alpine/apk-tools.git
|
||||
cd apk-tools && git checkout b5a31c0d865342ad80be10d68f1bb3d3ad9b0866
|
||||
meson setup build && ninja -C build src/apk
|
||||
export APK_BIN="$PWD/build/src/apk"
|
||||
```
|
||||
|
||||
### Build the package
|
||||
|
||||
```bash
|
||||
# from the repo root
|
||||
./packaging/openwrt-apk/build-apk.sh --arch aarch64 # or x86_64, mipsel, mips, arm
|
||||
```
|
||||
|
||||
Output: `dist/fips_<version>_<openwrt-arch>.apk`. Override the version with
|
||||
`PKG_VERSION` (filename) and `APK_VERSION` (embedded metadata); otherwise both are
|
||||
derived from git.
|
||||
|
||||
## Installing on the router
|
||||
|
||||
Packages are **unsigned** (the same posture as our `.ipk`), so install with
|
||||
`--allow-untrusted`:
|
||||
|
||||
```bash
|
||||
scp -O dist/fips_<version>_<arch>.apk root@192.168.1.1:/tmp/
|
||||
ssh root@192.168.1.1 apk add --allow-untrusted /tmp/fips_<version>_<arch>.apk
|
||||
```
|
||||
|
||||
On OpenWrt 25.x, installing from a *signed repository* requires the publisher's
|
||||
key; a single `--allow-untrusted` package install does not. If we ever publish an
|
||||
apk feed, add ECDSA (prime256v1) signing via `apk mkpkg --sign` and distribute the
|
||||
public key to `/etc/apk/keys/`.
|
||||
|
||||
`/etc/fips/fips.yaml` is marked as a config file (via
|
||||
`/lib/apk/packages/fips.conffiles`), so apk preserves local edits across upgrades,
|
||||
and `/lib/upgrade/keep.d/fips` preserves `/etc/fips/` across `sysupgrade` — the
|
||||
same guarantees as the `.ipk` package.
|
||||
74
packaging/openwrt-apk/apk-version.sh
Executable file
74
packaging/openwrt-apk/apk-version.sh
Executable file
@@ -0,0 +1,74 @@
|
||||
#!/bin/sh
|
||||
# Emit an apk-tools-compatible version string for FIPS.
|
||||
#
|
||||
# apk-tools enforces a strict version grammar:
|
||||
# <digit>(.<digit>)*(_<suffix><digit>*)*(-r<N>)
|
||||
# where <suffix> is a recognised pre-release/post-release token
|
||||
# (alpha, beta, pre, rc, cvs, svn, git, hg, p).
|
||||
#
|
||||
# Unlike a regex rewrite of an already-flattened version string, this
|
||||
# helper builds the apk version directly from the *structured* inputs the
|
||||
# caller already has (a release tag, or a commit height). There is no
|
||||
# parsing-back-out of a "branch.height.hash" blob, so there is no fragile
|
||||
# reparse step to get wrong.
|
||||
#
|
||||
# Usage:
|
||||
# apk-version.sh tag <git-tag> # e.g. v1.2.3, v1.2.3-rc1
|
||||
# apk-version.sh dev <height> # e.g. 1234 (git rev-list --count HEAD)
|
||||
# apk-version.sh auto # derive from the current git checkout
|
||||
#
|
||||
# Examples:
|
||||
# apk-version.sh tag v1.2.3 -> 1.2.3-r0
|
||||
# apk-version.sh tag v1.2.3-rc1 -> 1.2.3_rc1-r0
|
||||
# apk-version.sh dev 1234 -> 0.0.0_git1234-r0
|
||||
set -eu
|
||||
|
||||
mode="${1:-auto}"
|
||||
|
||||
case "$mode" in
|
||||
tag) raw_tag="${2:?tag mode requires a tag argument}"; height="" ;;
|
||||
dev) raw_tag=""; height="${2:?dev mode requires a height argument}" ;;
|
||||
auto)
|
||||
if raw_tag="$(git describe --exact-match --tags 2>/dev/null)"; then
|
||||
height=""
|
||||
else
|
||||
raw_tag=""
|
||||
height="$(git rev-list --count HEAD 2>/dev/null || echo 0)"
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
echo "usage: $0 [auto | tag <git-tag> | dev <height>]" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
|
||||
if [ -n "$raw_tag" ]; then
|
||||
# Release tag: vX.Y.Z or vX.Y.Z-<pre>. Strip the leading 'v', split the
|
||||
# core (X.Y.Z) from the pre-release token, and map our hyphen separator
|
||||
# to apk's '_' pre-release marker.
|
||||
body="${raw_tag#v}"
|
||||
core="${body%%-*}"
|
||||
case "$body" in
|
||||
*-*) pre="${body#*-}" ;;
|
||||
*) pre="" ;;
|
||||
esac
|
||||
|
||||
case "$pre" in
|
||||
"") suffix="" ;;
|
||||
alpha*|beta*|pre*|rc*) suffix="_${pre}" ;;
|
||||
*)
|
||||
# Unknown pre-release token: apk would reject or misorder it, so
|
||||
# drop it rather than emit an invalid version. The human-readable
|
||||
# PACKAGE_VERSION (the raw tag) is still used for the filename.
|
||||
suffix=""
|
||||
;;
|
||||
esac
|
||||
|
||||
printf '%s%s-r0\n' "$core" "$suffix"
|
||||
else
|
||||
# Untagged build: no meaningful semver, so anchor at 0.0.0 and encode the
|
||||
# monotonic commit height as a _git pre-release component. This keeps apk's
|
||||
# ordering sane across dev builds without smuggling the hash/branch into a
|
||||
# field that cannot represent them.
|
||||
printf '0.0.0_git%s-r0\n' "${height:-0}"
|
||||
fi
|
||||
45
packaging/openwrt-apk/apk-version.test.sh
Executable file
45
packaging/openwrt-apk/apk-version.test.sh
Executable file
@@ -0,0 +1,45 @@
|
||||
#!/bin/sh
|
||||
# Case-table test for apk-version.sh. Run: sh apk-version.test.sh
|
||||
set -eu
|
||||
|
||||
HERE="$(cd "$(dirname "$0")" && pwd)"
|
||||
SUT="$HERE/apk-version.sh"
|
||||
|
||||
fail=0
|
||||
check() {
|
||||
# check <expected> <args...>
|
||||
expected="$1"; shift
|
||||
actual="$(sh "$SUT" "$@")"
|
||||
if [ "$actual" = "$expected" ]; then
|
||||
printf ' PASS %-22s -> %s\n' "$*" "$actual"
|
||||
else
|
||||
printf ' FAIL %-22s -> %s (expected %s)\n' "$*" "$actual" "$expected"
|
||||
fail=1
|
||||
fi
|
||||
}
|
||||
|
||||
echo "== apk-version.sh =="
|
||||
|
||||
# Plain release tags.
|
||||
check "1.2.3-r0" tag v1.2.3
|
||||
check "0.4.0-r0" tag v0.4.0
|
||||
check "10.20.30-r0" tag v10.20.30
|
||||
|
||||
# Pre-release tags: hyphen separator becomes apk's '_' marker.
|
||||
check "1.2.3_rc1-r0" tag v1.2.3-rc1
|
||||
check "1.2.3_alpha1-r0" tag v1.2.3-alpha1
|
||||
check "1.2.3_beta2-r0" tag v1.2.3-beta2
|
||||
check "1.2.3_pre1-r0" tag v1.2.3-pre1
|
||||
|
||||
# Unknown pre-release token is dropped (apk cannot represent it).
|
||||
check "1.2.3-r0" tag v1.2.3-weird9
|
||||
|
||||
# Dev builds: monotonic commit height as a _git component.
|
||||
check "0.0.0_git1234-r0" dev 1234
|
||||
check "0.0.0_git0-r0" dev 0
|
||||
|
||||
if [ "$fail" -ne 0 ]; then
|
||||
echo "FAILED"
|
||||
exit 1
|
||||
fi
|
||||
echo "OK"
|
||||
297
packaging/openwrt-apk/build-apk.sh
Executable file
297
packaging/openwrt-apk/build-apk.sh
Executable file
@@ -0,0 +1,297 @@
|
||||
#!/bin/bash
|
||||
# Build a FIPS .apk package for OpenWrt without the OpenWrt SDK.
|
||||
#
|
||||
# apk-tools (.apk) is the mandatory package manager from OpenWrt 25 onward; it
|
||||
# is also available opt-in on 24.10, where opkg (.ipk) remains the default. The
|
||||
# .ipk package in ../openwrt-ipk/ still covers OpenWrt 24.x and earlier; this
|
||||
# .apk package is what you need on 25+. Unlike the .ipk format (a plain tar.gz
|
||||
# of tarballs that we
|
||||
# assemble by hand in ../openwrt-ipk/build-ipk.sh), the .apk container is the
|
||||
# apk-tools v3 ADB format, which is impractical to hand-roll. Instead we drive
|
||||
# the official `apk mkpkg` applet — the same tool OpenWrt's build system calls
|
||||
# in include/package-pack.mk — so no SDK is required, only the `apk` binary.
|
||||
#
|
||||
# Usage:
|
||||
# ./packaging/openwrt-apk/build-apk.sh [--arch <name>]
|
||||
#
|
||||
# Architectures (--arch): aarch64 [default], x86_64, mipsel, mips, arm
|
||||
# (the apk CI matrix ships aarch64 + x86_64; the rest are buildable locally).
|
||||
#
|
||||
# Output: dist/fips_<version>_<openwrt-arch>.apk
|
||||
#
|
||||
# Prerequisites:
|
||||
# cargo install cargo-zigbuild (Rust musl cross-compilation)
|
||||
# apk-tools v3 `apk` binary on PATH, or pointed at via APK_BIN=/path/to/apk
|
||||
# (build from source — see README.md; CI builds apk-tools 3.0.5).
|
||||
# fakeroot (optional but recommended; makes packaged files root-owned).
|
||||
#
|
||||
# Install on a router (packages are unsigned, like our .ipk):
|
||||
# scp -O dist/fips_<version>_<arch>.apk root@192.168.1.1:/tmp/
|
||||
# ssh root@192.168.1.1 apk add --allow-untrusted /tmp/fips_<version>_<arch>.apk
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Arguments
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
ARCH="aarch64"
|
||||
BIN_DIR="" # if set, use prebuilt binaries from here instead of compiling
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--arch) ARCH="$2"; shift 2 ;;
|
||||
--arch=*) ARCH="${1#*=}"; shift ;;
|
||||
--bin-dir) BIN_DIR="$2"; shift 2 ;;
|
||||
--bin-dir=*) BIN_DIR="${1#*=}"; shift ;;
|
||||
*) echo "Unknown argument: $1" >&2; exit 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Architecture mapping
|
||||
#
|
||||
# RUST_TARGET — passed to cargo --target
|
||||
# OPENWRT_ARCH — apk "arch:" field and the package filename
|
||||
#
|
||||
# Kept in sync with ../openwrt-ipk/build-ipk.sh (same target table).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
case "$ARCH" in
|
||||
aarch64)
|
||||
RUST_TARGET="aarch64-unknown-linux-musl"
|
||||
OPENWRT_ARCH="aarch64_cortex-a53"
|
||||
;;
|
||||
mipsel)
|
||||
RUST_TARGET="mipsel-unknown-linux-musl"
|
||||
OPENWRT_ARCH="mipsel_24kc"
|
||||
;;
|
||||
mips)
|
||||
RUST_TARGET="mips-unknown-linux-musl"
|
||||
OPENWRT_ARCH="mips_24kc"
|
||||
;;
|
||||
arm)
|
||||
RUST_TARGET="arm-unknown-linux-musleabihf"
|
||||
OPENWRT_ARCH="arm_cortex-a7"
|
||||
;;
|
||||
x86_64)
|
||||
RUST_TARGET="x86_64-unknown-linux-musl"
|
||||
OPENWRT_ARCH="x86_64"
|
||||
;;
|
||||
*)
|
||||
echo "Unknown arch: $ARCH" >&2
|
||||
echo "Valid: aarch64, mipsel, mips, arm, x86_64" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Paths
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
||||
# The installed-filesystem payload (init scripts, config, sysctl, etc.) is
|
||||
# shared with the .ipk package; there is one canonical copy in openwrt-ipk/.
|
||||
FILES_DIR="$PROJECT_ROOT/packaging/openwrt-ipk/files"
|
||||
DIST_DIR="$PROJECT_ROOT/dist"
|
||||
|
||||
PKG_NAME="fips"
|
||||
# Human-readable version for the filename (e.g. v0.4.0 or master.123.abcdef0),
|
||||
# mirroring the .ipk artifacts and the CI/NIP-94 plumbing.
|
||||
PKG_VERSION="${PKG_VERSION:-$(cd "$PROJECT_ROOT" && git describe --tags --always --dirty 2>/dev/null || echo "0.1.0")}"
|
||||
# apk-tools-compatible version embedded inside the package metadata.
|
||||
APK_VERSION="${APK_VERSION:-$(cd "$PROJECT_ROOT" && sh "$SCRIPT_DIR/apk-version.sh" auto)}"
|
||||
|
||||
APK_BIN="${APK_BIN:-apk}"
|
||||
if ! command -v "$APK_BIN" >/dev/null 2>&1; then
|
||||
echo "Error: apk-tools binary not found (looked for '$APK_BIN')." >&2
|
||||
echo " Build apk-tools v3 from source or set APK_BIN=/path/to/apk." >&2
|
||||
echo " See packaging/openwrt-apk/README.md." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "==> Building $PKG_NAME $PKG_VERSION (apk version $APK_VERSION) for $OPENWRT_ARCH ($RUST_TARGET)"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. Obtain binaries
|
||||
#
|
||||
# Either use a directory of prebuilt binaries (--bin-dir; CI cross-compiles
|
||||
# once in a shared job and hands them to both the .ipk and .apk packagers), or
|
||||
# compile from source here for a self-contained local build.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
if [ -n "$BIN_DIR" ]; then
|
||||
RELEASE_DIR="$BIN_DIR"
|
||||
echo "==> Using prebuilt binaries from $RELEASE_DIR"
|
||||
for bin in fips fipsctl fipstop fips-gateway; do
|
||||
[ -f "$RELEASE_DIR/$bin" ] || {
|
||||
echo "Error: prebuilt binary not found: $RELEASE_DIR/$bin" >&2
|
||||
exit 1
|
||||
}
|
||||
done
|
||||
else
|
||||
if ! command -v cargo-zigbuild &>/dev/null; then
|
||||
echo "Error: cargo-zigbuild not found." >&2
|
||||
echo " Install: cargo install cargo-zigbuild" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! rustup target list --installed | grep -q "^$RUST_TARGET$"; then
|
||||
echo "==> Adding Rust target $RUST_TARGET..."
|
||||
rustup target add "$RUST_TARGET"
|
||||
fi
|
||||
|
||||
echo "==> Compiling..."
|
||||
cd "$PROJECT_ROOT"
|
||||
cargo zigbuild \
|
||||
--release \
|
||||
--target "$RUST_TARGET" \
|
||||
--bin fips \
|
||||
--bin fipsctl \
|
||||
--bin fipstop \
|
||||
--bin fips-gateway
|
||||
|
||||
RELEASE_DIR="$PROJECT_ROOT/target/$RUST_TARGET/release"
|
||||
|
||||
echo "==> Stripping binaries..."
|
||||
STRIP="${LLVM_STRIP:-strip}"
|
||||
for bin in fips fipsctl fipstop fips-gateway; do
|
||||
"$STRIP" "$RELEASE_DIR/$bin" 2>/dev/null || true
|
||||
done
|
||||
fi
|
||||
|
||||
SIZE=$(du -sh "$RELEASE_DIR/fips" | cut -f1)
|
||||
echo " fips: $SIZE"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2. Stage the installed filesystem tree (--files root for apk mkpkg)
|
||||
# ---------------------------------------------------------------------------
|
||||
# This block is the same payload as ../openwrt-ipk/build-ipk.sh; keep the two
|
||||
# in sync. The CI apk structural check asserts every path below is present.
|
||||
|
||||
WORK_DIR="$(mktemp -d)"
|
||||
trap 'rm -rf "$WORK_DIR"' EXIT
|
||||
|
||||
STAGE_DIR="$WORK_DIR/root" # becomes the package's filesystem
|
||||
SCRIPTS_DIR="$WORK_DIR/scripts" # maintainer scripts (metadata, not payload)
|
||||
mkdir -p "$STAGE_DIR" "$SCRIPTS_DIR"
|
||||
|
||||
install -d "$STAGE_DIR/usr/bin"
|
||||
install -m 0755 "$RELEASE_DIR/fips" "$STAGE_DIR/usr/bin/fips"
|
||||
install -m 0755 "$RELEASE_DIR/fipsctl" "$STAGE_DIR/usr/bin/fipsctl"
|
||||
install -m 0755 "$RELEASE_DIR/fipstop" "$STAGE_DIR/usr/bin/fipstop"
|
||||
install -m 0755 "$RELEASE_DIR/fips-gateway" "$STAGE_DIR/usr/bin/fips-gateway"
|
||||
install -m 0755 "$FILES_DIR/usr/bin/fips-mesh-setup" "$STAGE_DIR/usr/bin/fips-mesh-setup"
|
||||
|
||||
install -d "$STAGE_DIR/etc/init.d"
|
||||
install -m 0755 "$FILES_DIR/etc/init.d/fips" "$STAGE_DIR/etc/init.d/fips"
|
||||
install -m 0755 "$FILES_DIR/etc/init.d/fips-gateway" "$STAGE_DIR/etc/init.d/fips-gateway"
|
||||
|
||||
install -d "$STAGE_DIR/etc/fips"
|
||||
install -m 0600 "$FILES_DIR/etc/fips/fips.yaml" "$STAGE_DIR/etc/fips/fips.yaml"
|
||||
install -m 0755 "$FILES_DIR/etc/fips/firewall.sh" "$STAGE_DIR/etc/fips/firewall.sh"
|
||||
|
||||
# The shared fips.yaml ships ethernet.wan.interface: "eth0", the OpenWrt 24
|
||||
# default. This .apk package targets OpenWrt 25+ (DSA), where the WAN port is
|
||||
# named "wan", so ship "wan" as the default. Patching the staged copy keeps the
|
||||
# as-installed config correct for the platform without maintaining a second copy
|
||||
# of the file; operators can still edit /etc/fips/fips.yaml for non-standard boards.
|
||||
sed -i 's|interface: "eth0"|interface: "wan"|' "$STAGE_DIR/etc/fips/fips.yaml"
|
||||
|
||||
install -d "$STAGE_DIR/etc/dnsmasq.d"
|
||||
install -m 0644 "$FILES_DIR/etc/dnsmasq.d/fips.conf" "$STAGE_DIR/etc/dnsmasq.d/fips.conf"
|
||||
|
||||
install -d "$STAGE_DIR/etc/sysctl.d"
|
||||
install -m 0644 "$FILES_DIR/etc/sysctl.d/fips-bridge.conf" "$STAGE_DIR/etc/sysctl.d/fips-bridge.conf"
|
||||
install -m 0644 "$FILES_DIR/etc/sysctl.d/fips-gateway.conf" "$STAGE_DIR/etc/sysctl.d/fips-gateway.conf"
|
||||
|
||||
install -d "$STAGE_DIR/etc/hotplug.d/net"
|
||||
install -m 0755 "$FILES_DIR/etc/hotplug.d/net/99-fips" "$STAGE_DIR/etc/hotplug.d/net/99-fips"
|
||||
|
||||
install -d "$STAGE_DIR/etc/uci-defaults"
|
||||
install -m 0755 "$FILES_DIR/etc/uci-defaults/90-fips-setup" "$STAGE_DIR/etc/uci-defaults/90-fips-setup"
|
||||
|
||||
install -d "$STAGE_DIR/lib/upgrade/keep.d"
|
||||
install -m 0644 "$FILES_DIR/lib/upgrade/keep.d/fips" "$STAGE_DIR/lib/upgrade/keep.d/fips"
|
||||
|
||||
# ---- conffiles ----
|
||||
# apk mkpkg discovers config files from /lib/apk/packages/<name>.conffiles
|
||||
# inside the --files tree (same mechanism OpenWrt's package-pack.mk uses).
|
||||
# Listing fips.yaml here makes apk preserve user edits across upgrades, the
|
||||
# apk equivalent of opkg's conffiles handling.
|
||||
install -d "$STAGE_DIR/lib/apk/packages"
|
||||
cat > "$STAGE_DIR/lib/apk/packages/${PKG_NAME}.conffiles" <<'EOF'
|
||||
/etc/fips/fips.yaml
|
||||
EOF
|
||||
|
||||
# ---- maintainer scripts ----
|
||||
# Map our opkg maintainer scripts onto apk's lifecycle phases:
|
||||
# opkg postinst -> apk post-install (enable + start services)
|
||||
# opkg prerm -> apk pre-deinstall (stop + disable services)
|
||||
|
||||
cat > "$SCRIPTS_DIR/post-install" <<'EOF'
|
||||
#!/bin/sh
|
||||
# Run first-boot UCI setup (the script deletes itself when done).
|
||||
if [ -x /etc/uci-defaults/90-fips-setup ]; then
|
||||
/etc/uci-defaults/90-fips-setup && rm -f /etc/uci-defaults/90-fips-setup
|
||||
fi
|
||||
|
||||
/etc/init.d/fips enable
|
||||
/etc/init.d/fips start
|
||||
/etc/init.d/fips-gateway enable
|
||||
/etc/init.d/fips-gateway start
|
||||
exit 0
|
||||
EOF
|
||||
|
||||
cat > "$SCRIPTS_DIR/pre-deinstall" <<'EOF'
|
||||
#!/bin/sh
|
||||
/etc/init.d/fips-gateway stop 2>/dev/null || true
|
||||
/etc/init.d/fips-gateway disable 2>/dev/null || true
|
||||
/etc/init.d/fips stop 2>/dev/null || true
|
||||
/etc/init.d/fips disable 2>/dev/null || true
|
||||
exit 0
|
||||
EOF
|
||||
|
||||
chmod 0755 "$SCRIPTS_DIR/post-install" "$SCRIPTS_DIR/pre-deinstall"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3. Assemble the .apk via apk mkpkg
|
||||
# ---------------------------------------------------------------------------
|
||||
# fakeroot makes the packaged files root-owned even though CI runs unprivileged.
|
||||
|
||||
DESCRIPTION="FIPS Mesh Network Daemon. Distributed, decentralized mesh networking over UDP, TCP, and raw Ethernet, with a TUN interface (fips0), ULA IPv6 addressing, and a .fips DNS responder."
|
||||
DEPENDS="kmod-tun kmod-br-netfilter kmod-nft-nat kmod-nf-conntrack ip-full"
|
||||
|
||||
PKG_FILENAME="${PKG_NAME}_${PKG_VERSION}_${OPENWRT_ARCH}.apk"
|
||||
mkdir -p "$DIST_DIR"
|
||||
|
||||
FAKEROOT=""
|
||||
if command -v fakeroot >/dev/null 2>&1; then
|
||||
FAKEROOT="fakeroot"
|
||||
else
|
||||
echo "Warning: fakeroot not found — packaged files will be owned by the build user." >&2
|
||||
fi
|
||||
|
||||
$FAKEROOT "$APK_BIN" mkpkg \
|
||||
--info "name:$PKG_NAME" \
|
||||
--info "version:$APK_VERSION" \
|
||||
--info "description:$DESCRIPTION" \
|
||||
--info "arch:$OPENWRT_ARCH" \
|
||||
--info "license:MIT" \
|
||||
--info "origin:$PKG_NAME" \
|
||||
--info "url:https://github.com/jmcorgan/fips" \
|
||||
--info "maintainer:FIPS Network" \
|
||||
--info "depends:$DEPENDS" \
|
||||
--script "post-install:$SCRIPTS_DIR/post-install" \
|
||||
--script "pre-deinstall:$SCRIPTS_DIR/pre-deinstall" \
|
||||
--files "$STAGE_DIR" \
|
||||
--output "$DIST_DIR/$PKG_FILENAME"
|
||||
|
||||
echo ""
|
||||
echo "==> Done: dist/$PKG_FILENAME"
|
||||
echo " $(du -sh "$DIST_DIR/$PKG_FILENAME" | cut -f1)"
|
||||
echo ""
|
||||
echo "Install on router (OpenWrt 25+, or 24.10 with apk enabled):"
|
||||
echo " scp -O dist/$PKG_FILENAME root@192.168.1.1:/tmp/"
|
||||
echo " ssh root@192.168.1.1 apk add --allow-untrusted /tmp/$PKG_FILENAME"
|
||||
@@ -96,6 +96,9 @@ define Package/fips/install
|
||||
$(INSTALL_BIN) $(RUST_RELEASE_DIR)/fipstop $(1)/usr/bin/fipstop
|
||||
$(INSTALL_BIN) $(RUST_RELEASE_DIR)/fips-gateway $(1)/usr/bin/fips-gateway
|
||||
|
||||
# 802.11s mesh backhaul setup helper
|
||||
$(INSTALL_BIN) $(CURDIR)/files/usr/bin/fips-mesh-setup $(1)/usr/bin/fips-mesh-setup
|
||||
|
||||
# procd init script
|
||||
$(INSTALL_DIR) $(1)/etc/init.d
|
||||
$(INSTALL_BIN) $(CURDIR)/files/etc/init.d/fips $(1)/etc/init.d/fips
|
||||
|
||||
@@ -14,6 +14,7 @@ For ad-hoc deployment without the build system, see
|
||||
| `/usr/bin/fipsctl` | CLI control tool (`fipsctl show peers`, `fipsctl show links`, …) |
|
||||
| `/usr/bin/fipstop` | Live TUI dashboard |
|
||||
| `/usr/bin/fips-gateway` | Outbound LAN gateway service (not started by default) |
|
||||
| `/usr/bin/fips-mesh-setup` | Opt-in helper — creates an open 802.11s mesh interface for router↔router backhaul |
|
||||
| `/etc/init.d/fips` | procd service for the daemon (auto-start, crash respawn) |
|
||||
| `/etc/init.d/fips-gateway` | procd service for the gateway (disabled by default) |
|
||||
| `/etc/fips/fips.yaml` | Node configuration (edit before first start) |
|
||||
@@ -132,7 +133,10 @@ The default config enables:
|
||||
|
||||
For Ethernet transport, uncomment the `ethernet:` section and set the correct
|
||||
physical interface names for your router. **Always use physical port names
|
||||
(`eth0`, `eth1`), never bridge names (`br-lan`).** See
|
||||
(`eth0`, `eth1`, or DSA port names like `wan`/`lan1`), never bridge names
|
||||
(`br-lan`).** The shipped default WAN port is `eth0` (OpenWrt 24); on OpenWrt
|
||||
25 (DSA) boards the WAN port is named `wan` — the `.apk` package ships that
|
||||
default. Run `ip link show` to confirm the names on your board. See
|
||||
[`deploy/native/README.md`](../../deploy/native/README.md) for details.
|
||||
|
||||
## Service management
|
||||
|
||||
@@ -27,11 +27,14 @@ set -euo pipefail
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
ARCH="aarch64"
|
||||
BIN_DIR="" # if set, use prebuilt binaries from here instead of compiling
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--arch) ARCH="$2"; shift 2 ;;
|
||||
--arch=*) ARCH="${1#*=}"; shift ;;
|
||||
--bin-dir) BIN_DIR="$2"; shift 2 ;;
|
||||
--bin-dir=*) BIN_DIR="${1#*=}"; shift ;;
|
||||
*) echo "Unknown argument: $1" >&2; exit 1 ;;
|
||||
esac
|
||||
done
|
||||
@@ -86,44 +89,55 @@ PKG_VERSION="${PKG_VERSION:-$(cd "$PROJECT_ROOT" && git describe --tags --always
|
||||
echo "==> Building $PKG_NAME $PKG_VERSION for $OPENWRT_ARCH ($RUST_TARGET)"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Prerequisites
|
||||
# 1. Obtain binaries
|
||||
#
|
||||
# Either use a directory of prebuilt binaries (--bin-dir; CI cross-compiles
|
||||
# once in a shared job and hands them to both the .ipk and .apk packagers), or
|
||||
# compile from source here for a self-contained local build.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
if ! command -v cargo-zigbuild &>/dev/null; then
|
||||
echo "Error: cargo-zigbuild not found." >&2
|
||||
echo " Install: cargo install cargo-zigbuild" >&2
|
||||
exit 1
|
||||
if [ -n "$BIN_DIR" ]; then
|
||||
RELEASE_DIR="$BIN_DIR"
|
||||
echo "==> Using prebuilt binaries from $RELEASE_DIR"
|
||||
for bin in fips fipsctl fipstop fips-gateway; do
|
||||
[ -f "$RELEASE_DIR/$bin" ] || {
|
||||
echo "Error: prebuilt binary not found: $RELEASE_DIR/$bin" >&2
|
||||
exit 1
|
||||
}
|
||||
done
|
||||
else
|
||||
if ! command -v cargo-zigbuild &>/dev/null; then
|
||||
echo "Error: cargo-zigbuild not found." >&2
|
||||
echo " Install: cargo install cargo-zigbuild" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! rustup target list --installed | grep -q "^$RUST_TARGET$"; then
|
||||
echo "==> Adding Rust target $RUST_TARGET..."
|
||||
rustup target add "$RUST_TARGET"
|
||||
fi
|
||||
|
||||
echo "==> Compiling..."
|
||||
cd "$PROJECT_ROOT"
|
||||
cargo zigbuild \
|
||||
--release \
|
||||
--target "$RUST_TARGET" \
|
||||
--bin fips \
|
||||
--bin fipsctl \
|
||||
--bin fipstop \
|
||||
--bin fips-gateway
|
||||
|
||||
RELEASE_DIR="$PROJECT_ROOT/target/$RUST_TARGET/release"
|
||||
|
||||
echo "==> Stripping binaries..."
|
||||
STRIP="${LLVM_STRIP:-strip}"
|
||||
for bin in fips fipsctl fipstop fips-gateway; do
|
||||
"$STRIP" "$RELEASE_DIR/$bin" 2>/dev/null || true
|
||||
done
|
||||
fi
|
||||
|
||||
if ! rustup target list --installed | grep -q "^$RUST_TARGET$"; then
|
||||
echo "==> Adding Rust target $RUST_TARGET..."
|
||||
rustup target add "$RUST_TARGET"
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. Build
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
echo "==> Compiling..."
|
||||
cd "$PROJECT_ROOT"
|
||||
cargo zigbuild \
|
||||
--release \
|
||||
--target "$RUST_TARGET" \
|
||||
--bin fips \
|
||||
--bin fipsctl \
|
||||
--bin fipstop \
|
||||
--bin fips-gateway
|
||||
|
||||
RELEASE_DIR="$PROJECT_ROOT/target/$RUST_TARGET/release"
|
||||
|
||||
echo "==> Stripping binaries..."
|
||||
STRIP="${LLVM_STRIP:-strip}"
|
||||
for bin in fips fipsctl fipstop fips-gateway; do
|
||||
"$STRIP" "$RELEASE_DIR/$bin" 2>/dev/null || true
|
||||
done
|
||||
|
||||
SIZE=$(du -sh "$RELEASE_DIR/fips" | cut -f1)
|
||||
echo " fips: $SIZE after strip"
|
||||
echo " fips: $SIZE"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2. Assemble .ipk
|
||||
@@ -147,6 +161,7 @@ install -m 0755 "$RELEASE_DIR/fips" "$DATA_DIR/usr/bin/fips"
|
||||
install -m 0755 "$RELEASE_DIR/fipsctl" "$DATA_DIR/usr/bin/fipsctl"
|
||||
install -m 0755 "$RELEASE_DIR/fipstop" "$DATA_DIR/usr/bin/fipstop"
|
||||
install -m 0755 "$RELEASE_DIR/fips-gateway" "$DATA_DIR/usr/bin/fips-gateway"
|
||||
install -m 0755 "$FILES_DIR/usr/bin/fips-mesh-setup" "$DATA_DIR/usr/bin/fips-mesh-setup"
|
||||
|
||||
install -d "$DATA_DIR/etc/init.d"
|
||||
install -m 0755 "$FILES_DIR/etc/init.d/fips" "$DATA_DIR/etc/init.d/fips"
|
||||
|
||||
@@ -10,12 +10,21 @@ node:
|
||||
#
|
||||
# Or set an explicit key (overrides persistent):
|
||||
# nsec: "nsec1..."
|
||||
discovery:
|
||||
# Optional Nostr-mediated overlay endpoint discovery.
|
||||
# Mesh-lookup protocol (node.lookup.*): the overlay coordinate-lookup engine
|
||||
# (mesh address -> coordinates). Defaults shown; uncomment to override.
|
||||
# lookup:
|
||||
# ttl: 64
|
||||
# attempt_timeouts_secs: [1, 2, 4, 8]
|
||||
# recent_expiry_secs: 10
|
||||
# backoff_base_secs: 0
|
||||
# backoff_max_secs: 0
|
||||
# forward_min_interval_secs: 2
|
||||
rendezvous:
|
||||
# Optional Nostr-mediated overlay endpoint rendezvous.
|
||||
# nostr:
|
||||
# enabled: true
|
||||
# policy: configured_only # disabled | configured_only | open
|
||||
# open_discovery_max_pending: 64 # caps queued open-discovery retries
|
||||
# open_discovery_max_pending: 64 # caps queued open-rendezvous retries
|
||||
# app: "fips-overlay-v1"
|
||||
# advertise: true
|
||||
# advert_relays:
|
||||
@@ -74,23 +83,49 @@ transports:
|
||||
ethernet:
|
||||
wan:
|
||||
interface: "eth0"
|
||||
discovery: true
|
||||
listen: true
|
||||
announce: true
|
||||
auto_connect: true
|
||||
accept_connections: true
|
||||
wwan:
|
||||
interface: "phy0-sta0"
|
||||
discovery: true
|
||||
listen: true
|
||||
announce: true
|
||||
auto_connect: true
|
||||
accept_connections: true
|
||||
lan:
|
||||
interface: "br-lan"
|
||||
discovery: true
|
||||
listen: true
|
||||
announce: true
|
||||
auto_connect: true
|
||||
accept_connections: true
|
||||
|
||||
# 802.11s mesh backhaul between FIPS routers. These entries ship
|
||||
# commented out so a stock install that never creates fips-mesh*
|
||||
# logs no per-boot "interface missing" bind warning. Running
|
||||
# 'fips-mesh-setup <radio>' creates the interface AND uncomments the
|
||||
# matching block here (once per radio; radio0 -> fips-mesh0, radio1 ->
|
||||
# fips-mesh1); 'fips-mesh-setup remove' re-comments it. Restart fips
|
||||
# after — a transport whose interface is missing at startup is skipped,
|
||||
# not retried. Dual-band routers can mesh on both bands at once —
|
||||
# failover, not multipath: FIPS keeps one active link per peer, the
|
||||
# other band stands by. The mesh runs OPEN (no SAE) with 802.11s
|
||||
# forwarding off: FIPS's Noise handshake is the encryption and
|
||||
# authentication, and FIPS is the routing layer. See
|
||||
# docs/how-to/set-up-80211s-mesh-backhaul.md.
|
||||
# mesh0:
|
||||
# interface: "fips-mesh0"
|
||||
# discovery: true
|
||||
# announce: true
|
||||
# auto_connect: true
|
||||
# accept_connections: true
|
||||
# mesh1:
|
||||
# interface: "fips-mesh1"
|
||||
# discovery: true
|
||||
# announce: true
|
||||
# auto_connect: true
|
||||
# accept_connections: true
|
||||
|
||||
# Bluetooth Low Energy transport — requires BlueZ and the 'ble' feature.
|
||||
# ble:
|
||||
# adapter: "hci0"
|
||||
@@ -121,5 +156,5 @@ peers: []
|
||||
# - transport: udp
|
||||
# addr: "test-us01.fips.network:2121" # IP or hostname (e.g., "peer.example.com:2121")
|
||||
# - transport: udp
|
||||
# addr: "nat" # Use node.discovery.nostr for Nostr/STUN hole punching
|
||||
# addr: "nat" # Use node.rendezvous.nostr for Nostr/STUN hole punching
|
||||
# connect_policy: auto_connect
|
||||
|
||||
@@ -12,6 +12,17 @@ start_service() {
|
||||
# Ensure TUN module is loaded before starting the daemon.
|
||||
modprobe tun 2>/dev/null || true
|
||||
|
||||
# Pre-create the control-socket runtime directory so the daemon binds the
|
||||
# canonical /run/fips/control.sock instead of falling back to /tmp. This is
|
||||
# the procd equivalent of the systemd unit's RuntimeDirectory=fips (and the
|
||||
# fips.tmpfiles "d /run/fips 0750 root fips" entry); OpenWrt was the only
|
||||
# platform missing it. Without it, fips-gateway — which creates /run/fips
|
||||
# for its own gateway.sock — makes fipsctl/fipstop resolve a control socket
|
||||
# under /run/fips that the daemon actually bound under /tmp.
|
||||
mkdir -p /run/fips
|
||||
chmod 0750 /run/fips
|
||||
chgrp fips /run/fips 2>/dev/null || true
|
||||
|
||||
procd_open_instance
|
||||
procd_set_param command "$PROG" --config "$CONFIG"
|
||||
# Respawn: restart after 5 s, give up after 5 consecutive failures within
|
||||
|
||||
259
packaging/openwrt-ipk/files/usr/bin/fips-mesh-setup
Executable file
259
packaging/openwrt-ipk/files/usr/bin/fips-mesh-setup
Executable file
@@ -0,0 +1,259 @@
|
||||
#!/bin/sh
|
||||
# fips-mesh-setup — configure open 802.11s mesh interfaces for FIPS backhaul.
|
||||
#
|
||||
# Usage:
|
||||
# fips-mesh-setup <radio> [mesh-id] e.g. fips-mesh-setup radio1
|
||||
# fips-mesh-setup remove [radio] no radio: remove all instances
|
||||
#
|
||||
# Creates a mesh-point interface on the given radio and leaves everything
|
||||
# above L2 to FIPS. Run once per radio: dual-band routers can mesh on both
|
||||
# bands at once (2.4 GHz reaches further, 5 GHz carries more). Note this is
|
||||
# failover, not multipath — FIPS keeps one active link per peer; the other
|
||||
# band stands by and reconnects the peer if the active link dies.
|
||||
#
|
||||
# - encryption 'none' — the mesh is OPEN on purpose. FIPS's Noise IK
|
||||
# handshake authenticates and encrypts every peer link, so SAE would
|
||||
# only duplicate that (and on ath10k it forces the slower raw Tx/Rx
|
||||
# firmware mode). A stranger can form an 802.11s peering but cannot
|
||||
# pass the FIPS handshake.
|
||||
# - mesh_fwding '0' — disables 802.11s HWMP forwarding so each mesh
|
||||
# link is a plain L2 neighbor link. FIPS is the routing layer; two
|
||||
# routing layers would fight.
|
||||
#
|
||||
# Interfaces are named per radio index (radio0 -> fips-mesh0, radio1 ->
|
||||
# fips-mesh1) and are intentionally NOT bridged into br-lan: the FIPS
|
||||
# Ethernet transport binds each directly and runs discovery beacons over it.
|
||||
#
|
||||
# The shipped /etc/fips/fips.yaml carries 'mesh0' and 'mesh1' entries under
|
||||
# 'transports.ethernet' bound to these names, but commented out — a stock
|
||||
# install that never creates fips-mesh* then logs no bind warning. This
|
||||
# helper uncomments the matching entry when it creates an interface and
|
||||
# re-comments it on remove, so the daemon binds the transport without a
|
||||
# manual config edit. After an interface is up, restart fips.
|
||||
# See docs/how-to/set-up-80211s-mesh-backhaul.md for the full guide.
|
||||
|
||||
DEFAULT_MESH_ID="fips-mesh"
|
||||
CONFIG="/etc/fips/fips.yaml"
|
||||
|
||||
# Replace $CONFIG with the rewritten $CONFIG.tmp. Force mode 0600 first: the
|
||||
# package installs fips.yaml 0600 (it may hold an inline 'nsec' private key),
|
||||
# and a fresh tmp file would otherwise land world-readable after the move.
|
||||
mesh_config_write() {
|
||||
chmod 600 "$CONFIG.tmp" && mv "$CONFIG.tmp" "$CONFIG"
|
||||
}
|
||||
|
||||
# Uncomment the 'mesh<idx>' transports.ethernet block in $CONFIG (created by
|
||||
# 'fips-mesh-setup'). Reversible with mesh_config_disable. Returns:
|
||||
# 0 enabled (or already active) 1 no config file 2 no such block
|
||||
mesh_config_enable() {
|
||||
idx="$1"
|
||||
[ -f "$CONFIG" ] || return 1
|
||||
grep -q "^ mesh$idx:" "$CONFIG" && return 0
|
||||
grep -q "^ # mesh$idx:" "$CONFIG" || return 2
|
||||
awk -v idx="$idx" '
|
||||
$0 ~ ("^ # mesh" idx ":[ \t]*$") { blk = 1; sub(/^ # /, " "); print; next }
|
||||
blk && /^ # / { sub(/^ # /, " "); print; next }
|
||||
{ blk = 0; print }
|
||||
' "$CONFIG" > "$CONFIG.tmp" && mesh_config_write
|
||||
}
|
||||
|
||||
# Re-comment the 'mesh<idx>' block so the daemon stops binding it (and stops
|
||||
# warning about the now-missing interface). Inverse of mesh_config_enable.
|
||||
mesh_config_disable() {
|
||||
idx="$1"
|
||||
[ -f "$CONFIG" ] || return 1
|
||||
grep -q "^ mesh$idx:" "$CONFIG" || return 0
|
||||
awk -v idx="$idx" '
|
||||
$0 ~ ("^ mesh" idx ":[ \t]*$") { blk = 1; sub(/^ /, " # "); print; next }
|
||||
blk && /^ / { sub(/^ /, " # "); print; next }
|
||||
{ blk = 0; print }
|
||||
' "$CONFIG" > "$CONFIG.tmp" && mesh_config_write
|
||||
}
|
||||
|
||||
usage() {
|
||||
echo "Usage: fips-mesh-setup <radio> [mesh-id]" >&2
|
||||
echo " fips-mesh-setup remove [radio]" >&2
|
||||
echo "Radios on this device:" >&2
|
||||
uci show wireless 2>/dev/null | sed -n "s/^wireless\.\([^.]*\)=wifi-device$/ \1/p" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
# List the UCI section names of fips-managed mesh wifi-ifaces.
|
||||
mesh_sections() {
|
||||
uci show wireless 2>/dev/null | sed -n "s/^wireless\.\(fips_mesh[^.=]*\)=wifi-iface$/\1/p"
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# remove [radio] — delete the wireless and network sections created below
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
if [ "$1" = "remove" ]; then
|
||||
if [ -n "$2" ]; then
|
||||
SECTIONS="fips_mesh_$(printf '%s' "$2" | tr -c 'a-zA-Z0-9_' '_')"
|
||||
else
|
||||
SECTIONS="$(mesh_sections)"
|
||||
fi
|
||||
[ -n "$SECTIONS" ] || {
|
||||
echo "No fips mesh instances configured."
|
||||
exit 0
|
||||
}
|
||||
for section in $SECTIONS; do
|
||||
ifname="$(uci -q get "wireless.$section.ifname")"
|
||||
uci -q delete "wireless.$section"
|
||||
uci -q delete "network.$section"
|
||||
# Re-comment the matching mesh<N> transport in fips.yaml so the
|
||||
# daemon stops warning about the interface we just removed.
|
||||
idx="$(printf '%s' "$ifname" | sed -n 's/.*[^0-9]\([0-9]\{1,\}\)$/\1/p')"
|
||||
[ -n "$idx" ] && mesh_config_disable "$idx"
|
||||
echo "Removed ${ifname:-$section}."
|
||||
done
|
||||
uci commit wireless
|
||||
uci commit network
|
||||
# 'wifi reload' re-applies the whole wireless config, so it briefly drops
|
||||
# every client AP on all radios (a few seconds) — expected on remove.
|
||||
wifi reload
|
||||
echo "Restart fips: /etc/init.d/fips restart"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
RADIO="$1"
|
||||
MESH_ID="${2:-$DEFAULT_MESH_ID}"
|
||||
|
||||
[ -n "$RADIO" ] || usage
|
||||
|
||||
if [ "$(uci -q get "wireless.$RADIO")" != "wifi-device" ]; then
|
||||
echo "Error: '$RADIO' is not a wifi-device in /etc/config/wireless." >&2
|
||||
usage
|
||||
fi
|
||||
|
||||
# One instance per radio: section fips_mesh_<radio>, netdev fips-mesh<N>
|
||||
# where N is the radio's trailing index (radio0 -> fips-mesh0). For radios
|
||||
# named without a trailing number, fall back to the first free index.
|
||||
SECTION="fips_mesh_$(printf '%s' "$RADIO" | tr -c 'a-zA-Z0-9_' '_')"
|
||||
IDX="$(printf '%s' "$RADIO" | sed -n 's/.*[^0-9]\([0-9]\{1,\}\)$/\1/p')"
|
||||
[ -n "$IDX" ] || IDX="$(printf '%s' "$RADIO" | sed -n 's/^\([0-9]\{1,\}\)$/\1/p')"
|
||||
if [ -z "$IDX" ]; then
|
||||
IDX=0
|
||||
while uci show wireless 2>/dev/null | grep -q "\.ifname='fips-mesh$IDX'"; do
|
||||
IDX=$((IDX + 1))
|
||||
done
|
||||
fi
|
||||
MESH_IFNAME="fips-mesh$IDX"
|
||||
|
||||
# Refuse a name collision from another radio's instance (e.g. two radios
|
||||
# whose names end in the same digit) rather than silently hijacking it.
|
||||
OWNER="$(uci show wireless 2>/dev/null \
|
||||
| sed -n "s/^wireless\.\(fips_mesh[^.=]*\)\.ifname='$MESH_IFNAME'$/\1/p")"
|
||||
if [ -n "$OWNER" ] && [ "$OWNER" != "$SECTION" ]; then
|
||||
echo "Error: $MESH_IFNAME is already used by section '$OWNER'." >&2
|
||||
echo "Remove it first: fips-mesh-setup remove" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Driver capability check (advisory — config below is harmless either way)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
if command -v iw >/dev/null 2>&1; then
|
||||
if ! iw list 2>/dev/null | grep -q "\* mesh point"; then
|
||||
echo "Warning: no radio on this device advertises 'mesh point' support" >&2
|
||||
echo "(iw list | grep 'mesh point'). The interface may fail to come up." >&2
|
||||
fi
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Wireless: open 802.11s mesh point, HWMP forwarding off
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
uci -q delete "wireless.$SECTION"
|
||||
uci set "wireless.$SECTION=wifi-iface"
|
||||
uci set "wireless.$SECTION.device=$RADIO"
|
||||
uci set "wireless.$SECTION.mode=mesh"
|
||||
uci set "wireless.$SECTION.mesh_id=$MESH_ID"
|
||||
uci set "wireless.$SECTION.encryption=none"
|
||||
uci set "wireless.$SECTION.mesh_fwding=0"
|
||||
uci set "wireless.$SECTION.ifname=$MESH_IFNAME"
|
||||
uci set "wireless.$SECTION.network=$SECTION"
|
||||
|
||||
# Radios ship disabled on fresh OpenWrt installs; a disabled radio would
|
||||
# leave the mesh interface down with no error anywhere visible.
|
||||
if [ "$(uci -q get "wireless.$RADIO.disabled")" = "1" ]; then
|
||||
echo "Note: enabling $RADIO (was disabled)."
|
||||
uci -q delete "wireless.$RADIO.disabled"
|
||||
fi
|
||||
|
||||
# The mesh inherits the radio's channel, and mesh points only peer on the
|
||||
# same channel. 'auto' lets each router pick its own — the classic silent
|
||||
# non-peering cause — so surface the setting loudly.
|
||||
CHANNEL="$(uci -q get "wireless.$RADIO.channel")"
|
||||
BAND="$(uci -q get "wireless.$RADIO.band")"
|
||||
if [ -z "$CHANNEL" ] || [ "$CHANNEL" = "auto" ]; then
|
||||
echo "Warning: $RADIO channel is '${CHANNEL:-unset}' — each router may" >&2
|
||||
echo "auto-select a different channel and mesh points only peer on the" >&2
|
||||
echo "same one. Pin the same channel on every backhaul router, e.g.:" >&2
|
||||
echo " uci set wireless.$RADIO.channel='36' && uci commit wireless && wifi reload" >&2
|
||||
fi
|
||||
|
||||
# A client (sta) interface on the same radio follows its upstream AP's
|
||||
# channel and drags every other interface with it — a mesh pinned to a
|
||||
# different channel silently never joins, and does not recover when the
|
||||
# STA disconnects.
|
||||
for s in $(uci show wireless 2>/dev/null | sed -n "s/^wireless\.\([^.]*\)\.mode='sta'$/\1/p"); do
|
||||
if [ "$(uci -q get "wireless.$s.device")" = "$RADIO" ]; then
|
||||
echo "Warning: $RADIO also carries client interface '$s' (mode 'sta')." >&2
|
||||
echo "The whole radio follows that STA's upstream channel — a mesh" >&2
|
||||
echo "pinned to a different channel stays down silently. Align the" >&2
|
||||
echo "mesh channel with the upstream AP, or put the mesh on a radio" >&2
|
||||
echo "without a STA (a roaming uplink is incompatible with a" >&2
|
||||
echo "fixed-channel mesh on the same radio)." >&2
|
||||
fi
|
||||
done
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Network: unmanaged interface so netifd brings the netdev up. No IP config —
|
||||
# the FIPS Ethernet transport speaks raw frames on it.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
uci -q delete "network.$SECTION"
|
||||
uci set "network.$SECTION=interface"
|
||||
uci set "network.$SECTION.proto=none"
|
||||
|
||||
uci commit wireless
|
||||
uci commit network
|
||||
# 'wifi reload' re-applies the whole wireless config, so it briefly drops
|
||||
# every client AP on all radios (a few seconds) — expected when adding a mesh.
|
||||
wifi reload
|
||||
|
||||
# Enable the matching mesh<N> transport in the shipped fips.yaml (it ships
|
||||
# commented out). Tailor the restart hint to what we could do.
|
||||
mesh_config_enable "$IDX"
|
||||
case $? in
|
||||
0) TRANSPORT_NOTE="The mesh$IDX transport in $CONFIG that binds '$MESH_IFNAME' is
|
||||
now uncommented and enabled." ;;
|
||||
1) TRANSPORT_NOTE="No $CONFIG found — add a transports.ethernet entry binding
|
||||
interface '$MESH_IFNAME' by hand." ;;
|
||||
*) TRANSPORT_NOTE="No 'mesh$IDX' entry in $CONFIG — add a transports.ethernet
|
||||
entry binding interface '$MESH_IFNAME' by hand (copy the mesh0 block)." ;;
|
||||
esac
|
||||
|
||||
cat <<EOF
|
||||
Created open 802.11s mesh '$MESH_ID' as $MESH_IFNAME on $RADIO \
|
||||
(band ${BAND:-?}, channel ${CHANNEL:-auto}).
|
||||
|
||||
ALL routers in this backhaul must share this mesh ID AND channel
|
||||
(per band). On a dual-band router, run fips-mesh-setup for the other
|
||||
radio too — second band is a standby path (failover, not multipath).
|
||||
|
||||
Next steps:
|
||||
1. $TRANSPORT_NOTE
|
||||
Restart the daemon AFTER the interface is up — a transport whose
|
||||
interface is missing at startup is skipped, not retried:
|
||||
/etc/init.d/fips restart
|
||||
2. Verify L2 peering with a second FIPS router in range:
|
||||
iw dev $MESH_IFNAME station dump
|
||||
and the FIPS link on top of it:
|
||||
fipsctl show peers
|
||||
|
||||
Run 'fips-mesh-setup remove' to undo all instances, or
|
||||
'fips-mesh-setup remove $RADIO' for just this one.
|
||||
EOF
|
||||
@@ -68,7 +68,7 @@ and set the interface name:
|
||||
transports:
|
||||
ethernet:
|
||||
interface: "eth0"
|
||||
discovery: true
|
||||
listen: true
|
||||
announce: true
|
||||
auto_connect: true
|
||||
accept_connections: true
|
||||
|
||||
@@ -8,7 +8,7 @@ use fips::config::{IdentitySource, resolve_identity};
|
||||
use fips::version;
|
||||
use fips::{Config, Node};
|
||||
use std::path::PathBuf;
|
||||
use tracing::{debug, error, info, warn};
|
||||
use tracing::{debug, error, info};
|
||||
use tracing_subscriber::{EnvFilter, fmt};
|
||||
|
||||
/// FIPS mesh network daemon
|
||||
@@ -157,26 +157,23 @@ async fn run_daemon(
|
||||
|
||||
info!("FIPS running");
|
||||
|
||||
// Run the RX event loop until shutdown signal.
|
||||
// stop() drops the packet channel, causing run_rx_loop to exit.
|
||||
tokio::select! {
|
||||
result = node.run_rx_loop() => {
|
||||
match result {
|
||||
Ok(()) => info!("RX loop exited"),
|
||||
Err(e) => error!("RX loop error: {}", e),
|
||||
}
|
||||
}
|
||||
_ = shutdown_signal => {
|
||||
info!("Shutdown signal received");
|
||||
}
|
||||
// Serve until the shutdown signal, then drain in place before returning.
|
||||
// The rx loop observes the signal directly, so its channels are never
|
||||
// destructively cancelled — they live in the loop's locals across serve and
|
||||
// drain, and are dropped only on clean exit (after which teardown does not
|
||||
// need them). On the signal the loop broadcasts a shutdown Disconnect and
|
||||
// waits (bounded by node.drain_timeout_secs) for peers to clear.
|
||||
match node.run_rx_loop_with_shutdown(shutdown_signal).await {
|
||||
Ok(()) => info!("RX loop exited"),
|
||||
Err(e) => error!("RX loop error: {}", e),
|
||||
}
|
||||
|
||||
info!("FIPS shutting down");
|
||||
|
||||
// Stop the node (shuts down transports, TUN, I/O threads)
|
||||
if let Err(e) = node.stop().await {
|
||||
warn!("Error during shutdown: {}", e);
|
||||
}
|
||||
// Close the drain window (if the loop drained) and tear down. A drained
|
||||
// loop tears down without re-broadcasting; a loop that exited some other
|
||||
// way falls back to the immediate stop().
|
||||
node.finish_shutdown().await;
|
||||
|
||||
info!("FIPS shutdown complete");
|
||||
}
|
||||
|
||||
@@ -82,6 +82,8 @@ enum Commands {
|
||||
enum StatsCommands {
|
||||
/// List available history metrics
|
||||
List,
|
||||
/// Dump current counter values for every protocol metric family
|
||||
Metrics,
|
||||
/// List peers tracked in the stats history
|
||||
Peers,
|
||||
/// Fetch a time-series window for a metric
|
||||
@@ -448,6 +450,7 @@ fn main() {
|
||||
}
|
||||
Commands::Stats { what } => match what {
|
||||
StatsCommands::List => build_query("show_stats_list"),
|
||||
StatsCommands::Metrics => build_query("show_metrics"),
|
||||
StatsCommands::Peers => build_query("show_stats_peers"),
|
||||
StatsCommands::History {
|
||||
metric,
|
||||
|
||||
@@ -2,7 +2,7 @@ use ratatui::widgets::TableState;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Hash)]
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
|
||||
pub enum Tab {
|
||||
Node,
|
||||
Peers,
|
||||
@@ -113,6 +113,19 @@ impl Tab {
|
||||
Tab::Peers | Tab::Sessions | Tab::Transports | Tab::Gateway
|
||||
)
|
||||
}
|
||||
|
||||
/// Number of focusable, independently-scrollable panes on this tab, for the
|
||||
/// multi-pane focus/scroll model. Returns 0 for tabs that don't participate
|
||||
/// (they use table selection or their own scroll instead). The Tree, Bloom
|
||||
/// (Filters), and Routing tabs each lay out three stacked panes; the
|
||||
/// Performance (Mmp) tab lays out two (Link MMP, Session MMP).
|
||||
pub fn scroll_pane_count(&self) -> usize {
|
||||
match self {
|
||||
Tab::Tree | Tab::Bloom | Tab::Routing => 3,
|
||||
Tab::Mmp => 2,
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
@@ -125,6 +138,17 @@ pub struct DetailView {
|
||||
pub scroll: u16,
|
||||
}
|
||||
|
||||
/// A pending Del-disconnect confirmation against a selected peer. Holds the
|
||||
/// peer's npub (for the control command) and a human-readable label plus a
|
||||
/// reconnect note tailored to the peer kind (or a generic line when the
|
||||
/// connect-policy is not surfaced).
|
||||
#[derive(Clone)]
|
||||
pub struct ConfirmDisconnect {
|
||||
pub npub: String,
|
||||
pub display_name: String,
|
||||
pub reconnect_note: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub enum SelectedTreeItem {
|
||||
None,
|
||||
@@ -132,6 +156,45 @@ pub enum SelectedTreeItem {
|
||||
Link,
|
||||
}
|
||||
|
||||
/// Per-view column-sort state: the active sort column index and direction.
|
||||
/// `s` cycles the column; `S` toggles direction. Default is column 0 ascending,
|
||||
/// which for the name-first column layouts is an alphabetical-by-name order.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub struct SortState {
|
||||
pub col: usize,
|
||||
pub descending: bool,
|
||||
}
|
||||
|
||||
impl SortState {
|
||||
/// Cycle to the next sort column (wrapping over `n` columns), resetting to
|
||||
/// ascending on a column change so a fresh column starts predictably.
|
||||
pub fn cycle_col(&mut self, n: usize) {
|
||||
if n == 0 {
|
||||
return;
|
||||
}
|
||||
self.col = (self.col + 1) % n;
|
||||
self.descending = false;
|
||||
}
|
||||
|
||||
/// Toggle the sort direction on the current column.
|
||||
pub fn toggle_dir(&mut self) {
|
||||
self.descending = !self.descending;
|
||||
}
|
||||
}
|
||||
|
||||
/// Sortable column labels for the Link MMP table (sort key order matches the
|
||||
/// rendered column order). Column 0 is the peer name.
|
||||
pub const MMP_LINK_SORT_LABELS: &[&str] = &["name", "srtt", "loss", "etx", "lqi", "gp"];
|
||||
pub const MMP_LINK_SORT_COLS: usize = MMP_LINK_SORT_LABELS.len();
|
||||
|
||||
/// Sortable column labels for the Session MMP table.
|
||||
pub const MMP_SESSION_SORT_LABELS: &[&str] = &["name", "srtt", "loss", "etx", "sqi", "mtu"];
|
||||
pub const MMP_SESSION_SORT_COLS: usize = MMP_SESSION_SORT_LABELS.len();
|
||||
|
||||
/// Sortable column labels for the Graphs by-peer summary list.
|
||||
pub const GRAPHS_PEER_SORT_LABELS: &[&str] = &["name", "min", "max", "last", "n"];
|
||||
pub const GRAPHS_PEER_SORT_COLS: usize = GRAPHS_PEER_SORT_LABELS.len();
|
||||
|
||||
/// Options for the Graphs tab window selector.
|
||||
pub const GRAPHS_WINDOWS: &[(&str, &str)] =
|
||||
&[("1m", "1s"), ("10m", "1s"), ("1h", "1s"), ("24h", "1m")];
|
||||
@@ -201,6 +264,18 @@ pub struct App {
|
||||
pub data: HashMap<Tab, serde_json::Value>,
|
||||
pub table_states: HashMap<Tab, TableState>,
|
||||
pub detail_view: Option<DetailView>,
|
||||
/// Whether the `?` help overlay is currently shown.
|
||||
pub show_help: bool,
|
||||
/// A pending Del-disconnect confirmation, if the modal is open.
|
||||
pub confirm_disconnect: Option<ConfirmDisconnect>,
|
||||
/// Per-tab focused pane index for multi-pane tabs, generalizing the
|
||||
/// one-off peers `TableState`. Absent entry means pane 0. The accessors
|
||||
/// below are the general focus/scroll model the interaction consumers
|
||||
/// (multi-pane focus, Graphs by-peer) build on.
|
||||
pub focused_pane: HashMap<Tab, usize>,
|
||||
/// Per-(tab, pane) scroll offset (rows), generalizing the one-off detail
|
||||
/// and graphs scroll state.
|
||||
pub scroll_offsets: HashMap<(Tab, usize), u16>,
|
||||
pub last_fetch: Instant,
|
||||
pub last_error: Option<(Instant, String)>,
|
||||
pub expanded_transports: HashSet<u64>,
|
||||
@@ -227,6 +302,12 @@ pub struct App {
|
||||
/// Cached peer list from `show_stats_peers`, populated when the
|
||||
/// Graphs tab is active in a non-Node mode.
|
||||
pub graphs_peers: Vec<GraphsPeer>,
|
||||
/// Column-sort state for the Link MMP table.
|
||||
pub mmp_link_sort: SortState,
|
||||
/// Column-sort state for the Session MMP table.
|
||||
pub mmp_session_sort: SortState,
|
||||
/// Column-sort state for the Graphs by-peer summary list.
|
||||
pub graphs_peer_sort: SortState,
|
||||
}
|
||||
|
||||
impl App {
|
||||
@@ -239,6 +320,10 @@ impl App {
|
||||
data: HashMap::new(),
|
||||
table_states: HashMap::new(),
|
||||
detail_view: None,
|
||||
show_help: false,
|
||||
confirm_disconnect: None,
|
||||
focused_pane: HashMap::new(),
|
||||
scroll_offsets: HashMap::new(),
|
||||
last_fetch: Instant::now(),
|
||||
last_error: None,
|
||||
expanded_transports: HashSet::new(),
|
||||
@@ -253,13 +338,52 @@ impl App {
|
||||
graphs_peer_metric_idx: 0,
|
||||
graphs_peer_idx: 0,
|
||||
graphs_peers: Vec::new(),
|
||||
mmp_link_sort: SortState::default(),
|
||||
mmp_session_sort: SortState::default(),
|
||||
graphs_peer_sort: SortState::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Cycle the Graphs-tab view mode.
|
||||
/// Cycle the sort column for the active view (Link/Session MMP or Graphs
|
||||
/// by-peer), passing the view's column count. On the Performance tab the
|
||||
/// sort acts on the focused pane only (pane 0 Link MMP, pane 1 Session MMP),
|
||||
/// so each pane keeps its own sort state.
|
||||
pub fn cycle_sort_col(&mut self) {
|
||||
match self.active_tab {
|
||||
Tab::Mmp => {
|
||||
if self.focused_pane() == 1 {
|
||||
self.mmp_session_sort.cycle_col(MMP_SESSION_SORT_COLS);
|
||||
} else {
|
||||
self.mmp_link_sort.cycle_col(MMP_LINK_SORT_COLS);
|
||||
}
|
||||
}
|
||||
Tab::Graphs => self.graphs_peer_sort.cycle_col(GRAPHS_PEER_SORT_COLS),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Toggle the sort direction for the active view (the focused pane on the
|
||||
/// Performance tab).
|
||||
pub fn toggle_sort_dir(&mut self) {
|
||||
match self.active_tab {
|
||||
Tab::Mmp => {
|
||||
if self.focused_pane() == 1 {
|
||||
self.mmp_session_sort.toggle_dir();
|
||||
} else {
|
||||
self.mmp_link_sort.toggle_dir();
|
||||
}
|
||||
}
|
||||
Tab::Graphs => self.graphs_peer_sort.toggle_dir(),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Cycle the Graphs-tab view mode. Closes any open by-peer detail, which
|
||||
/// only applies to the MetricByPeer mode.
|
||||
pub fn graphs_next_mode(&mut self) {
|
||||
self.graphs_mode = self.graphs_mode.next();
|
||||
self.graphs_scroll = 0;
|
||||
self.detail_view = None;
|
||||
}
|
||||
|
||||
/// Advance the mode-specific selector (metric or peer).
|
||||
@@ -310,6 +434,48 @@ impl App {
|
||||
Some(&self.graphs_peers[idx])
|
||||
}
|
||||
|
||||
/// Number of peers in the current Graphs by-peer (MetricByPeer) payload.
|
||||
/// The MetricByPeer view lists one summary line per peer carried in the
|
||||
/// `peers` array of the fetched `show_stats_history_all_peers` response.
|
||||
pub fn graphs_metric_peer_count(&self) -> usize {
|
||||
self.data
|
||||
.get(&Tab::Graphs)
|
||||
.and_then(|d| d.get("peers"))
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|a| a.len())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Move the by-peer list / detail cursor to the next peer (wrapping).
|
||||
/// Shared by the MetricByPeer summary list (Up/Down select) and the
|
||||
/// open by-peer detail (Up/Down follow the selection, re-rendering the
|
||||
/// plot for the newly selected peer).
|
||||
pub fn graphs_peer_select_next(&mut self) {
|
||||
let n = self.graphs_metric_peer_count();
|
||||
if n > 0 {
|
||||
self.graphs_peer_idx = (self.graphs_peer_idx + 1) % n;
|
||||
}
|
||||
}
|
||||
|
||||
/// Move the by-peer list / detail cursor to the previous peer (wrapping).
|
||||
pub fn graphs_peer_select_prev(&mut self) {
|
||||
let n = self.graphs_metric_peer_count();
|
||||
if n > 0 {
|
||||
self.graphs_peer_idx = (self.graphs_peer_idx + n - 1) % n;
|
||||
}
|
||||
}
|
||||
|
||||
/// Open the Graphs by-peer detail (full-pane btop plot) for the currently
|
||||
/// selected peer. No-op unless the by-peer list has at least one peer.
|
||||
pub fn graphs_open_peer_detail(&mut self) {
|
||||
if self.graphs_metric_peer_count() > 0 {
|
||||
if self.graphs_peer_idx >= self.graphs_metric_peer_count() {
|
||||
self.graphs_peer_idx = 0;
|
||||
}
|
||||
self.detail_view = Some(DetailView { scroll: 0 });
|
||||
}
|
||||
}
|
||||
|
||||
/// Current Graphs-tab (window, granularity) pair.
|
||||
pub fn graphs_window(&self) -> (&'static str, &'static str) {
|
||||
GRAPHS_WINDOWS[self.graphs_window_idx % GRAPHS_WINDOWS.len()]
|
||||
@@ -393,6 +559,161 @@ impl App {
|
||||
self.detail_view = None;
|
||||
}
|
||||
|
||||
/// Toggle the `?` help overlay.
|
||||
pub fn toggle_help(&mut self) {
|
||||
self.show_help = !self.show_help;
|
||||
}
|
||||
|
||||
/// Open a disconnect confirmation for the currently selected Peers row.
|
||||
/// No-op unless the Peers tab is active with a selected row that carries an
|
||||
/// npub. The reconnect note states that the peer stays disconnected until
|
||||
/// it is manually reconnected; a manual disconnect suppresses
|
||||
/// auto-reconnect for all peer kinds, so there is no per-direction
|
||||
/// tailoring.
|
||||
pub fn request_disconnect_confirm(&mut self) {
|
||||
if self.active_tab != Tab::Peers {
|
||||
return;
|
||||
}
|
||||
let Some(selected) = self
|
||||
.table_states
|
||||
.get(&Tab::Peers)
|
||||
.and_then(|s| s.selected())
|
||||
else {
|
||||
return;
|
||||
};
|
||||
// The displayed order is the role-grouped sort (peers.rs); mirror it so
|
||||
// the confirm names the same peer the cursor is on.
|
||||
let mut peers = self
|
||||
.data
|
||||
.get(&Tab::Peers)
|
||||
.and_then(|v| v.get("peers"))
|
||||
.and_then(|v| v.as_array())
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
peers.sort_by(|a, b| {
|
||||
let rank = |p: &serde_json::Value| -> u8 {
|
||||
let parent = p
|
||||
.get("is_parent")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
let child = p.get("is_child").and_then(|v| v.as_bool()).unwrap_or(false);
|
||||
if parent {
|
||||
0
|
||||
} else if child {
|
||||
1
|
||||
} else {
|
||||
2
|
||||
}
|
||||
};
|
||||
rank(a).cmp(&rank(b)).then_with(|| {
|
||||
let lqi = |p: &serde_json::Value| {
|
||||
p.get("mmp")
|
||||
.and_then(|m| m.get("lqi"))
|
||||
.and_then(|v| v.as_f64())
|
||||
};
|
||||
match (lqi(a), lqi(b)) {
|
||||
(Some(x), Some(y)) => x.partial_cmp(&y).unwrap_or(std::cmp::Ordering::Equal),
|
||||
(Some(_), None) => std::cmp::Ordering::Less,
|
||||
(None, Some(_)) => std::cmp::Ordering::Greater,
|
||||
(None, None) => std::cmp::Ordering::Equal,
|
||||
}
|
||||
})
|
||||
});
|
||||
let Some(peer) = peers.get(selected) else {
|
||||
return;
|
||||
};
|
||||
let npub = peer.get("npub").and_then(|v| v.as_str()).unwrap_or("");
|
||||
if npub.is_empty() {
|
||||
return;
|
||||
}
|
||||
let display_name = peer
|
||||
.get("display_name")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or(npub)
|
||||
.to_string();
|
||||
let reconnect_note = "It stays disconnected until you manually reconnect it.".to_string();
|
||||
self.confirm_disconnect = Some(ConfirmDisconnect {
|
||||
npub: npub.to_string(),
|
||||
display_name,
|
||||
reconnect_note,
|
||||
});
|
||||
}
|
||||
|
||||
/// Cancel a pending disconnect confirmation.
|
||||
pub fn cancel_disconnect(&mut self) {
|
||||
self.confirm_disconnect = None;
|
||||
}
|
||||
|
||||
/// Take the pending disconnect target, clearing the confirmation. Returns
|
||||
/// the npub to disconnect when one was confirmed.
|
||||
pub fn take_disconnect_target(&mut self) -> Option<String> {
|
||||
self.confirm_disconnect.take().map(|c| c.npub)
|
||||
}
|
||||
|
||||
/// Deselect the active tab's table row (return to the overview state).
|
||||
/// No-op when the active tab has no selection.
|
||||
pub fn deselect_row(&mut self) {
|
||||
if let Some(state) = self.table_states.get_mut(&self.active_tab) {
|
||||
state.select(None);
|
||||
}
|
||||
}
|
||||
|
||||
// The focus/scroll model below is the shared substrate the interaction
|
||||
// consumers (multi-pane focus, Graphs by-peer detail) build on; some
|
||||
// accessors land ahead of their first consumer, mirroring the test-kit's
|
||||
// not-yet-used-helper allowance.
|
||||
/// Currently focused pane index on the active tab (0 if unset). The general
|
||||
/// focus model the multi-pane and Graphs-by-peer consumers read.
|
||||
#[allow(dead_code)]
|
||||
pub fn focused_pane(&self) -> usize {
|
||||
self.focused_pane
|
||||
.get(&self.active_tab)
|
||||
.copied()
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Cycle pane focus forward across `pane_count` panes on the active tab.
|
||||
#[allow(dead_code)]
|
||||
pub fn focus_next_pane(&mut self, pane_count: usize) {
|
||||
if pane_count == 0 {
|
||||
return;
|
||||
}
|
||||
let cur = self.focused_pane();
|
||||
self.focused_pane
|
||||
.insert(self.active_tab, (cur + 1) % pane_count);
|
||||
}
|
||||
|
||||
/// Scroll offset for a given pane on the active tab.
|
||||
pub fn pane_scroll(&self, pane: usize) -> u16 {
|
||||
self.scroll_offsets
|
||||
.get(&(self.active_tab, pane))
|
||||
.copied()
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Scroll the focused pane on the active tab by `delta` rows (saturating),
|
||||
/// generalizing the one-off detail/graphs scroll counters.
|
||||
#[allow(dead_code)]
|
||||
pub fn scroll_focused_pane(&mut self, delta: i16) {
|
||||
let pane = self.focused_pane();
|
||||
let entry = self
|
||||
.scroll_offsets
|
||||
.entry((self.active_tab, pane))
|
||||
.or_insert(0);
|
||||
*entry = if delta >= 0 {
|
||||
entry.saturating_add(delta as u16)
|
||||
} else {
|
||||
entry.saturating_sub((-delta) as u16)
|
||||
};
|
||||
}
|
||||
|
||||
/// Set the focused pane's scroll offset directly (used by Home/End). End
|
||||
/// passes a large value the renderer clamps to the pane's content height.
|
||||
pub fn set_focused_pane_scroll(&mut self, offset: u16) {
|
||||
let pane = self.focused_pane();
|
||||
self.scroll_offsets.insert((self.active_tab, pane), offset);
|
||||
}
|
||||
|
||||
/// Scroll detail view down.
|
||||
pub fn scroll_detail_down(&mut self) {
|
||||
if let Some(ref mut dv) = self.detail_view {
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
use ratatui::crossterm::event::{self, Event as CrosstermEvent, KeyEvent};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::mpsc;
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
pub enum Event {
|
||||
Key(KeyEvent),
|
||||
@@ -9,45 +11,87 @@ pub enum Event {
|
||||
Tick,
|
||||
}
|
||||
|
||||
/// Upper bound on a single `event::poll` wait. Kept short (vs the full
|
||||
/// tick interval) so [`EventHandler::stop`] can join the input thread
|
||||
/// promptly at quit instead of blocking for up to a refresh interval.
|
||||
const POLL_INTERVAL: Duration = Duration::from_millis(250);
|
||||
|
||||
pub struct EventHandler {
|
||||
rx: mpsc::Receiver<Event>,
|
||||
running: Arc<AtomicBool>,
|
||||
handle: Option<thread::JoinHandle<()>>,
|
||||
}
|
||||
|
||||
impl EventHandler {
|
||||
pub fn new(tick_rate: Duration) -> Self {
|
||||
let (tx, rx) = mpsc::channel();
|
||||
let running = Arc::new(AtomicBool::new(true));
|
||||
let thread_running = Arc::clone(&running);
|
||||
|
||||
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;
|
||||
}
|
||||
let handle = thread::spawn(move || {
|
||||
let mut last_tick = Instant::now();
|
||||
while thread_running.load(Ordering::Relaxed) {
|
||||
// Bound the poll by the time left until the next tick, but
|
||||
// never longer than POLL_INTERVAL so the running flag is
|
||||
// checked (and quit honored) promptly.
|
||||
let timeout = tick_rate
|
||||
.saturating_sub(last_tick.elapsed())
|
||||
.min(POLL_INTERVAL);
|
||||
|
||||
match event::poll(timeout) {
|
||||
Ok(true) => match event::read() {
|
||||
Ok(CrosstermEvent::Key(key)) => {
|
||||
if tx.send(Event::Key(key)).is_err() {
|
||||
return;
|
||||
}
|
||||
CrosstermEvent::Resize(..) => {
|
||||
if tx.send(Event::Resize).is_err() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Poll timed out — send a tick
|
||||
Ok(CrosstermEvent::Resize(..)) => {
|
||||
if tx.send(Event::Resize).is_err() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
Ok(_) => {}
|
||||
Err(_) => return,
|
||||
},
|
||||
Ok(false) => {}
|
||||
Err(_) => return,
|
||||
}
|
||||
|
||||
if last_tick.elapsed() >= tick_rate {
|
||||
if tx.send(Event::Tick).is_err() {
|
||||
return;
|
||||
}
|
||||
last_tick = Instant::now();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Self { rx }
|
||||
Self {
|
||||
rx,
|
||||
running,
|
||||
handle: Some(handle),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn next(&self) -> Result<Event, mpsc::RecvError> {
|
||||
self.rx.recv()
|
||||
}
|
||||
|
||||
/// Stop the input thread and wait for it to exit. Call this before
|
||||
/// restoring the terminal so the thread is not still reading stdin
|
||||
/// after raw mode is disabled — otherwise stray bytes (a keystroke or
|
||||
/// a terminal query response) echo onto the restored screen, which is
|
||||
/// especially visible over SSH/tmux.
|
||||
pub fn stop(&mut self) {
|
||||
self.running.store(false, Ordering::Relaxed);
|
||||
if let Some(handle) = self.handle.take() {
|
||||
let _ = handle.join();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for EventHandler {
|
||||
fn drop(&mut self) {
|
||||
self.stop();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -223,10 +223,39 @@ fn fetch_data(
|
||||
{
|
||||
app.data.insert(Tab::Cache, data);
|
||||
}
|
||||
// The Tree and Filters views carry no parent/child role flags in their own
|
||||
// daemon responses, so cross-fetch the peers view and join by node address
|
||||
// to group their peer lists the same way the Peers tab does. Non-fatal: on
|
||||
// error the grouping falls back to placing every peer under Other.
|
||||
if (app.active_tab == Tab::Tree || app.active_tab == Tab::Bloom)
|
||||
&& let Ok(data) = rt.block_on(client.query("show_peers"))
|
||||
{
|
||||
app.data.insert(Tab::Peers, data);
|
||||
}
|
||||
|
||||
app.last_fetch = std::time::Instant::now();
|
||||
}
|
||||
|
||||
/// Down-arrow behaviour on the Graphs tab. The by-peer detail follows the
|
||||
/// selection (next peer); the by-peer list moves its cursor; the stacked
|
||||
/// node/peer modes scroll the content.
|
||||
fn graphs_down(app: &mut App) {
|
||||
match app.graphs_mode {
|
||||
crate::app::GraphsMode::MetricByPeer => app.graphs_peer_select_next(),
|
||||
_ if app.detail_view.is_some() => app.scroll_detail_down(),
|
||||
_ => app.graphs_scroll_down(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Up-arrow behaviour on the Graphs tab (mirror of `graphs_down`).
|
||||
fn graphs_up(app: &mut App) {
|
||||
match app.graphs_mode {
|
||||
crate::app::GraphsMode::MetricByPeer => app.graphs_peer_select_prev(),
|
||||
_ if app.detail_view.is_some() => app.scroll_detail_up(),
|
||||
_ => app.graphs_scroll_up(),
|
||||
}
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let cli = Cli::parse();
|
||||
|
||||
@@ -254,8 +283,14 @@ fn main() {
|
||||
eprintln!("fipstop: failed to initialize terminal: {e}");
|
||||
std::process::exit(1);
|
||||
});
|
||||
// Force a full repaint of a known-blank screen before the first draw.
|
||||
// try_init enters the alternate screen but does not clear it, and the
|
||||
// first draw only emits cells that differ from an assumed-blank buffer;
|
||||
// on terminals that don't hand back a cleared alternate buffer (notably
|
||||
// tmux, and over SSH) that leaves stale content showing through.
|
||||
let _ = terminal.clear();
|
||||
let mut app = App::new(refresh);
|
||||
let events = EventHandler::new(refresh);
|
||||
let mut events = EventHandler::new(refresh);
|
||||
|
||||
// Initial fetch
|
||||
fetch_data(&rt, &client, &gateway_client, &mut app);
|
||||
@@ -272,10 +307,65 @@ fn main() {
|
||||
if key.kind != ratatui::crossterm::event::KeyEventKind::Press {
|
||||
continue;
|
||||
}
|
||||
// The disconnect confirmation is modal: while open, only Y
|
||||
// (confirm), N/Esc (cancel), and quit are honored.
|
||||
if app.confirm_disconnect.is_some() {
|
||||
match (key.code, key.modifiers) {
|
||||
(KeyCode::Char('q'), _) | (KeyCode::Char('c'), KeyModifiers::CONTROL) => {
|
||||
app.should_quit = true;
|
||||
}
|
||||
(KeyCode::Char('y'), _) | (KeyCode::Char('Y'), _) => {
|
||||
if let Some(npub) = app.take_disconnect_target() {
|
||||
let params = serde_json::json!({ "npub": npub });
|
||||
if let Err(e) =
|
||||
rt.block_on(client.query_with_params("disconnect", params))
|
||||
{
|
||||
app.last_error = Some((std::time::Instant::now(), e));
|
||||
}
|
||||
fetch_data(&rt, &client, &gateway_client, &mut app);
|
||||
}
|
||||
}
|
||||
(KeyCode::Char('n'), _) | (KeyCode::Char('N'), _) | (KeyCode::Esc, _) => {
|
||||
app.cancel_disconnect();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
if app.should_quit {
|
||||
break;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// The `?` overlay is modal: while open, only `?`/Esc (close)
|
||||
// and quit are honored, so navigation keys don't act behind it.
|
||||
if app.show_help {
|
||||
match (key.code, key.modifiers) {
|
||||
(KeyCode::Char('q'), _) | (KeyCode::Char('c'), KeyModifiers::CONTROL) => {
|
||||
app.should_quit = true;
|
||||
}
|
||||
(KeyCode::Char('?'), _) | (KeyCode::Esc, _) => {
|
||||
app.show_help = false;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
if app.should_quit {
|
||||
break;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
match (key.code, key.modifiers) {
|
||||
(KeyCode::Char('q'), _) | (KeyCode::Char('c'), KeyModifiers::CONTROL) => {
|
||||
app.should_quit = true;
|
||||
}
|
||||
(KeyCode::Char('?'), _) => {
|
||||
app.toggle_help();
|
||||
}
|
||||
(KeyCode::Delete, _) => {
|
||||
// Del on a selected Peers row opens the disconnect
|
||||
// confirmation (the only state-mutating action).
|
||||
if app.active_tab == Tab::Peers && app.detail_view.is_none() {
|
||||
app.request_disconnect_confirm();
|
||||
}
|
||||
}
|
||||
(KeyCode::Tab, KeyModifiers::NONE) => {
|
||||
app.close_detail();
|
||||
app.active_tab = app.active_tab.next();
|
||||
@@ -287,25 +377,64 @@ fn main() {
|
||||
fetch_data(&rt, &client, &gateway_client, &mut app);
|
||||
}
|
||||
(KeyCode::Down, _) => {
|
||||
if app.detail_view.is_some() {
|
||||
if app.active_tab == Tab::Graphs {
|
||||
graphs_down(&mut app);
|
||||
} else if app.detail_view.is_some() {
|
||||
app.scroll_detail_down();
|
||||
} else if app.active_tab == Tab::Graphs {
|
||||
app.graphs_scroll_down();
|
||||
} else if app.active_tab.has_table() {
|
||||
app.select_next();
|
||||
} else if app.active_tab.scroll_pane_count() > 0 {
|
||||
app.scroll_focused_pane(1);
|
||||
}
|
||||
}
|
||||
(KeyCode::Up, _) => {
|
||||
if app.detail_view.is_some() {
|
||||
if app.active_tab == Tab::Graphs {
|
||||
graphs_up(&mut app);
|
||||
} else if app.detail_view.is_some() {
|
||||
app.scroll_detail_up();
|
||||
} else if app.active_tab == Tab::Graphs {
|
||||
app.graphs_scroll_up();
|
||||
} else if app.active_tab.has_table() {
|
||||
app.select_prev();
|
||||
} else if app.active_tab.scroll_pane_count() > 0 {
|
||||
app.scroll_focused_pane(-1);
|
||||
}
|
||||
}
|
||||
(KeyCode::PageDown, _) => {
|
||||
if app.active_tab.scroll_pane_count() > 0 {
|
||||
app.scroll_focused_pane(10);
|
||||
}
|
||||
}
|
||||
(KeyCode::PageUp, _) => {
|
||||
if app.active_tab.scroll_pane_count() > 0 {
|
||||
app.scroll_focused_pane(-10);
|
||||
}
|
||||
}
|
||||
(KeyCode::Home, _) => {
|
||||
if app.active_tab.scroll_pane_count() > 0 {
|
||||
app.set_focused_pane_scroll(0);
|
||||
}
|
||||
}
|
||||
(KeyCode::End, _) => {
|
||||
if app.active_tab.scroll_pane_count() > 0 {
|
||||
// A large offset the renderer clamps to content.
|
||||
app.set_focused_pane_scroll(u16::MAX);
|
||||
}
|
||||
}
|
||||
(KeyCode::Char('f'), KeyModifiers::NONE) => {
|
||||
// Cycle pane focus on the multi-pane scrollable tabs.
|
||||
let panes = app.active_tab.scroll_pane_count();
|
||||
if panes > 0 {
|
||||
app.focus_next_pane(panes);
|
||||
}
|
||||
}
|
||||
(KeyCode::Enter, _) => {
|
||||
if app.active_tab.has_table() && app.detail_view.is_none() {
|
||||
if app.active_tab == Tab::Graphs
|
||||
&& app.detail_view.is_none()
|
||||
&& app.graphs_mode == crate::app::GraphsMode::MetricByPeer
|
||||
{
|
||||
// Expand the selected by-peer summary line into a
|
||||
// full-pane btop plot.
|
||||
app.graphs_open_peer_detail();
|
||||
} else if app.active_tab.has_table() && app.detail_view.is_none() {
|
||||
app.open_detail();
|
||||
}
|
||||
}
|
||||
@@ -324,21 +453,20 @@ fn main() {
|
||||
}
|
||||
}
|
||||
}
|
||||
(KeyCode::Char('m'), KeyModifiers::NONE)
|
||||
if app.active_tab == Tab::Graphs && app.detail_view.is_none() =>
|
||||
{
|
||||
(KeyCode::Char('m'), KeyModifiers::NONE) if app.active_tab == Tab::Graphs => {
|
||||
// `m` cycles the broader Graphs mode, even from inside
|
||||
// the by-peer detail (which then closes, since the
|
||||
// detail only applies to the by-peer mode).
|
||||
app.graphs_next_mode();
|
||||
fetch_data(&rt, &client, &gateway_client, &mut app);
|
||||
}
|
||||
(KeyCode::Char('n'), KeyModifiers::NONE)
|
||||
if app.active_tab == Tab::Graphs && app.detail_view.is_none() =>
|
||||
{
|
||||
(KeyCode::Char('n'), KeyModifiers::NONE) if app.active_tab == Tab::Graphs => {
|
||||
// `n` switches the statistic, for both the by-peer list
|
||||
// and the open by-peer detail (which re-renders).
|
||||
app.graphs_next_selector();
|
||||
fetch_data(&rt, &client, &gateway_client, &mut app);
|
||||
}
|
||||
(KeyCode::Char('N'), KeyModifiers::SHIFT)
|
||||
if app.active_tab == Tab::Graphs && app.detail_view.is_none() =>
|
||||
{
|
||||
(KeyCode::Char('N'), KeyModifiers::SHIFT) if app.active_tab == Tab::Graphs => {
|
||||
app.graphs_prev_selector();
|
||||
fetch_data(&rt, &client, &gateway_client, &mut app);
|
||||
}
|
||||
@@ -354,8 +482,12 @@ fn main() {
|
||||
}
|
||||
}
|
||||
(KeyCode::Esc, _) => {
|
||||
// Priority: close an open detail first, otherwise
|
||||
// deselect the active table row (return to overview).
|
||||
if app.detail_view.is_some() {
|
||||
app.close_detail();
|
||||
} else if app.active_tab.has_table() {
|
||||
app.deselect_row();
|
||||
}
|
||||
}
|
||||
(KeyCode::Char('e'), KeyModifiers::NONE) => {
|
||||
@@ -381,6 +513,15 @@ fn main() {
|
||||
app.graphs_scroll = 0;
|
||||
fetch_data(&rt, &client, &gateway_client, &mut app);
|
||||
}
|
||||
(KeyCode::Char('s'), KeyModifiers::NONE) => {
|
||||
// `s` cycles the active sort column on the MMP and
|
||||
// Graphs by-peer tables (no-op on other tabs).
|
||||
app.cycle_sort_col();
|
||||
}
|
||||
(KeyCode::Char('S'), _) => {
|
||||
// `S` toggles the sort direction on those same tables.
|
||||
app.toggle_sort_dir();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -400,5 +541,9 @@ fn main() {
|
||||
}
|
||||
}
|
||||
|
||||
// Stop the input thread before restoring the terminal so it is not
|
||||
// still reading stdin once raw mode is disabled (stray bytes would
|
||||
// otherwise echo onto the restored screen).
|
||||
events.stop();
|
||||
restore_terminal();
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ use ratatui::Frame;
|
||||
use ratatui::layout::{Constraint, Layout, Rect};
|
||||
use ratatui::style::{Color, Modifier, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{Block, Borders, Paragraph};
|
||||
use ratatui::widgets::Paragraph;
|
||||
|
||||
use crate::app::{App, Tab};
|
||||
|
||||
@@ -20,47 +20,99 @@ pub fn draw(frame: &mut Frame, app: &App, area: Rect) {
|
||||
};
|
||||
|
||||
let chunks = Layout::vertical([
|
||||
Constraint::Length(7), // Bloom Filter State
|
||||
Constraint::Length(8), // Bloom Filter State
|
||||
Constraint::Length(15), // Bloom Announce Stats
|
||||
Constraint::Min(3), // Peer Filters
|
||||
])
|
||||
.split(area);
|
||||
|
||||
draw_state(frame, data, chunks[0]);
|
||||
draw_stats(frame, data, chunks[1]);
|
||||
draw_peer_filters(frame, data, chunks[2]);
|
||||
let focused = app.focused_pane();
|
||||
draw_state(
|
||||
frame,
|
||||
app,
|
||||
data,
|
||||
app.pane_scroll(0),
|
||||
focused == 0,
|
||||
chunks[0],
|
||||
);
|
||||
draw_stats(frame, data, app.pane_scroll(1), focused == 1, chunks[1]);
|
||||
draw_peer_filters(
|
||||
frame,
|
||||
app,
|
||||
data,
|
||||
app.pane_scroll(2),
|
||||
focused == 2,
|
||||
chunks[2],
|
||||
);
|
||||
}
|
||||
|
||||
fn draw_state(frame: &mut Frame, data: &serde_json::Value, area: Rect) {
|
||||
let lines = vec![
|
||||
helpers::kv_line(
|
||||
fn draw_state(
|
||||
frame: &mut Frame,
|
||||
app: &App,
|
||||
data: &serde_json::Value,
|
||||
scroll: u16,
|
||||
focused: bool,
|
||||
area: Rect,
|
||||
) {
|
||||
// is_root determines whether the uptree filter renders as "n/a (root)";
|
||||
// read it from the dashboard (State) surface, which carries it.
|
||||
let is_root = app
|
||||
.data
|
||||
.get(&Tab::Node)
|
||||
.and_then(|d| d.get("is_root"))
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
|
||||
// Uptree filter (what we last sent to the tree parent): "n/a (root)" for a
|
||||
// root node, an em-dash before the first announce, else the value.
|
||||
let uptree_fill = if is_root {
|
||||
"n/a (root)".to_string()
|
||||
} else {
|
||||
match data.get("uptree_fill_ratio").and_then(|v| v.as_f64()) {
|
||||
Some(r) => format!("{:.1}%", r * 100.0),
|
||||
None => "\u{2014}".into(),
|
||||
}
|
||||
};
|
||||
let subtree_est = if is_root {
|
||||
"n/a (root)".to_string()
|
||||
} else {
|
||||
match data.get("uptree_estimated_count").and_then(|v| v.as_f64()) {
|
||||
Some(n) => format!("{:.0}", n),
|
||||
None => "\u{2014}".into(),
|
||||
}
|
||||
};
|
||||
|
||||
let lines = helpers::kv_lines(&[
|
||||
(
|
||||
"Node Addr",
|
||||
&helpers::truncate_hex(helpers::str_field(data, "own_node_addr"), 16),
|
||||
helpers::truncate_hex(helpers::str_field(data, "own_node_addr"), 16),
|
||||
),
|
||||
helpers::kv_line("Leaf Only", helpers::bool_field(data, "is_leaf_only")),
|
||||
helpers::kv_line("Sequence", &helpers::u64_field(data, "sequence")),
|
||||
helpers::kv_line(
|
||||
(
|
||||
"Leaf Only",
|
||||
helpers::bool_field(data, "is_leaf_only").into(),
|
||||
),
|
||||
("Sequence", helpers::u64_field(data, "sequence")),
|
||||
(
|
||||
"Leaf Deps",
|
||||
&helpers::u64_field(data, "leaf_dependent_count"),
|
||||
helpers::u64_field(data, "leaf_dependent_count"),
|
||||
),
|
||||
];
|
||||
("Fill (sent uptree)", uptree_fill),
|
||||
("Subtree est", subtree_est),
|
||||
]);
|
||||
|
||||
let block = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.title(" Bloom Filter State ");
|
||||
let block = helpers::pane_block(" Bloom Filter State ", focused);
|
||||
let inner = block.inner(area);
|
||||
frame.render_widget(block, area);
|
||||
frame.render_widget(Paragraph::new(lines), inner);
|
||||
let scroll = helpers::clamp_scroll(scroll, lines.len(), inner.height as usize);
|
||||
frame.render_widget(Paragraph::new(lines).scroll((scroll, 0)), inner);
|
||||
}
|
||||
|
||||
fn draw_stats(frame: &mut Frame, data: &serde_json::Value, area: Rect) {
|
||||
let block = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.title(" Bloom Announce Stats ");
|
||||
fn draw_stats(frame: &mut Frame, data: &serde_json::Value, scroll: u16, focused: bool, area: Rect) {
|
||||
let block = helpers::pane_block(" Bloom Announce Stats ", focused);
|
||||
let inner = block.inner(area);
|
||||
frame.render_widget(block, area);
|
||||
|
||||
let mut lines = vec![
|
||||
let lines = vec![
|
||||
helpers::section_header("Inbound"),
|
||||
helpers::kv_line("Received", &helpers::nested_u64(data, "stats", "received")),
|
||||
helpers::kv_line("Accepted", &helpers::nested_u64(data, "stats", "accepted")),
|
||||
@@ -88,13 +140,18 @@ fn draw_stats(frame: &mut Frame, data: &serde_json::Value, area: Rect) {
|
||||
),
|
||||
];
|
||||
|
||||
let max_lines = inner.height as usize;
|
||||
lines.truncate(max_lines);
|
||||
|
||||
frame.render_widget(Paragraph::new(lines), inner);
|
||||
let scroll = helpers::clamp_scroll(scroll, lines.len(), inner.height as usize);
|
||||
frame.render_widget(Paragraph::new(lines).scroll((scroll, 0)), inner);
|
||||
}
|
||||
|
||||
fn draw_peer_filters(frame: &mut Frame, data: &serde_json::Value, area: Rect) {
|
||||
fn draw_peer_filters(
|
||||
frame: &mut Frame,
|
||||
app: &App,
|
||||
data: &serde_json::Value,
|
||||
scroll: u16,
|
||||
focused: bool,
|
||||
area: Rect,
|
||||
) {
|
||||
let filters = data
|
||||
.get("peer_filters")
|
||||
.and_then(|v| v.as_array())
|
||||
@@ -102,9 +159,7 @@ fn draw_peer_filters(frame: &mut Frame, data: &serde_json::Value, area: Rect) {
|
||||
.unwrap_or_default();
|
||||
|
||||
let count = filters.len();
|
||||
let block = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.title(format!(" Peer Filters ({count}) "));
|
||||
let block = helpers::pane_block(&format!(" Peer Filters ({count}) "), focused);
|
||||
let inner = block.inner(area);
|
||||
frame.render_widget(block, area);
|
||||
|
||||
@@ -114,48 +169,65 @@ fn draw_peer_filters(frame: &mut Frame, data: &serde_json::Value, area: Rect) {
|
||||
return;
|
||||
}
|
||||
|
||||
let lines: Vec<Line> = filters
|
||||
.iter()
|
||||
.map(|f| {
|
||||
let name = helpers::str_field(f, "display_name");
|
||||
let seq = helpers::u64_field(f, "filter_sequence");
|
||||
let has = f
|
||||
.get("has_filter")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
|
||||
let mut spans = vec![
|
||||
Span::styled(
|
||||
format!(" {name:<16}"),
|
||||
Style::default().add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::styled("seq: ", Style::default().fg(Color::DarkGray)),
|
||||
Span::raw(format!("{seq:<6}")),
|
||||
];
|
||||
|
||||
if has {
|
||||
let fill = f
|
||||
.get("fill_ratio")
|
||||
.and_then(|v| v.as_f64())
|
||||
.map(|r| format!("{:.1}%", r * 100.0))
|
||||
.unwrap_or_else(|| "-".into());
|
||||
let est = f
|
||||
.get("estimated_count")
|
||||
.and_then(|v| v.as_f64())
|
||||
.map(|n| format!("{:.0}", n))
|
||||
.unwrap_or_else(|| "-".into());
|
||||
spans.push(Span::styled("fill: ", Style::default().fg(Color::DarkGray)));
|
||||
spans.push(Span::raw(format!("{fill:<8}")));
|
||||
spans.push(Span::styled("est: ", Style::default().fg(Color::DarkGray)));
|
||||
spans.push(Span::raw(format!("{est:<6}")));
|
||||
spans.push(Span::styled("ok", Style::default().fg(Color::Green)));
|
||||
} else {
|
||||
spans.push(Span::styled("none", Style::default().fg(Color::Red)));
|
||||
}
|
||||
|
||||
Line::from(spans)
|
||||
})
|
||||
// The bloom response carries no role flags; recover them from the peers view
|
||||
// (cross-fetched on this tab) by joining each filter's `peer` hex address,
|
||||
// then group by tree role (parent -> STP children -> other) to match the
|
||||
// Peers and Tree tabs so the same peer sits under the same heading.
|
||||
let role_map = helpers::peer_role_map(app.data.get(&Tab::Peers));
|
||||
let mut filters: Vec<serde_json::Value> = filters
|
||||
.into_iter()
|
||||
.map(|f| helpers::enrich_role(f, &role_map, "peer"))
|
||||
.collect();
|
||||
helpers::sort_by_group(&mut filters);
|
||||
|
||||
frame.render_widget(Paragraph::new(lines), inner);
|
||||
let lines = helpers::grouped_peer_lines(&filters, peer_filter_line);
|
||||
|
||||
let scroll = helpers::clamp_scroll(scroll, lines.len(), inner.height as usize);
|
||||
frame.render_widget(Paragraph::new(lines).scroll((scroll, 0)), inner);
|
||||
}
|
||||
|
||||
/// Render one Bloom-tab peer-filter line: name, filter sequence, and either the
|
||||
/// fill/estimate columns (when the peer has a filter) or a "none" marker.
|
||||
fn peer_filter_line(f: &serde_json::Value) -> Line<'static> {
|
||||
let name = helpers::str_field(f, "display_name");
|
||||
let seq = helpers::u64_field(f, "filter_sequence");
|
||||
let has = f
|
||||
.get("has_filter")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
|
||||
// Right-justify each numeric into a fixed field wide enough for
|
||||
// realistic data, with a guaranteed trailing separator so a
|
||||
// wider-than-expected value can never touch the next label, and
|
||||
// the digit columns line up across rows.
|
||||
let mut spans = vec![
|
||||
Span::styled(
|
||||
format!(" {} ", helpers::truncate_name(name, 16)),
|
||||
Style::default().add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::styled("seq: ", Style::default().fg(Color::DarkGray)),
|
||||
Span::raw(format!("{seq:>9} ")),
|
||||
];
|
||||
|
||||
if has {
|
||||
let fill = f
|
||||
.get("fill_ratio")
|
||||
.and_then(|v| v.as_f64())
|
||||
.map(|r| format!("{:.1}%", r * 100.0))
|
||||
.unwrap_or_else(|| "-".into());
|
||||
let est = f
|
||||
.get("estimated_count")
|
||||
.and_then(|v| v.as_f64())
|
||||
.map(|n| format!("{:.0}", n))
|
||||
.unwrap_or_else(|| "-".into());
|
||||
spans.push(Span::styled("fill: ", Style::default().fg(Color::DarkGray)));
|
||||
spans.push(Span::raw(format!("{fill:>6} ")));
|
||||
spans.push(Span::styled("est: ", Style::default().fg(Color::DarkGray)));
|
||||
spans.push(Span::raw(format!("{est:>6} ")));
|
||||
spans.push(Span::styled("ok", Style::default().fg(Color::Green)));
|
||||
} else {
|
||||
spans.push(Span::styled("none", Style::default().fg(Color::Red)));
|
||||
}
|
||||
|
||||
Line::from(spans)
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ pub fn draw(frame: &mut Frame, app: &App, area: Rect) {
|
||||
let chunks = Layout::vertical([
|
||||
Constraint::Length(7), // Runtime
|
||||
Constraint::Length(7), // Identity
|
||||
Constraint::Length(6), // State (sparkline row adds one line)
|
||||
Constraint::Length(8), // State (root egg + transports + sparkline rows)
|
||||
Constraint::Length(9), // Traffic + Listening on fips0 (side-by-side)
|
||||
Constraint::Min(0), // remaining
|
||||
])
|
||||
@@ -95,6 +95,12 @@ fn draw_identity(frame: &mut Frame, data: &serde_json::Value, area: Rect) {
|
||||
let npub = helpers::str_field(data, "npub");
|
||||
let node_addr = helpers::str_field(data, "node_addr");
|
||||
let ipv6_addr = helpers::str_field(data, "ipv6_addr");
|
||||
// Effective persistence: whether this identity survives a restart.
|
||||
let mode = match data.get("persistent").and_then(|v| v.as_bool()) {
|
||||
Some(true) => "persistent",
|
||||
Some(false) => "ephemeral",
|
||||
None => "-",
|
||||
};
|
||||
|
||||
let label = Style::default().fg(Color::DarkGray);
|
||||
|
||||
@@ -111,6 +117,10 @@ fn draw_identity(frame: &mut Frame, data: &serde_json::Value, area: Rect) {
|
||||
Span::styled(" ipv6: ", label),
|
||||
Span::raw(ipv6_addr.to_string()),
|
||||
]),
|
||||
Line::from(vec![
|
||||
Span::styled(" identity: ", label),
|
||||
Span::raw(mode.to_string()),
|
||||
]),
|
||||
];
|
||||
|
||||
frame.render_widget(Paragraph::new(lines), inner);
|
||||
@@ -148,6 +158,24 @@ fn draw_state(frame: &mut Frame, data: &serde_json::Value, area: Rect) {
|
||||
helpers::sparkline(&helpers::nested_f64_array(data, "sparklines", "peer_count"));
|
||||
let spark_style = Style::default().fg(Color::DarkGray);
|
||||
|
||||
// Root: an Easter-egg marker when this node IS the root, otherwise the
|
||||
// truncated root hex. The full root address + npub live on the Tree tab.
|
||||
let is_root = data
|
||||
.get("is_root")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
let root_display = if is_root {
|
||||
"I am the one who roots".to_string()
|
||||
} else {
|
||||
let root_hex = helpers::str_field(data, "root");
|
||||
let head: String = root_hex.chars().take(16).collect();
|
||||
format!("{head}\u{2026}")
|
||||
};
|
||||
|
||||
// Configured transport types each with their peer count, e.g.
|
||||
// "udp (5), tcp (2), tor (0)". Idle-but-configured types stay visible at 0.
|
||||
let transports_by_type = format_transport_peer_counts(data);
|
||||
|
||||
let lines = vec![
|
||||
Line::from(vec![
|
||||
Span::styled(" state: ", label),
|
||||
@@ -170,9 +198,22 @@ fn draw_state(frame: &mut Frame, data: &serde_json::Value, area: Rect) {
|
||||
Span::styled(transports, count),
|
||||
Span::styled(" connections: ", label),
|
||||
Span::styled(connections, count),
|
||||
Span::styled(" mesh: ", label),
|
||||
]),
|
||||
// The mesh size is a bloom-cardinality estimate, not an exact count;
|
||||
// it gets its own line so the longer "approx. mesh estimate:" label
|
||||
// does not overflow the counts line at narrow widths.
|
||||
Line::from(vec![
|
||||
Span::styled(" approx. mesh estimate: ", label),
|
||||
Span::styled(mesh_size, count),
|
||||
]),
|
||||
Line::from(vec![
|
||||
Span::styled(" root: ", label),
|
||||
Span::raw(root_display),
|
||||
]),
|
||||
Line::from(vec![
|
||||
Span::styled(" transports: ", label),
|
||||
Span::raw(transports_by_type),
|
||||
]),
|
||||
Line::from(vec![
|
||||
Span::styled(" peers: ", label),
|
||||
Span::styled(peer_spark, spark_style),
|
||||
@@ -184,6 +225,25 @@ fn draw_state(frame: &mut Frame, data: &serde_json::Value, area: Rect) {
|
||||
frame.render_widget(Paragraph::new(lines), inner);
|
||||
}
|
||||
|
||||
/// Format the `transport_peer_counts` map as `type (count)` joined with
|
||||
/// commas, e.g. `udp (5), tcp (2), tor (0)`. Keys are rendered in sorted
|
||||
/// order (the daemon emits a sorted map). Returns `-` when absent or empty.
|
||||
fn format_transport_peer_counts(data: &serde_json::Value) -> String {
|
||||
let Some(map) = data
|
||||
.get("transport_peer_counts")
|
||||
.and_then(|v| v.as_object())
|
||||
else {
|
||||
return "-".into();
|
||||
};
|
||||
if map.is_empty() {
|
||||
return "-".into();
|
||||
}
|
||||
map.iter()
|
||||
.map(|(ty, count)| format!("{ty} ({})", count.as_u64().unwrap_or(0)))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
}
|
||||
|
||||
fn draw_node_stats(frame: &mut Frame, data: &serde_json::Value, area: Rect) {
|
||||
let block = Block::default().borders(Borders::ALL).title(" Traffic ");
|
||||
let inner = block.inner(area);
|
||||
|
||||
@@ -18,7 +18,9 @@ use ratatui::style::{Color, Modifier, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{Block, BorderType, Borders, Paragraph};
|
||||
|
||||
use crate::app::{App, GRAPHS_METRICS, GraphsMode, PEER_GRAPHS_METRICS, Tab};
|
||||
use crate::app::{
|
||||
App, GRAPHS_METRICS, GRAPHS_PEER_SORT_LABELS, GraphsMode, PEER_GRAPHS_METRICS, SortState, Tab,
|
||||
};
|
||||
|
||||
/// 5×5 braille lookup table indexed by (left fill 0..=4, right fill
|
||||
/// 0..=4). Direct transcription of btop's `braille_up` glyph set.
|
||||
@@ -78,10 +80,16 @@ fn draw_selector(frame: &mut Frame, app: &App, area: Rect) {
|
||||
Span::styled(" scroll: ", label),
|
||||
Span::styled(format!("{}", app.graphs_scroll), dim),
|
||||
]);
|
||||
let line2 = Line::from(Span::styled(
|
||||
" [↑/↓] scroll [←/→] window [m] mode [n/N] cycle [g] graphs [q] quit",
|
||||
label,
|
||||
));
|
||||
// The full keybinding reference lives in the status-bar footer (registry)
|
||||
// and the `?` overlay; this in-pane line is a brief mode-specific reminder.
|
||||
let line2_text = match app.graphs_mode {
|
||||
GraphsMode::MetricByPeer if app.detail_view.is_some() => {
|
||||
" [↑/↓] peer [n/N] stat [m] mode [Esc] back"
|
||||
}
|
||||
GraphsMode::MetricByPeer => " [↑/↓] select [Enter] expand [n/N] stat [m] mode",
|
||||
_ => " [↑/↓] scroll [←/→] window [m] mode [n/N] cycle",
|
||||
};
|
||||
let line2 = Line::from(Span::styled(line2_text, label));
|
||||
|
||||
frame.render_widget(Paragraph::new(vec![line1, line2]), area);
|
||||
}
|
||||
@@ -187,21 +195,11 @@ fn draw_stacked(frame: &mut Frame, app: &mut App, inner: Rect) {
|
||||
frame.render_widget(paragraph, inner);
|
||||
}
|
||||
|
||||
fn draw_metric_by_peer(frame: &mut Frame, app: &mut App, inner: Rect) {
|
||||
let data = match app.data.get(&Tab::Graphs) {
|
||||
Some(d) => d,
|
||||
None => {
|
||||
frame.render_widget(
|
||||
Paragraph::new(" Waiting for data...").style(Style::default().fg(Color::DarkGray)),
|
||||
inner,
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let metric_name = app.graphs_selected_peer_metric();
|
||||
let peers = data.get("peers").and_then(|v| v.as_array());
|
||||
let peer_series: Vec<(String, Vec<f64>)> = peers
|
||||
/// Parse the by-peer payload (`peers` array of `{display_name, values}`) into
|
||||
/// `[(name, values)]`, in payload order.
|
||||
fn peer_series_from_data(data: &serde_json::Value) -> Vec<(String, Vec<f64>)> {
|
||||
data.get("peers")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|arr| {
|
||||
arr.iter()
|
||||
.map(|p| {
|
||||
@@ -219,7 +217,28 @@ fn draw_metric_by_peer(frame: &mut Frame, app: &mut App, inner: Rect) {
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// By-peer mode: a scrollable summary list (one line per peer, with
|
||||
/// min/max/last/n for the selected metric). Selecting a peer (Enter) swaps to a
|
||||
/// full-pane btop plot via `draw_metric_by_peer_detail`. The cursor follows
|
||||
/// `graphs_peer_idx`; Up/Down move it and the focus/scroll model keeps the
|
||||
/// selection visible.
|
||||
fn draw_metric_by_peer(frame: &mut Frame, app: &mut App, inner: Rect) {
|
||||
let data = match app.data.get(&Tab::Graphs) {
|
||||
Some(d) => d,
|
||||
None => {
|
||||
frame.render_widget(
|
||||
Paragraph::new(" Waiting for data...").style(Style::default().fg(Color::DarkGray)),
|
||||
inner,
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let metric_name = app.graphs_selected_peer_metric();
|
||||
let peer_series = peer_series_from_data(data);
|
||||
|
||||
if peer_series.is_empty() {
|
||||
frame.render_widget(
|
||||
@@ -230,94 +249,133 @@ fn draw_metric_by_peer(frame: &mut Frame, app: &mut App, inner: Rect) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Pick a column count that keeps each cell wide enough for a
|
||||
// readable braille plot. Each cell needs ~30 columns minimum.
|
||||
let cols = if inner.width < 40 {
|
||||
1
|
||||
} else if inner.width < 100 {
|
||||
2
|
||||
} else {
|
||||
3
|
||||
};
|
||||
let rows = peer_series.len().div_ceil(cols);
|
||||
|
||||
// Stack of cell-rows; each row is a horizontal split of cell cells.
|
||||
let row_constraints: Vec<Constraint> = (0..rows)
|
||||
.map(|_| Constraint::Length(METRIC_BLOCK_ROWS))
|
||||
.collect();
|
||||
let row_areas = Layout::vertical(row_constraints).split(inner);
|
||||
|
||||
for row_idx in 0..rows {
|
||||
let col_constraints: Vec<Constraint> = (0..cols)
|
||||
.map(|_| Constraint::Ratio(1, cols as u32))
|
||||
.collect();
|
||||
let col_areas = Layout::horizontal(col_constraints).split(row_areas[row_idx]);
|
||||
|
||||
for col_idx in 0..cols {
|
||||
let peer_idx = row_idx * cols + col_idx;
|
||||
if peer_idx >= peer_series.len() {
|
||||
break;
|
||||
}
|
||||
let (peer_name, values) = &peer_series[peer_idx];
|
||||
let cell_lines = render_metric_block_labeled(
|
||||
metric_name,
|
||||
peer_name,
|
||||
values,
|
||||
col_areas[col_idx].width,
|
||||
);
|
||||
frame.render_widget(Paragraph::new(cell_lines), col_areas[col_idx]);
|
||||
}
|
||||
// An open detail view swaps the whole pane for a full-width btop plot of
|
||||
// the selected peer; Up/Down then flip peers rather than scroll the list.
|
||||
if app.detail_view.is_some() {
|
||||
draw_metric_by_peer_detail(frame, app, inner, metric_name, &peer_series);
|
||||
return;
|
||||
}
|
||||
|
||||
// The cursor tracks a payload-order peer index; build a display-order
|
||||
// permutation per the active sort so re-sorting reorders the list while the
|
||||
// cursor stays on the same logical peer (mapped to its new display row).
|
||||
let order = sorted_order(&peer_series, app.graphs_peer_sort);
|
||||
let sel_payload = app.graphs_peer_idx.min(peer_series.len() - 1);
|
||||
let display_selected = order.iter().position(|&i| i == sel_payload).unwrap_or(0);
|
||||
|
||||
let unit = metric_unit(metric_name);
|
||||
let cursor = Style::default()
|
||||
.fg(Color::Black)
|
||||
.bg(Color::Cyan)
|
||||
.add_modifier(Modifier::BOLD);
|
||||
let label = Style::default().fg(Color::DarkGray);
|
||||
let name_style = Style::default().fg(Color::White);
|
||||
|
||||
let mut lines: Vec<Line<'static>> = vec![sort_header(app.graphs_peer_sort)];
|
||||
for (display_row, &payload_idx) in order.iter().enumerate() {
|
||||
let (peer_name, values) = &peer_series[payload_idx];
|
||||
let (min, max, last, n) = summarize(values);
|
||||
let is_sel = display_row == display_selected;
|
||||
let marker = if is_sel { "\u{25b6} " } else { " " };
|
||||
let nm = name_style;
|
||||
// Right-justify the numeric columns into fixed-width fields so the
|
||||
// min/max/last values and the sample count line up down the list
|
||||
// regardless of magnitude.
|
||||
let row = Line::from(vec![
|
||||
Span::styled(
|
||||
format!("{marker}{peer_name:<18}"),
|
||||
if is_sel { cursor } else { nm },
|
||||
),
|
||||
Span::styled(format!(" [{unit}]"), label),
|
||||
Span::styled(" min ", label),
|
||||
Span::raw(format!("{:>8}", format_value(min))),
|
||||
Span::styled(" max ", label),
|
||||
Span::raw(format!("{:>8}", format_value(max))),
|
||||
Span::styled(" last ", label),
|
||||
Span::raw(format!("{:>8}", format_value(last))),
|
||||
Span::styled(" n=", label),
|
||||
Span::raw(format!("{n:>5}")),
|
||||
]);
|
||||
lines.push(row);
|
||||
}
|
||||
|
||||
// Keep the selected row visible using the shared per-pane scroll model
|
||||
// (Graphs by-peer list is pane 0). The +1 accounts for the sort header row.
|
||||
let visible = inner.height as usize;
|
||||
let selected = display_selected + 1;
|
||||
let mut offset = app.pane_scroll(0) as usize;
|
||||
if selected < offset {
|
||||
offset = selected;
|
||||
} else if visible > 0 && selected >= offset + visible {
|
||||
offset = selected + 1 - visible;
|
||||
}
|
||||
let max_offset = lines.len().saturating_sub(visible);
|
||||
offset = offset.min(max_offset);
|
||||
app.scroll_offsets.insert((Tab::Graphs, 0), offset as u16);
|
||||
|
||||
let paragraph = Paragraph::new(lines).scroll((offset as u16, 0));
|
||||
frame.render_widget(paragraph, inner);
|
||||
}
|
||||
|
||||
/// Variant of `render_metric_block` that labels the block with the
|
||||
/// peer name in addition to the metric. Used by the metric-by-peer grid.
|
||||
fn render_metric_block_labeled(
|
||||
/// Full-pane btop plot for the selected by-peer peer. The detail follows the
|
||||
/// selection (Up/Down flip peers, n/N switch the statistic), re-rendering this
|
||||
/// plot for the current `(peer, metric)`.
|
||||
fn draw_metric_by_peer_detail(
|
||||
frame: &mut Frame,
|
||||
app: &App,
|
||||
inner: Rect,
|
||||
metric: &str,
|
||||
peer_name: &str,
|
||||
values: &[f64],
|
||||
width: u16,
|
||||
) -> Vec<Line<'static>> {
|
||||
peer_series: &[(String, Vec<f64>)],
|
||||
) {
|
||||
let idx = app.graphs_peer_idx.min(peer_series.len() - 1);
|
||||
let (peer_name, values) = &peer_series[idx];
|
||||
let unit = metric_unit(metric);
|
||||
let mut out: Vec<Line<'static>> = Vec::with_capacity(METRIC_BLOCK_ROWS as usize);
|
||||
|
||||
let (min, max, last, n) = summarize(values);
|
||||
let title_style = Style::default()
|
||||
.fg(Color::White)
|
||||
.add_modifier(Modifier::BOLD);
|
||||
let label = Style::default().fg(Color::DarkGray);
|
||||
let title = Line::from(vec![
|
||||
|
||||
let (min, max, last, n) = summarize(values);
|
||||
let header = Line::from(vec![
|
||||
Span::styled(format!(" {peer_name}"), title_style),
|
||||
Span::styled(format!(" [{unit}]"), label),
|
||||
Span::styled(" max ", label),
|
||||
Span::styled(format!(" {metric} [{unit}]"), label),
|
||||
Span::styled(" min ", label),
|
||||
Span::raw(format_value(min)),
|
||||
Span::styled(" max ", label),
|
||||
Span::raw(format_value(max)),
|
||||
Span::styled(" last ", label),
|
||||
Span::raw(format_value(last)),
|
||||
Span::styled(" n=", label),
|
||||
Span::styled(" samples ", label),
|
||||
Span::raw(format!("{n}")),
|
||||
]);
|
||||
out.push(title);
|
||||
|
||||
let mut lines: Vec<Line<'static>> = vec![header, Line::from("")];
|
||||
|
||||
let gutter = 2u16;
|
||||
let plot_cols = width.saturating_sub(gutter) as usize;
|
||||
let plot_cols = inner.width.saturating_sub(gutter) as usize;
|
||||
// Reserve the header (1) + blank (1) + a footer hint line; the rest is plot.
|
||||
let plot_rows = (inner.height as usize).saturating_sub(3).max(1);
|
||||
|
||||
if plot_cols == 0 || values.is_empty() {
|
||||
for _ in 0..METRIC_PLOT_ROWS {
|
||||
out.push(Line::from(Span::styled(
|
||||
for _ in 0..plot_rows {
|
||||
lines.push(Line::from(Span::styled(
|
||||
" (no samples)",
|
||||
Style::default().fg(Color::DarkGray),
|
||||
)));
|
||||
}
|
||||
out.push(Line::from(""));
|
||||
return out;
|
||||
} else {
|
||||
let sampled = resample(values, plot_cols * 2);
|
||||
lines.extend(render_btop_graph(
|
||||
&sampled,
|
||||
plot_rows,
|
||||
min,
|
||||
max,
|
||||
gutter as usize,
|
||||
));
|
||||
}
|
||||
|
||||
let sampled = resample(values, plot_cols * 2);
|
||||
let rows = METRIC_PLOT_ROWS as usize;
|
||||
let plot_lines = render_btop_graph(&sampled, rows, min, max, gutter as usize);
|
||||
out.extend(plot_lines);
|
||||
out.push(Line::from(""));
|
||||
out
|
||||
frame.render_widget(Paragraph::new(lines), inner);
|
||||
}
|
||||
|
||||
/// Render a single metric's mini block: one title row, four plot rows,
|
||||
@@ -337,7 +395,9 @@ fn render_metric_block(metric: &str, values: &[f64], width: u16) -> Vec<Line<'st
|
||||
let title = Line::from(vec![
|
||||
Span::styled(format!(" {metric}"), title_style),
|
||||
Span::styled(format!(" [{unit}]"), label),
|
||||
Span::styled(" max ", label),
|
||||
Span::styled(" min ", label),
|
||||
Span::raw(format_value(min)),
|
||||
Span::styled(" max ", label),
|
||||
Span::raw(format_value(max)),
|
||||
Span::styled(" last ", label),
|
||||
Span::raw(format_value(last)),
|
||||
@@ -372,6 +432,69 @@ fn render_metric_block(metric: &str, values: &[f64], width: u16) -> Vec<Line<'st
|
||||
out
|
||||
}
|
||||
|
||||
/// Build a display-order permutation of `peer_series` indices per the sort
|
||||
/// state. Column 0 sorts by name; columns 1..=4 sort by the corresponding
|
||||
/// summary scalar (min/max/last/n). Descending reverses the order.
|
||||
fn sorted_order(peer_series: &[(String, Vec<f64>)], sort: SortState) -> Vec<usize> {
|
||||
let mut order: Vec<usize> = (0..peer_series.len()).collect();
|
||||
order.sort_by(|&a, &b| {
|
||||
let (na, va) = &peer_series[a];
|
||||
let (nb, vb) = &peer_series[b];
|
||||
let ord = if sort.col == 0 {
|
||||
na.cmp(nb)
|
||||
} else {
|
||||
let key = |v: &[f64]| -> f64 {
|
||||
let (min, max, last, n) = summarize(v);
|
||||
match sort.col {
|
||||
1 => min,
|
||||
2 => max,
|
||||
3 => last,
|
||||
_ => n as f64,
|
||||
}
|
||||
};
|
||||
let ka = key(va);
|
||||
let kb = key(vb);
|
||||
// NaN keys (e.g. an empty series' last) sort last under ascending.
|
||||
match (ka.is_nan(), kb.is_nan()) {
|
||||
(true, true) => std::cmp::Ordering::Equal,
|
||||
(true, false) => std::cmp::Ordering::Greater,
|
||||
(false, true) => std::cmp::Ordering::Less,
|
||||
(false, false) => ka.partial_cmp(&kb).unwrap_or(std::cmp::Ordering::Equal),
|
||||
}
|
||||
};
|
||||
if sort.descending { ord.reverse() } else { ord }
|
||||
});
|
||||
order
|
||||
}
|
||||
|
||||
/// Render the Graphs by-peer sort-column header: each column label with the
|
||||
/// active sort column highlighted and carrying a direction arrow.
|
||||
fn sort_header(sort: SortState) -> Line<'static> {
|
||||
let active = Style::default()
|
||||
.fg(Color::Cyan)
|
||||
.add_modifier(Modifier::BOLD);
|
||||
let idle = Style::default().fg(Color::DarkGray);
|
||||
// Solid triangles mark the sort direction, distinct from the cursor and
|
||||
// any plot glyphs.
|
||||
let arrow = if sort.descending {
|
||||
"\u{25bc}"
|
||||
} else {
|
||||
"\u{25b2}"
|
||||
};
|
||||
let mut spans: Vec<Span<'static>> = vec![Span::styled(" sort: ", idle)];
|
||||
for (i, lbl) in GRAPHS_PEER_SORT_LABELS.iter().enumerate() {
|
||||
if i > 0 {
|
||||
spans.push(Span::styled(" ", idle));
|
||||
}
|
||||
if i == sort.col {
|
||||
spans.push(Span::styled(format!("{lbl}{arrow}"), active));
|
||||
} else {
|
||||
spans.push(Span::styled(lbl.to_string(), idle));
|
||||
}
|
||||
}
|
||||
Line::from(spans)
|
||||
}
|
||||
|
||||
fn summarize(values: &[f64]) -> (f64, f64, f64, usize) {
|
||||
if values.is_empty() {
|
||||
return (0.0, 0.0, 0.0, 0);
|
||||
@@ -444,15 +567,23 @@ fn render_btop_graph(
|
||||
}
|
||||
|
||||
let range = max - min;
|
||||
// NaN samples pass through normalize as NaN so the cell loop below
|
||||
// can blank them. Non-NaN samples are clamped into 0..=100.
|
||||
// Flat series (range <= 0) carry no scale, so map them by their level:
|
||||
// a genuine zero reading renders as an empty plot (NaN blanks every
|
||||
// cell), while a steady non-zero reading rests on the baseline (0.0)
|
||||
// rather than floating at mid-height. NaN samples always blank.
|
||||
let flat = !range.is_finite() || range <= 0.0;
|
||||
let flat_zero = flat && max == 0.0;
|
||||
let normalized: Vec<f64> = values
|
||||
.iter()
|
||||
.map(|&v| {
|
||||
if v.is_nan() {
|
||||
if v.is_nan() || flat_zero {
|
||||
// Blank cells: a NaN sample, or a genuine flat-zero series
|
||||
// (rendered as an empty plot).
|
||||
f64::NAN
|
||||
} else if !range.is_finite() || range <= 0.0 {
|
||||
50.0
|
||||
} else if flat {
|
||||
// A small positive level so the steady value rests as a
|
||||
// row of dots on the baseline rather than an empty plot.
|
||||
8.0
|
||||
} else {
|
||||
((v - min) / range * 100.0).clamp(0.0, 100.0)
|
||||
}
|
||||
@@ -618,6 +749,66 @@ mod tests {
|
||||
assert_eq!(lines.len(), METRIC_BLOCK_ROWS as usize);
|
||||
}
|
||||
|
||||
/// Collect the rendered braille plot rows (excluding the gutter) of a
|
||||
/// metric block into one concatenated string for inspection.
|
||||
fn plot_text(lines: &[Line<'static>]) -> String {
|
||||
// The block is: title, METRIC_PLOT_ROWS plot rows, blank separator.
|
||||
lines
|
||||
.iter()
|
||||
.skip(1)
|
||||
.take(METRIC_PLOT_ROWS as usize)
|
||||
.flat_map(|l| l.spans.iter())
|
||||
.map(|s| s.content.to_string())
|
||||
.collect::<String>()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flat_zero_renders_empty_plot() {
|
||||
// All-zero flat series: empty plot (no braille dots).
|
||||
let lines = render_metric_block("loss_rate", &[0.0, 0.0, 0.0, 0.0], 40);
|
||||
let plot = plot_text(&lines);
|
||||
assert!(
|
||||
plot.trim().is_empty(),
|
||||
"flat-zero plot should have no dots, got {plot:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flat_nonzero_renders_baseline_dots() {
|
||||
// Steady non-zero flat series: a baseline row of dots, not an empty
|
||||
// plot and not floating at mid-height.
|
||||
let lines = render_metric_block("mesh_size", &[7.0, 7.0, 7.0, 7.0], 40);
|
||||
let plot = plot_text(&lines);
|
||||
assert!(
|
||||
!plot.trim().is_empty(),
|
||||
"flat non-zero plot should rest on the baseline as dots"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_data_has_distinct_placeholder() {
|
||||
// Empty input must be visibly distinct from a flat-zero empty plot.
|
||||
let lines = render_metric_block("mesh_size", &[], 40);
|
||||
let joined: String = lines
|
||||
.iter()
|
||||
.flat_map(|l| l.spans.iter())
|
||||
.map(|s| s.content.to_string())
|
||||
.collect();
|
||||
assert!(joined.contains("no samples"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn title_row_includes_min() {
|
||||
let lines = render_metric_block("mesh_size", &[2.0, 5.0, 9.0], 60);
|
||||
let title: String = lines[0]
|
||||
.spans
|
||||
.iter()
|
||||
.map(|s| s.content.to_string())
|
||||
.collect();
|
||||
assert!(title.contains("min "), "title should label a min field");
|
||||
assert!(title.contains("max "), "title should still show max");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gradient_spans_stops() {
|
||||
if let Color::Rgb(r, g, _) = gradient_rgb(0.0) {
|
||||
|
||||
367
src/bin/fipstop/ui/help.rs
Normal file
367
src/bin/fipstop/ui/help.rs
Normal file
@@ -0,0 +1,367 @@
|
||||
//! Declarative keybinding registry and the `?` help overlay.
|
||||
//!
|
||||
//! A single static table keyed by `(Tab, UiMode)` is the one source of truth
|
||||
//! both the always-visible context footer (`draw_status_bar`) and the full `?`
|
||||
//! overlay render from, so the two can never drift. Every key the dispatch
|
||||
//! handles in a given context is registered here as a `(key, label)` pair; the
|
||||
//! footer renders the contextual subset (with a width-aware truncation rule),
|
||||
//! and the overlay renders the whole reference.
|
||||
//!
|
||||
//! A test (`registry_keys_exist_in_dispatch`) asserts every key string the
|
||||
//! table mentions is one the `main.rs` dispatch actually recognizes, so a
|
||||
//! stale or invented hint can't slip in.
|
||||
|
||||
use ratatui::Frame;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::style::{Color, Modifier, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{Block, Borders, Clear, Paragraph};
|
||||
|
||||
use crate::app::{App, Tab};
|
||||
|
||||
/// The UI interaction mode the active tab is in, derived from existing `App`
|
||||
/// fields. Selects which hint set the footer and overlay show. Order:
|
||||
/// overview (nothing selected/open) is the base; a selected table row, an open
|
||||
/// detail view, and (for multi-pane tabs) pane focus refine it.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
pub enum UiMode {
|
||||
/// No row selected, no detail open — the tab's resting state.
|
||||
Overview,
|
||||
/// A table row is selected (Peers/Sessions/Transports/Gateway).
|
||||
RowSelected,
|
||||
/// A detail view is open over the active tab.
|
||||
DetailOpen,
|
||||
}
|
||||
|
||||
impl UiMode {
|
||||
/// Derive the current mode from `App` state for the active tab.
|
||||
pub fn of(app: &App) -> UiMode {
|
||||
if app.detail_view.is_some() {
|
||||
return UiMode::DetailOpen;
|
||||
}
|
||||
if app.active_tab.has_table()
|
||||
&& app
|
||||
.table_states
|
||||
.get(&app.active_tab)
|
||||
.and_then(|s| s.selected())
|
||||
.is_some()
|
||||
{
|
||||
return UiMode::RowSelected;
|
||||
}
|
||||
UiMode::Overview
|
||||
}
|
||||
}
|
||||
|
||||
/// One keybinding hint: the key glyph shown in brackets and its action label.
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct Hint {
|
||||
pub key: &'static str,
|
||||
pub label: &'static str,
|
||||
}
|
||||
|
||||
const fn hint(key: &'static str, label: &'static str) -> Hint {
|
||||
Hint { key, label }
|
||||
}
|
||||
|
||||
/// Global hints available on (almost) every tab regardless of mode. These are
|
||||
/// the lowest-priority footer candidates: when the bar overflows they drop
|
||||
/// first, leaving the contextual hints and the always-present `[?] Help`.
|
||||
pub const GLOBAL_HINTS: &[Hint] = &[hint("Tab", "next"), hint("g", "graphs"), hint("q", "quit")];
|
||||
|
||||
const DETAIL_HINTS: &[Hint] = &[hint("Esc", "close"), hint("\u{2191}\u{2193}", "scroll")];
|
||||
const PEERS_SELECTED_HINTS: &[Hint] = &[
|
||||
hint("Enter", "detail"),
|
||||
hint("Del", "disconnect"),
|
||||
hint("Esc", "deselect"),
|
||||
];
|
||||
const ROW_SELECTED_HINTS: &[Hint] = &[hint("Enter", "detail"), hint("Esc", "deselect")];
|
||||
const TABLE_OVERVIEW_HINTS: &[Hint] = &[hint("\u{2191}\u{2193}", "select")];
|
||||
const GRAPHS_OVERVIEW_HINTS: &[Hint] = &[
|
||||
hint("Enter", "expand"),
|
||||
hint("m", "mode"),
|
||||
hint("n/N", "stat"),
|
||||
hint("\u{2190}\u{2192}", "window"),
|
||||
hint("s/S", "sort"),
|
||||
];
|
||||
/// The MMP (Performance) tab: `f` moves focus between the Link and Session MMP
|
||||
/// panes, the arrows scroll the focused pane, and `s`/`S` sort the focused pane.
|
||||
const MMP_HINTS: &[Hint] = &[
|
||||
hint("f", "focus pane"),
|
||||
hint("\u{2191}\u{2193}", "scroll"),
|
||||
hint("s/S", "sort"),
|
||||
];
|
||||
/// The multi-pane scrollable tabs (Tree, Filters, Routing): `f` moves pane
|
||||
/// focus and the arrow keys scroll the focused pane.
|
||||
const PANE_SCROLL_HINTS: &[Hint] = &[hint("f", "focus pane"), hint("\u{2191}\u{2193}", "scroll")];
|
||||
/// By-peer detail (full-pane plot) on the Graphs tab: Up/Down flip the peer the
|
||||
/// plot follows, n/N switch the statistic, m cycles the mode, Esc returns to the
|
||||
/// scrollable peer list.
|
||||
const GRAPHS_DETAIL_HINTS: &[Hint] = &[
|
||||
hint("\u{2191}\u{2193}", "peer"),
|
||||
hint("n/N", "stat"),
|
||||
hint("m", "mode"),
|
||||
hint("Esc", "back"),
|
||||
];
|
||||
const NO_HINTS: &[Hint] = &[];
|
||||
|
||||
/// The contextual hints for a `(Tab, UiMode)`. Highest footer priority — these
|
||||
/// describe what the current state's keys do and are kept when the bar is
|
||||
/// narrow. The overlay shows these plus the globals plus `[?] Help`.
|
||||
pub fn contextual_hints(tab: Tab, mode: UiMode) -> &'static [Hint] {
|
||||
match (tab, mode) {
|
||||
(Tab::Graphs, UiMode::DetailOpen) => GRAPHS_DETAIL_HINTS,
|
||||
(_, UiMode::DetailOpen) => DETAIL_HINTS,
|
||||
(Tab::Peers, UiMode::RowSelected) => PEERS_SELECTED_HINTS,
|
||||
(_, UiMode::RowSelected) => ROW_SELECTED_HINTS,
|
||||
(Tab::Peers | Tab::Sessions | Tab::Transports | Tab::Gateway, UiMode::Overview) => {
|
||||
TABLE_OVERVIEW_HINTS
|
||||
}
|
||||
(Tab::Graphs, UiMode::Overview) => GRAPHS_OVERVIEW_HINTS,
|
||||
(Tab::Mmp, UiMode::Overview) => MMP_HINTS,
|
||||
(Tab::Tree | Tab::Bloom | Tab::Routing, UiMode::Overview) => PANE_SCROLL_HINTS,
|
||||
_ => NO_HINTS,
|
||||
}
|
||||
}
|
||||
|
||||
/// Render a key hint as `[key] label` spans (key dim-bracketed, label plain).
|
||||
fn hint_spans(h: &Hint) -> Vec<Span<'static>> {
|
||||
vec![
|
||||
Span::styled(format!("[{}] ", h.key), Style::default().fg(Color::Yellow)),
|
||||
Span::styled(
|
||||
format!("{} ", h.label),
|
||||
Style::default().fg(Color::DarkGray),
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
/// Build the footer hint line for the active context, fitting `budget` columns.
|
||||
///
|
||||
/// Contextual hints come first and are kept; global hints fill remaining width
|
||||
/// and drop when they don't fit; `[?] Help` is always appended last as the
|
||||
/// overflow affordance. Returns the spans to append after the connection and
|
||||
/// timing spans in the status bar.
|
||||
pub fn footer_hint_spans(tab: Tab, mode: UiMode, budget: usize) -> Vec<Span<'static>> {
|
||||
let help = Span::styled("[?] Help ", Style::default().fg(Color::DarkGray));
|
||||
let help_w = "[?] Help ".len();
|
||||
|
||||
let mut spans: Vec<Span<'static>> = Vec::new();
|
||||
let mut used = 0usize;
|
||||
|
||||
// Reserve room for the always-present help affordance.
|
||||
let avail = budget.saturating_sub(help_w);
|
||||
|
||||
let push_if_fits = |spans: &mut Vec<Span<'static>>, used: &mut usize, h: &Hint| -> bool {
|
||||
let w = h.key.chars().count() + h.label.chars().count() + 4; // "[] " + " "
|
||||
if *used + w <= avail {
|
||||
spans.extend(hint_spans(h));
|
||||
*used += w;
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
};
|
||||
|
||||
// Contextual first (highest priority).
|
||||
for h in contextual_hints(tab, mode) {
|
||||
push_if_fits(&mut spans, &mut used, h);
|
||||
}
|
||||
// Globals fill remaining space, dropping when they don't fit.
|
||||
for h in GLOBAL_HINTS {
|
||||
push_if_fits(&mut spans, &mut used, h);
|
||||
}
|
||||
|
||||
spans.push(help);
|
||||
spans
|
||||
}
|
||||
|
||||
/// Render the full `?` help overlay: a centered modal listing every binding
|
||||
/// for the active `(Tab, UiMode)` (contextual + global), drawn from the same
|
||||
/// registry the footer reads.
|
||||
pub fn draw_overlay(frame: &mut Frame, app: &App, area: Rect) {
|
||||
let tab = app.active_tab;
|
||||
let mode = UiMode::of(app);
|
||||
|
||||
let mut lines: Vec<Line<'static>> = Vec::new();
|
||||
lines.push(Line::from(Span::styled(
|
||||
format!(" {} — {:?}", tab.label(), mode),
|
||||
Style::default()
|
||||
.fg(Color::Cyan)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)));
|
||||
lines.push(Line::from(""));
|
||||
|
||||
lines.push(Line::from(Span::styled(
|
||||
" Context",
|
||||
Style::default()
|
||||
.fg(Color::Yellow)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)));
|
||||
let ctx = contextual_hints(tab, mode);
|
||||
if ctx.is_empty() {
|
||||
lines.push(Line::from(Span::styled(
|
||||
" (no context-specific keys)",
|
||||
Style::default().fg(Color::DarkGray),
|
||||
)));
|
||||
} else {
|
||||
for h in ctx {
|
||||
lines.push(overlay_row(h));
|
||||
}
|
||||
}
|
||||
lines.push(Line::from(""));
|
||||
lines.push(Line::from(Span::styled(
|
||||
" Global",
|
||||
Style::default()
|
||||
.fg(Color::Yellow)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)));
|
||||
for h in GLOBAL_HINTS {
|
||||
lines.push(overlay_row(h));
|
||||
}
|
||||
lines.push(overlay_row(&hint("BackTab", "previous tab")));
|
||||
lines.push(overlay_row(&hint("?", "toggle this help")));
|
||||
lines.push(Line::from(""));
|
||||
lines.push(Line::from(Span::styled(
|
||||
" Press ? or Esc to close",
|
||||
Style::default().fg(Color::DarkGray),
|
||||
)));
|
||||
|
||||
let popup = centered_rect(60, 70, area);
|
||||
frame.render_widget(Clear, popup);
|
||||
let block = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.title(" Help ")
|
||||
.style(Style::default().bg(Color::Black));
|
||||
let inner = block.inner(popup);
|
||||
frame.render_widget(block, popup);
|
||||
frame.render_widget(Paragraph::new(lines), inner);
|
||||
}
|
||||
|
||||
/// Render the Del-disconnect confirmation modal: a centered Y/N prompt naming
|
||||
/// the peer and showing a reconnect note tailored to its kind.
|
||||
pub fn draw_disconnect_modal(frame: &mut Frame, app: &App, area: Rect) {
|
||||
let Some(confirm) = &app.confirm_disconnect else {
|
||||
return;
|
||||
};
|
||||
|
||||
let lines = vec![
|
||||
Line::from(Span::styled(
|
||||
" Disconnect peer?",
|
||||
Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
|
||||
)),
|
||||
Line::from(""),
|
||||
Line::from(vec![
|
||||
Span::styled(" Peer: ", Style::default().fg(Color::DarkGray)),
|
||||
Span::styled(
|
||||
confirm.display_name.clone(),
|
||||
Style::default().add_modifier(Modifier::BOLD),
|
||||
),
|
||||
]),
|
||||
Line::from(Span::styled(
|
||||
format!(" {}", confirm.reconnect_note),
|
||||
Style::default().fg(Color::DarkGray),
|
||||
)),
|
||||
Line::from(""),
|
||||
Line::from(vec![
|
||||
Span::styled(" [Y] ", Style::default().fg(Color::Yellow)),
|
||||
Span::raw("disconnect "),
|
||||
Span::styled("[N/Esc] ", Style::default().fg(Color::Yellow)),
|
||||
Span::raw("cancel"),
|
||||
]),
|
||||
];
|
||||
|
||||
let popup = centered_rect_lines(64, 8, area);
|
||||
frame.render_widget(Clear, popup);
|
||||
let block = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.title(" Confirm ")
|
||||
.style(Style::default().bg(Color::Black));
|
||||
let inner = block.inner(popup);
|
||||
frame.render_widget(block, popup);
|
||||
frame.render_widget(Paragraph::new(lines), inner);
|
||||
}
|
||||
|
||||
/// A centered rectangle of fixed `w`×`h` cells (clamped to `area`).
|
||||
fn centered_rect_lines(w: u16, h: u16, area: Rect) -> Rect {
|
||||
let w = w.min(area.width);
|
||||
let h = h.min(area.height);
|
||||
let x = area.x + (area.width.saturating_sub(w)) / 2;
|
||||
let y = area.y + (area.height.saturating_sub(h)) / 2;
|
||||
Rect {
|
||||
x,
|
||||
y,
|
||||
width: w,
|
||||
height: h,
|
||||
}
|
||||
}
|
||||
|
||||
fn overlay_row(h: &Hint) -> Line<'static> {
|
||||
Line::from(vec![
|
||||
Span::styled(
|
||||
format!(" {:<10}", format!("[{}]", h.key)),
|
||||
Style::default().fg(Color::Yellow),
|
||||
),
|
||||
Span::raw(h.label.to_string()),
|
||||
])
|
||||
}
|
||||
|
||||
/// Compute a centered rectangle `pct_x`%×`pct_y`% of `area`.
|
||||
fn centered_rect(pct_x: u16, pct_y: u16, area: Rect) -> Rect {
|
||||
let w = area.width * pct_x / 100;
|
||||
let h = area.height * pct_y / 100;
|
||||
let x = area.x + (area.width.saturating_sub(w)) / 2;
|
||||
let y = area.y + (area.height.saturating_sub(h)) / 2;
|
||||
Rect {
|
||||
x,
|
||||
y,
|
||||
width: w,
|
||||
height: h,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Every key glyph the registry mentions must be one the `main.rs`
|
||||
/// dispatch actually handles, so a stale or invented hint can't ship. The
|
||||
/// dispatch key set is mirrored here; adding a binding to the registry
|
||||
/// without wiring it (or vice versa) trips this.
|
||||
#[test]
|
||||
fn registry_keys_exist_in_dispatch() {
|
||||
// The authoritative set of key glyphs the dispatch recognizes. Mirror
|
||||
// of the match arms in `main.rs` (plus the arrow/Enter/Esc/Tab keys).
|
||||
const DISPATCH_KEYS: &[&str] = &[
|
||||
"q",
|
||||
"Tab",
|
||||
"BackTab",
|
||||
"g",
|
||||
"m",
|
||||
"n/N",
|
||||
"s/S",
|
||||
"f",
|
||||
"?",
|
||||
"Del",
|
||||
"Enter",
|
||||
"Esc",
|
||||
"\u{2191}\u{2193}", // up/down
|
||||
"\u{2190}\u{2192}", // left/right
|
||||
];
|
||||
|
||||
let mut all: Vec<Hint> = GLOBAL_HINTS.to_vec();
|
||||
all.push(hint("BackTab", "previous tab"));
|
||||
all.push(hint("?", "toggle this help"));
|
||||
for &tab in &Tab::ALL {
|
||||
for mode in [UiMode::Overview, UiMode::RowSelected, UiMode::DetailOpen] {
|
||||
all.extend_from_slice(contextual_hints(tab, mode));
|
||||
}
|
||||
}
|
||||
for h in all {
|
||||
assert!(
|
||||
DISPATCH_KEYS.contains(&h.key),
|
||||
"registry key [{}] ({}) has no dispatch handler",
|
||||
h.key,
|
||||
h.label
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,37 @@
|
||||
use ratatui::style::{Color, Modifier, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{Block, Borders};
|
||||
use serde_json::Value;
|
||||
|
||||
/// A bordered pane block with a title that highlights its border (cyan, bold
|
||||
/// title) when `focused`, so the multi-pane focus model has a clear visual
|
||||
/// indicator of which pane the scroll keys act on.
|
||||
pub fn pane_block(title: &str, focused: bool) -> Block<'static> {
|
||||
let block = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.title(title.to_string());
|
||||
if focused {
|
||||
block
|
||||
.border_style(Style::default().fg(Color::Cyan))
|
||||
.title_style(
|
||||
Style::default()
|
||||
.fg(Color::Cyan)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)
|
||||
} else {
|
||||
block
|
||||
}
|
||||
}
|
||||
|
||||
/// Clamp a desired scroll offset to a pane's content so an over-scroll (e.g.
|
||||
/// from End, which passes `u16::MAX`) rests at the last full screen rather than
|
||||
/// scrolling past the content. `content_rows` is the total rendered line count
|
||||
/// and `visible_rows` the pane's inner height.
|
||||
pub fn clamp_scroll(offset: u16, content_rows: usize, visible_rows: usize) -> u16 {
|
||||
let max = content_rows.saturating_sub(visible_rows) as u16;
|
||||
offset.min(max)
|
||||
}
|
||||
|
||||
/// Extract a string field from JSON, returning "-" if missing.
|
||||
pub fn str_field<'a>(data: &'a Value, key: &str) -> &'a str {
|
||||
data.get(key).and_then(|v| v.as_str()).unwrap_or("-")
|
||||
@@ -24,6 +54,23 @@ pub fn truncate_hex(s: &str, max_len: usize) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// Truncate a display name to a fixed visible width, appending an ellipsis when
|
||||
/// it overflows, then pad to exactly `width` columns. Unlike a bare `{:<width}`
|
||||
/// format this guarantees the field never exceeds `width`, so a long npub-style
|
||||
/// name can't push past its column and butt against the next label. Counts and
|
||||
/// pads by `char`, which is correct for the ASCII/BMP names the daemon emits.
|
||||
pub fn truncate_name(s: &str, width: usize) -> String {
|
||||
let len = s.chars().count();
|
||||
if len <= width {
|
||||
format!("{s:<width$}")
|
||||
} else if width <= 1 {
|
||||
"\u{2026}".chars().take(width).collect()
|
||||
} else {
|
||||
let head: String = s.chars().take(width - 1).collect();
|
||||
format!("{head}\u{2026}")
|
||||
}
|
||||
}
|
||||
|
||||
/// Format bytes-per-second with engineering units (B/s, KB/s, MB/s, GB/s) and 3 significant digits.
|
||||
pub fn format_throughput(bytes_per_sec: f64) -> String {
|
||||
if bytes_per_sec < 0.0 {
|
||||
@@ -133,6 +180,17 @@ pub fn nested_f64_prefer(
|
||||
.unwrap_or_else(|| "-".into())
|
||||
}
|
||||
|
||||
/// Format an optional numeric field as a fixed-precision number, or an em-dash
|
||||
/// placeholder when the value is JSON `null` or the key is absent. Used for
|
||||
/// daemon-emitted `Option<f64>` fields (e.g. `effective_depth`) so an
|
||||
/// unmeasured value renders distinctly from a real zero.
|
||||
pub fn opt_f64_field(data: &Value, key: &str, decimals: usize) -> String {
|
||||
match data.get(key).and_then(|v| v.as_f64()) {
|
||||
Some(n) => format!("{:.prec$}", n, prec = decimals),
|
||||
None => "\u{2014}".into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract a bool field from JSON, returning "yes"/"no" or "-" if missing.
|
||||
pub fn bool_field(data: &Value, key: &str) -> &'static str {
|
||||
data.get(key)
|
||||
@@ -172,6 +230,148 @@ pub fn kv_line(key: &str, value: &str) -> Line<'static> {
|
||||
])
|
||||
}
|
||||
|
||||
/// Render a group of key-value pairs with the keys padded to a common
|
||||
/// width so the values share a single left edge. Alignment is computed
|
||||
/// once over the whole group rather than padded per call site, keeping
|
||||
/// the convention (one aligned value column per stack) in one place.
|
||||
pub fn kv_lines(pairs: &[(&str, String)]) -> Vec<Line<'static>> {
|
||||
let key_width = pairs.iter().map(|(k, _)| k.len()).max().unwrap_or(0);
|
||||
pairs
|
||||
.iter()
|
||||
.map(|(key, value)| {
|
||||
Line::from(vec![
|
||||
Span::styled(
|
||||
format!(" {key:<key_width$}: "),
|
||||
Style::default().fg(Color::DarkGray),
|
||||
),
|
||||
Span::raw(value.clone()),
|
||||
])
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Build a node-address -> (is_parent, is_child) map from the peers view's
|
||||
/// `peers` array. Only the peers view carries the tree-role flags, so the Tree
|
||||
/// and Bloom surfaces join their own peer lists against this map by node address
|
||||
/// to recover each peer's role. A missing or malformed payload yields an empty
|
||||
/// map (every peer then falls back to the Other group).
|
||||
pub fn peer_role_map(
|
||||
peers_data: Option<&Value>,
|
||||
) -> std::collections::HashMap<String, (bool, bool)> {
|
||||
let mut map = std::collections::HashMap::new();
|
||||
if let Some(arr) = peers_data
|
||||
.and_then(|d| d.get("peers"))
|
||||
.and_then(|v| v.as_array())
|
||||
{
|
||||
for p in arr {
|
||||
if let Some(addr) = p.get("node_addr").and_then(|v| v.as_str()) {
|
||||
let is_parent = p
|
||||
.get("is_parent")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
let is_child = p.get("is_child").and_then(|v| v.as_bool()).unwrap_or(false);
|
||||
map.insert(addr.to_string(), (is_parent, is_child));
|
||||
}
|
||||
}
|
||||
}
|
||||
map
|
||||
}
|
||||
|
||||
/// Enrich a tree/bloom peer Value with `is_parent`/`is_child` looked up in the
|
||||
/// peers role map by `addr_key` (the peer's node-address field, which differs
|
||||
/// per surface: `node_addr` on Tree, `peer` on Bloom). A peer not found in the
|
||||
/// map is left without role flags, so `group_rank` places it under Other.
|
||||
pub fn enrich_role(
|
||||
mut peer: Value,
|
||||
role_map: &std::collections::HashMap<String, (bool, bool)>,
|
||||
addr_key: &str,
|
||||
) -> Value {
|
||||
let addr = peer
|
||||
.get(addr_key)
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from);
|
||||
if let Some(addr) = addr
|
||||
&& let Some(&(is_parent, is_child)) = role_map.get(&addr)
|
||||
&& let Some(obj) = peer.as_object_mut()
|
||||
{
|
||||
obj.insert("is_parent".into(), Value::Bool(is_parent));
|
||||
obj.insert("is_child".into(), Value::Bool(is_child));
|
||||
}
|
||||
peer
|
||||
}
|
||||
|
||||
/// Tree-role group rank for a peer JSON object: parent first (0), then STP
|
||||
/// children (1), then everything else (2). A node with no parent simply has
|
||||
/// an empty group 0; a leaf with no children an empty group 1. Shared by the
|
||||
/// Peers, Tree, and Bloom surfaces so they group peers identically.
|
||||
pub fn group_rank(peer: &Value) -> u8 {
|
||||
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);
|
||||
if is_parent {
|
||||
0
|
||||
} else if is_child {
|
||||
1
|
||||
} else {
|
||||
2
|
||||
}
|
||||
}
|
||||
|
||||
/// The section label for a tree-role group rank, matching the Peers tab's
|
||||
/// box-drawing labels so all three surfaces read consistently.
|
||||
pub fn group_label(rank: u8) -> &'static str {
|
||||
match rank {
|
||||
0 => "\u{2500}\u{2500} Parent \u{2500}\u{2500}",
|
||||
1 => "\u{2500}\u{2500} STP Children \u{2500}\u{2500}",
|
||||
_ => "\u{2500}\u{2500} Other \u{2500}\u{2500}",
|
||||
}
|
||||
}
|
||||
|
||||
/// Stable-sort `peers` in place by tree-role group rank, preserving the input
|
||||
/// order within each group. Callers that want a finer secondary key (e.g. LQI)
|
||||
/// should sort by that key first, then call this for the group partition, or
|
||||
/// supply their own comparator keyed off `group_rank`.
|
||||
pub fn sort_by_group(peers: &mut [Value]) {
|
||||
peers.sort_by_key(group_rank);
|
||||
}
|
||||
|
||||
/// Render a group of peers as `Paragraph` lines: a styled section label before
|
||||
/// each non-empty group (in parent -> children -> other order), a blank
|
||||
/// separator between groups, and each peer rendered by `render_peer`. Empty
|
||||
/// groups are omitted (no label). `peers` is expected to already be grouped by
|
||||
/// `group_rank` (callers sort first). This is the Paragraph-of-Lines analogue
|
||||
/// of the Peers tab's grouped table, shared by the Tree and Bloom peer lists.
|
||||
pub fn grouped_peer_lines<F>(peers: &[Value], render_peer: F) -> Vec<Line<'static>>
|
||||
where
|
||||
F: Fn(&Value) -> Line<'static>,
|
||||
{
|
||||
let label_style = Style::default()
|
||||
.fg(Color::Yellow)
|
||||
.add_modifier(Modifier::BOLD);
|
||||
let mut lines: Vec<Line<'static>> = Vec::new();
|
||||
let mut last_group: Option<u8> = None;
|
||||
for peer in peers {
|
||||
let g = group_rank(peer);
|
||||
if last_group != Some(g) {
|
||||
if last_group.is_some() {
|
||||
lines.push(Line::from(""));
|
||||
}
|
||||
lines.push(Line::from(Span::styled(
|
||||
format!(" {}", group_label(g)),
|
||||
label_style,
|
||||
)));
|
||||
last_group = Some(g);
|
||||
}
|
||||
lines.push(render_peer(peer));
|
||||
}
|
||||
lines
|
||||
}
|
||||
|
||||
/// Render a sequence of values as Unicode block characters.
|
||||
///
|
||||
/// Returns an empty string for empty input. Constant series render as a
|
||||
|
||||
@@ -2,9 +2,9 @@ use ratatui::Frame;
|
||||
use ratatui::layout::{Constraint, Layout, Rect};
|
||||
use ratatui::style::{Color, Modifier, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{Block, Borders, Paragraph};
|
||||
use ratatui::widgets::Paragraph;
|
||||
|
||||
use crate::app::{App, Tab};
|
||||
use crate::app::{App, MMP_LINK_SORT_LABELS, MMP_SESSION_SORT_LABELS, SortState, Tab};
|
||||
|
||||
use super::helpers;
|
||||
|
||||
@@ -22,21 +22,129 @@ pub fn draw(frame: &mut Frame, app: &App, area: Rect) {
|
||||
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]);
|
||||
let focused = app.focused_pane();
|
||||
draw_link_mmp(
|
||||
frame,
|
||||
data,
|
||||
app.mmp_link_sort,
|
||||
app.pane_scroll(0),
|
||||
focused == 0,
|
||||
chunks[0],
|
||||
);
|
||||
draw_session_mmp(
|
||||
frame,
|
||||
data,
|
||||
app.mmp_session_sort,
|
||||
app.pane_scroll(1),
|
||||
focused == 1,
|
||||
chunks[1],
|
||||
);
|
||||
}
|
||||
|
||||
fn draw_link_mmp(frame: &mut Frame, data: &serde_json::Value, area: Rect) {
|
||||
let peers = data
|
||||
/// A numeric sort key for a metric value from a layer object, with absent
|
||||
/// values sorting last under an ascending sort by mapping them to +infinity.
|
||||
fn metric_key(layer: Option<&serde_json::Value>, prefer: &str, fallback: Option<&str>) -> f64 {
|
||||
layer
|
||||
.and_then(|l| l.get(prefer).or_else(|| fallback.and_then(|f| l.get(f))))
|
||||
.and_then(|v| v.as_f64())
|
||||
.unwrap_or(f64::INFINITY)
|
||||
}
|
||||
|
||||
/// Render the sortable-column header line: each column label, with the active
|
||||
/// sort column highlighted and carrying a direction arrow.
|
||||
fn sort_header(labels: &[&str], sort: SortState) -> Line<'static> {
|
||||
let active = Style::default()
|
||||
.fg(Color::Cyan)
|
||||
.add_modifier(Modifier::BOLD);
|
||||
let idle = Style::default().fg(Color::DarkGray);
|
||||
// Solid triangles for the sort direction, distinct from the line-arrow
|
||||
// glyphs the MMP trend columns use, so the two never collide visually or
|
||||
// in tests.
|
||||
let arrow = if sort.descending {
|
||||
"\u{25bc}"
|
||||
} else {
|
||||
"\u{25b2}"
|
||||
};
|
||||
let mut spans: Vec<Span<'static>> = vec![Span::styled(" sort: ", idle)];
|
||||
for (i, label) in labels.iter().enumerate() {
|
||||
if i > 0 {
|
||||
spans.push(Span::styled(" ", idle));
|
||||
}
|
||||
if i == sort.col {
|
||||
spans.push(Span::styled(format!("{label}{arrow}"), active));
|
||||
} else {
|
||||
spans.push(Span::styled(label.to_string(), idle));
|
||||
}
|
||||
}
|
||||
Line::from(spans)
|
||||
}
|
||||
|
||||
/// Apply the sort state to `peers` in place. Column 0 sorts by display name;
|
||||
/// the remaining columns sort by the corresponding metric from `layer_key`
|
||||
/// (the `link_layer` / `session_layer` object). Descending reverses the order.
|
||||
fn sort_peers(peers: &mut [serde_json::Value], sort: SortState, layer_key: &str) {
|
||||
peers.sort_by(|a, b| {
|
||||
let ord = if sort.col == 0 {
|
||||
let na = a.get("display_name").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let nb = b.get("display_name").and_then(|v| v.as_str()).unwrap_or("");
|
||||
na.cmp(nb)
|
||||
} else {
|
||||
let la = a.get(layer_key);
|
||||
let lb = b.get(layer_key);
|
||||
let (ka, kb) = metric_pair(la, lb, layer_key, sort.col);
|
||||
ka.partial_cmp(&kb).unwrap_or(std::cmp::Ordering::Equal)
|
||||
};
|
||||
if sort.descending { ord.reverse() } else { ord }
|
||||
});
|
||||
}
|
||||
|
||||
/// Compute the numeric sort keys for two peers on the given column, dispatching
|
||||
/// to the correct metric for the Link vs Session layer.
|
||||
fn metric_pair(
|
||||
la: Option<&serde_json::Value>,
|
||||
lb: Option<&serde_json::Value>,
|
||||
layer_key: &str,
|
||||
col: usize,
|
||||
) -> (f64, f64) {
|
||||
let (prefer, fallback): (&str, Option<&str>) = if layer_key == "link_layer" {
|
||||
match col {
|
||||
1 => ("srtt_ms", None),
|
||||
2 => ("smoothed_loss", Some("loss_rate")),
|
||||
3 => ("smoothed_etx", Some("etx")),
|
||||
4 => ("lqi", None),
|
||||
_ => ("goodput_bps", None),
|
||||
}
|
||||
} else {
|
||||
match col {
|
||||
1 => ("srtt_ms", None),
|
||||
2 => ("smoothed_loss", Some("loss_rate")),
|
||||
3 => ("smoothed_etx", Some("etx")),
|
||||
4 => ("sqi", None),
|
||||
_ => ("path_mtu", None),
|
||||
}
|
||||
};
|
||||
(
|
||||
metric_key(la, prefer, fallback),
|
||||
metric_key(lb, prefer, fallback),
|
||||
)
|
||||
}
|
||||
|
||||
fn draw_link_mmp(
|
||||
frame: &mut Frame,
|
||||
data: &serde_json::Value,
|
||||
sort: SortState,
|
||||
scroll: u16,
|
||||
focused: bool,
|
||||
area: Rect,
|
||||
) {
|
||||
let mut peers = data
|
||||
.get("peers")
|
||||
.and_then(|v| v.as_array())
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
|
||||
let count = peers.len();
|
||||
let block = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.title(format!(" Link MMP ({count} peers) "));
|
||||
let block = helpers::pane_block(&format!(" Link MMP ({count} peers) "), focused);
|
||||
let inner = block.inner(area);
|
||||
frame.render_widget(block, area);
|
||||
|
||||
@@ -46,7 +154,9 @@ fn draw_link_mmp(frame: &mut Frame, data: &serde_json::Value, area: Rect) {
|
||||
return;
|
||||
}
|
||||
|
||||
let mut lines: Vec<Line> = Vec::new();
|
||||
sort_peers(&mut peers, sort, "link_layer");
|
||||
|
||||
let mut lines: Vec<Line> = vec![sort_header(MMP_LINK_SORT_LABELS, sort)];
|
||||
for peer in &peers {
|
||||
let name = helpers::str_field(peer, "display_name");
|
||||
let ll = peer.get("link_layer");
|
||||
@@ -77,73 +187,59 @@ fn draw_link_mmp(frame: &mut Frame, data: &serde_json::Value, area: Rect) {
|
||||
.map(helpers::format_throughput)
|
||||
.unwrap_or_else(|| "-".into());
|
||||
|
||||
// Line 1: primary metrics
|
||||
// Trend arrows sit inline, immediately after the value they
|
||||
// describe: rtt -> srtt, loss -> loss, goodput -> gp. etx and lqi
|
||||
// carry no trend; jitter has no numeric column and is dropped. Each
|
||||
// tracked value reserves a fixed 1-char arrow slot (a space when
|
||||
// stable) so the columns stay aligned regardless of trend state.
|
||||
let label = Style::default().fg(Color::DarkGray);
|
||||
let srtt_arrow = trend_arrow(ll, "rtt_trend", true);
|
||||
let loss_arrow = trend_arrow(ll, "loss_trend", true);
|
||||
let gp_arrow = trend_arrow(ll, "goodput_trend", false);
|
||||
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(
|
||||
format!(" {name:<16}"),
|
||||
format!(" {} ", helpers::truncate_name(name, 16)),
|
||||
Style::default()
|
||||
.fg(Color::Yellow)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::styled("srtt: ", Style::default().fg(Color::DarkGray)),
|
||||
Span::styled("srtt: ", label),
|
||||
Span::raw(format!("{srtt:<10}")),
|
||||
Span::styled("loss: ", Style::default().fg(Color::DarkGray)),
|
||||
srtt_arrow,
|
||||
Span::styled(" loss: ", label),
|
||||
Span::raw(format!("{loss:<8}")),
|
||||
Span::styled("etx: ", Style::default().fg(Color::DarkGray)),
|
||||
loss_arrow,
|
||||
Span::styled(" etx: ", label),
|
||||
Span::raw(format!("{etx:<6}")),
|
||||
Span::styled("lqi: ", Style::default().fg(Color::DarkGray)),
|
||||
Span::styled("lqi: ", label),
|
||||
Span::raw(format!("{lqi:<8}")),
|
||||
Span::styled("gp: ", Style::default().fg(Color::DarkGray)),
|
||||
Span::styled("gp: ", label),
|
||||
Span::raw(goodput),
|
||||
gp_arrow,
|
||||
]));
|
||||
|
||||
// Line 2: trends
|
||||
if let Some(ll_val) = ll {
|
||||
let mut trend_spans: Vec<Span> = vec![Span::raw(" ")];
|
||||
let mut has_trends = false;
|
||||
|
||||
for (label, key, bad_rising) in [
|
||||
("rtt", "rtt_trend", true),
|
||||
("loss", "loss_trend", true),
|
||||
("goodput", "goodput_trend", false),
|
||||
("jitter", "jitter_trend", true),
|
||||
] {
|
||||
if let Some(trend) = ll_val.get(key).and_then(|v| v.as_str()) {
|
||||
if has_trends {
|
||||
trend_spans.push(Span::raw(" "));
|
||||
}
|
||||
trend_spans.push(Span::styled(
|
||||
format!("{label}: "),
|
||||
Style::default().fg(Color::DarkGray),
|
||||
));
|
||||
trend_spans.push(Span::styled(
|
||||
trend.to_string(),
|
||||
Style::default().fg(trend_color(trend, bad_rising)),
|
||||
));
|
||||
has_trends = true;
|
||||
}
|
||||
}
|
||||
|
||||
if has_trends {
|
||||
lines.push(Line::from(trend_spans));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
frame.render_widget(Paragraph::new(lines), inner);
|
||||
let scroll = helpers::clamp_scroll(scroll, lines.len(), inner.height as usize);
|
||||
frame.render_widget(Paragraph::new(lines).scroll((scroll, 0)), inner);
|
||||
}
|
||||
|
||||
fn draw_session_mmp(frame: &mut Frame, data: &serde_json::Value, area: Rect) {
|
||||
let sessions = data
|
||||
fn draw_session_mmp(
|
||||
frame: &mut Frame,
|
||||
data: &serde_json::Value,
|
||||
sort: SortState,
|
||||
scroll: u16,
|
||||
focused: bool,
|
||||
area: Rect,
|
||||
) {
|
||||
let mut sessions = data
|
||||
.get("sessions")
|
||||
.and_then(|v| v.as_array())
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
|
||||
let count = sessions.len();
|
||||
let block = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.title(format!(" Session MMP ({count} sessions) "));
|
||||
let block = helpers::pane_block(&format!(" Session MMP ({count} sessions) "), focused);
|
||||
let inner = block.inner(area);
|
||||
frame.render_widget(block, area);
|
||||
|
||||
@@ -153,60 +249,92 @@ fn draw_session_mmp(frame: &mut Frame, data: &serde_json::Value, area: Rect) {
|
||||
return;
|
||||
}
|
||||
|
||||
let lines: Vec<Line> = sessions
|
||||
.iter()
|
||||
.map(|s| {
|
||||
let name = helpers::str_field(s, "display_name");
|
||||
let sl = s.get("session_layer");
|
||||
sort_peers(&mut sessions, sort, "session_layer");
|
||||
|
||||
let srtt = sl
|
||||
.and_then(|l| l.get("srtt_ms"))
|
||||
.and_then(|v| v.as_f64())
|
||||
.map(|v| format!("{:.1}ms", v))
|
||||
.unwrap_or_else(|| "-".into());
|
||||
let loss = sl
|
||||
.and_then(|l| l.get("smoothed_loss").or_else(|| l.get("loss_rate")))
|
||||
.and_then(|v| v.as_f64())
|
||||
.map(|v| format!("{:.4}", v))
|
||||
.unwrap_or_else(|| "-".into());
|
||||
let etx = sl
|
||||
.and_then(|l| l.get("smoothed_etx").or_else(|| l.get("etx")))
|
||||
.and_then(|v| v.as_f64())
|
||||
.map(|v| format!("{:.2}", v))
|
||||
.unwrap_or_else(|| "-".into());
|
||||
let sqi = sl
|
||||
.and_then(|l| l.get("sqi"))
|
||||
.and_then(|v| v.as_f64())
|
||||
.map(|v| format!("{:.2}", v))
|
||||
.unwrap_or_else(|| "-".into());
|
||||
let mtu = sl
|
||||
.and_then(|l| l.get("path_mtu"))
|
||||
.and_then(|v| v.as_u64())
|
||||
.map(|v| v.to_string())
|
||||
.unwrap_or_else(|| "-".into());
|
||||
let mut lines: Vec<Line> = vec![sort_header(MMP_SESSION_SORT_LABELS, sort)];
|
||||
lines.extend(sessions.iter().map(|s| {
|
||||
let name = helpers::str_field(s, "display_name");
|
||||
let sl = s.get("session_layer");
|
||||
|
||||
Line::from(vec![
|
||||
Span::styled(
|
||||
format!(" {name:<16}"),
|
||||
Style::default()
|
||||
.fg(Color::Yellow)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::styled("srtt: ", Style::default().fg(Color::DarkGray)),
|
||||
Span::raw(format!("{srtt:<10}")),
|
||||
Span::styled("loss: ", Style::default().fg(Color::DarkGray)),
|
||||
Span::raw(format!("{loss:<8}")),
|
||||
Span::styled("etx: ", Style::default().fg(Color::DarkGray)),
|
||||
Span::raw(format!("{etx:<6}")),
|
||||
Span::styled("sqi: ", Style::default().fg(Color::DarkGray)),
|
||||
Span::raw(format!("{sqi:<8}")),
|
||||
Span::styled("mtu: ", Style::default().fg(Color::DarkGray)),
|
||||
Span::raw(mtu),
|
||||
])
|
||||
})
|
||||
.collect();
|
||||
let srtt = sl
|
||||
.and_then(|l| l.get("srtt_ms"))
|
||||
.and_then(|v| v.as_f64())
|
||||
.map(|v| format!("{:.1}ms", v))
|
||||
.unwrap_or_else(|| "-".into());
|
||||
let loss = sl
|
||||
.and_then(|l| l.get("smoothed_loss").or_else(|| l.get("loss_rate")))
|
||||
.and_then(|v| v.as_f64())
|
||||
.map(|v| format!("{:.4}", v))
|
||||
.unwrap_or_else(|| "-".into());
|
||||
let etx = sl
|
||||
.and_then(|l| l.get("smoothed_etx").or_else(|| l.get("etx")))
|
||||
.and_then(|v| v.as_f64())
|
||||
.map(|v| format!("{:.2}", v))
|
||||
.unwrap_or_else(|| "-".into());
|
||||
let sqi = sl
|
||||
.and_then(|l| l.get("sqi"))
|
||||
.and_then(|v| v.as_f64())
|
||||
.map(|v| format!("{:.2}", v))
|
||||
.unwrap_or_else(|| "-".into());
|
||||
let mtu = sl
|
||||
.and_then(|l| l.get("path_mtu"))
|
||||
.and_then(|v| v.as_u64())
|
||||
.map(|v| v.to_string())
|
||||
.unwrap_or_else(|| "-".into());
|
||||
|
||||
frame.render_widget(Paragraph::new(lines), inner);
|
||||
// Inline trend arrows mirror the Link MMP pane: srtt -> rtt_trend,
|
||||
// loss -> loss_trend, etx -> etx_trend, each with a fixed 1-char
|
||||
// slot (blank when stable) so the value columns stay aligned.
|
||||
let label = Style::default().fg(Color::DarkGray);
|
||||
let srtt_arrow = trend_arrow(sl, "rtt_trend", true);
|
||||
let loss_arrow = trend_arrow(sl, "loss_trend", true);
|
||||
let etx_arrow = trend_arrow(sl, "etx_trend", true);
|
||||
|
||||
Line::from(vec![
|
||||
Span::styled(
|
||||
format!(" {} ", helpers::truncate_name(name, 16)),
|
||||
Style::default()
|
||||
.fg(Color::Yellow)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::styled("srtt: ", label),
|
||||
Span::raw(format!("{srtt:<10}")),
|
||||
srtt_arrow,
|
||||
Span::styled(" loss: ", label),
|
||||
Span::raw(format!("{loss:<8}")),
|
||||
loss_arrow,
|
||||
Span::styled(" etx: ", label),
|
||||
Span::raw(format!("{etx:<6}")),
|
||||
etx_arrow,
|
||||
Span::styled(" sqi: ", label),
|
||||
Span::raw(format!("{sqi:<8}")),
|
||||
Span::styled("mtu: ", label),
|
||||
Span::raw(mtu),
|
||||
])
|
||||
}));
|
||||
|
||||
let scroll = helpers::clamp_scroll(scroll, lines.len(), inner.height as usize);
|
||||
frame.render_widget(Paragraph::new(lines).scroll((scroll, 0)), inner);
|
||||
}
|
||||
|
||||
/// Build the inline trend arrow span for a metric: a colored `↑`/`↓` for a
|
||||
/// rising/falling trend, or a single blank space when stable or absent.
|
||||
/// The slot is always one cell wide so value columns stay aligned. `layer`
|
||||
/// is the `link_layer` / `session_layer` object carrying the `*_trend` key.
|
||||
fn trend_arrow(layer: Option<&serde_json::Value>, key: &str, bad_rising: bool) -> Span<'static> {
|
||||
let trend = layer.and_then(|l| l.get(key)).and_then(|v| v.as_str());
|
||||
match trend {
|
||||
Some("rising") => Span::styled(
|
||||
"\u{2191}",
|
||||
Style::default().fg(trend_color("rising", bad_rising)),
|
||||
),
|
||||
Some("falling") => Span::styled(
|
||||
"\u{2193}",
|
||||
Style::default().fg(trend_color("falling", bad_rising)),
|
||||
),
|
||||
// Stable or no trend: a blank reserved slot.
|
||||
_ => Span::raw(" "),
|
||||
}
|
||||
}
|
||||
|
||||
/// Color a trend value based on whether "rising" is bad or good for this metric.
|
||||
|
||||
@@ -2,12 +2,17 @@ mod bloom;
|
||||
mod dashboard;
|
||||
mod gateway;
|
||||
mod graphs;
|
||||
pub(crate) mod help;
|
||||
mod helpers;
|
||||
pub(crate) mod listening;
|
||||
mod mmp;
|
||||
mod peers;
|
||||
mod routing;
|
||||
mod sessions;
|
||||
#[cfg(test)]
|
||||
mod snapshots;
|
||||
#[cfg(test)]
|
||||
mod testkit;
|
||||
mod transports;
|
||||
mod tree;
|
||||
|
||||
@@ -30,6 +35,15 @@ pub fn draw(frame: &mut Frame, app: &mut App) {
|
||||
draw_tab_bar(frame, app, chunks[0]);
|
||||
draw_content(frame, app, chunks[1]);
|
||||
draw_status_bar(frame, app, chunks[2]);
|
||||
|
||||
// The `?` help overlay draws over everything when toggled on.
|
||||
if app.show_help {
|
||||
help::draw_overlay(frame, app, chunks[1]);
|
||||
}
|
||||
// The Del-disconnect confirmation modal draws over the content.
|
||||
if app.confirm_disconnect.is_some() {
|
||||
help::draw_disconnect_modal(frame, app, chunks[1]);
|
||||
}
|
||||
}
|
||||
|
||||
fn draw_tab_bar(frame: &mut Frame, app: &App, area: Rect) {
|
||||
@@ -101,9 +115,17 @@ fn draw_status_bar(frame: &mut Frame, app: &App, area: Rect) {
|
||||
elapsed.as_secs_f64()
|
||||
));
|
||||
|
||||
let help = Span::styled("[?] Help ", Style::default().fg(Color::DarkGray));
|
||||
// Context-aware hints fill the remaining width after the connection and
|
||||
// timing spans, sourced from the shared keybinding registry so they can't
|
||||
// drift from the `?` overlay.
|
||||
let fixed_w = conn.width() + timing.width();
|
||||
let budget = (area.width as usize).saturating_sub(fixed_w);
|
||||
let mode = help::UiMode::of(app);
|
||||
let hint_spans = help::footer_hint_spans(app.active_tab, mode, budget);
|
||||
|
||||
let line = Line::from(vec![conn, timing, help]);
|
||||
let mut spans = vec![conn, timing];
|
||||
spans.extend(hint_spans);
|
||||
let line = Line::from(spans);
|
||||
let bar = Paragraph::new(line);
|
||||
frame.render_widget(bar, area);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ use ratatui::style::{Color, Modifier, Style};
|
||||
use ratatui::text::Line;
|
||||
use ratatui::widgets::{
|
||||
Block, Borders, Cell, Paragraph, Row, Scrollbar, ScrollbarOrientation, ScrollbarState, Table,
|
||||
TableState,
|
||||
};
|
||||
|
||||
use crate::app::{App, Tab};
|
||||
@@ -26,7 +27,9 @@ pub fn draw(frame: &mut Frame, app: &mut App, area: Rect) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Get peers sorted by LQI ascending (best first). Peers without LQI sort last.
|
||||
/// Get peers grouped by role (parent -> STP children -> other), and within
|
||||
/// each group sorted by LQI ascending (best first). Peers without LQI sort
|
||||
/// last within their group.
|
||||
fn get_peers_sorted(app: &App) -> Vec<serde_json::Value> {
|
||||
let mut peers = app
|
||||
.data
|
||||
@@ -37,20 +40,25 @@ 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());
|
||||
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,
|
||||
(None, Some(_)) => std::cmp::Ordering::Greater,
|
||||
(None, None) => std::cmp::Ordering::Equal,
|
||||
}
|
||||
// Primary key: role group. Secondary key: LQI ascending.
|
||||
helpers::group_rank(a)
|
||||
.cmp(&helpers::group_rank(b))
|
||||
.then_with(|| {
|
||||
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,
|
||||
(None, Some(_)) => std::cmp::Ordering::Greater,
|
||||
(None, None) => std::cmp::Ordering::Equal,
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
peers
|
||||
@@ -71,6 +79,7 @@ fn draw_table(
|
||||
Cell::from("SRTT"),
|
||||
Cell::from("Loss"),
|
||||
Cell::from("LQI"),
|
||||
Cell::from("EffD"),
|
||||
Cell::from("Goodput"),
|
||||
Cell::from("Pkts Tx"),
|
||||
Cell::from("Pkts Rx"),
|
||||
@@ -81,90 +90,114 @@ fn draw_table(
|
||||
.add_modifier(Modifier::BOLD),
|
||||
);
|
||||
|
||||
let rows: Vec<Row> = peers
|
||||
.iter()
|
||||
.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);
|
||||
// Build the grouped display: a styled label row before each non-empty
|
||||
// group, the group's peer rows, and a blank separator before the next
|
||||
// group. `peer_display_idx[p]` is the display-row index of sorted peer `p`,
|
||||
// so the stored peer-index selection (used by detail + navigation) can be
|
||||
// translated to the display row to highlight, and the cursor only ever
|
||||
// lands on peer rows.
|
||||
let mut rows: Vec<Row> = Vec::new();
|
||||
let mut peer_display_idx: Vec<usize> = Vec::with_capacity(peers.len());
|
||||
let mut last_group: Option<u8> = None;
|
||||
let group_label_style = Style::default()
|
||||
.fg(Color::Yellow)
|
||||
.add_modifier(Modifier::BOLD);
|
||||
for peer in peers.iter() {
|
||||
let g = helpers::group_rank(peer);
|
||||
if last_group != Some(g) {
|
||||
if last_group.is_some() {
|
||||
rows.push(Row::new(vec![Cell::from("")]));
|
||||
}
|
||||
rows.push(Row::new(vec![Cell::from(helpers::group_label(g))]).style(group_label_style));
|
||||
last_group = Some(g);
|
||||
}
|
||||
|
||||
// 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("");
|
||||
if t_type.is_empty() && t_addr.is_empty() {
|
||||
"-".to_string()
|
||||
} else if t_type.is_empty() {
|
||||
t_addr.to_string()
|
||||
} else if t_addr.is_empty() {
|
||||
t_type.to_string()
|
||||
} else {
|
||||
format!("{t_type}/{t_addr}")
|
||||
}
|
||||
};
|
||||
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 dir = peer
|
||||
.get("direction")
|
||||
// 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())
|
||||
.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);
|
||||
let goodput = helpers::nested_throughput(peer, "mmp", "goodput_bps");
|
||||
let pkts_tx = helpers::nested_u64(peer, "stats", "packets_sent");
|
||||
let pkts_rx = helpers::nested_u64(peer, "stats", "packets_recv");
|
||||
|
||||
// Tree role colorization
|
||||
let row_style = if is_parent {
|
||||
Style::default().fg(Color::Magenta)
|
||||
} else if is_child {
|
||||
Style::default().fg(Color::Cyan)
|
||||
.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() {
|
||||
t_addr.to_string()
|
||||
} else if t_addr.is_empty() {
|
||||
t_type.to_string()
|
||||
} else {
|
||||
Style::default()
|
||||
};
|
||||
format!("{t_type}/{t_addr}")
|
||||
}
|
||||
};
|
||||
|
||||
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);
|
||||
let eff_depth = helpers::opt_f64_field(peer, "effective_depth", 2);
|
||||
let goodput = helpers::nested_throughput(peer, "mmp", "goodput_bps");
|
||||
let pkts_tx = helpers::nested_u64(peer, "stats", "packets_sent");
|
||||
let pkts_rx = helpers::nested_u64(peer, "stats", "packets_recv");
|
||||
|
||||
// Tree role colorization
|
||||
let row_style = if is_parent {
|
||||
Style::default().fg(Color::Magenta)
|
||||
} else if is_child {
|
||||
Style::default().fg(Color::Cyan)
|
||||
} else {
|
||||
Style::default()
|
||||
};
|
||||
|
||||
peer_display_idx.push(rows.len());
|
||||
rows.push(
|
||||
Row::new(vec![
|
||||
Cell::from(name.to_string()),
|
||||
Cell::from(npub.to_string()),
|
||||
Cell::from(helpers::truncate_hex(npub, 18)),
|
||||
Cell::from(transport),
|
||||
Cell::from(dir.to_string()),
|
||||
Cell::from(srtt),
|
||||
Cell::from(loss),
|
||||
Cell::from(lqi),
|
||||
Cell::from(eff_depth),
|
||||
Cell::from(goodput),
|
||||
Cell::from(pkts_tx),
|
||||
Cell::from(pkts_rx),
|
||||
])
|
||||
.style(row_style)
|
||||
})
|
||||
.collect();
|
||||
.style(row_style),
|
||||
);
|
||||
}
|
||||
|
||||
let widths = [
|
||||
Constraint::Min(12), // Name
|
||||
Constraint::Length(67), // Npub (full bech32)
|
||||
Constraint::Min(20), // Name (wide enough for the group labels)
|
||||
Constraint::Length(20), // Npub (truncated; full form in the detail view)
|
||||
Constraint::Min(20), // Transport
|
||||
Constraint::Length(4), // Dir
|
||||
Constraint::Length(8), // SRTT
|
||||
Constraint::Length(7), // Loss
|
||||
Constraint::Length(6), // LQI
|
||||
Constraint::Length(6), // EffD
|
||||
Constraint::Length(10), // Goodput
|
||||
Constraint::Length(9), // Pkts Tx
|
||||
Constraint::Length(9), // Pkts Rx
|
||||
@@ -184,12 +217,21 @@ fn draw_table(
|
||||
)
|
||||
.highlight_symbol("▶ ");
|
||||
|
||||
let state = app.table_states.entry(Tab::Peers).or_default();
|
||||
frame.render_stateful_widget(table, area, state);
|
||||
// The stored selection is the *peer* index (used by detail + navigation);
|
||||
// translate it to the display row so the highlight lands on the peer's row
|
||||
// and the cursor never sits on a label or blank separator.
|
||||
let peer_sel = app.table_states.get(&Tab::Peers).and_then(|s| s.selected());
|
||||
let mut display_state = TableState::default();
|
||||
if let Some(p) = peer_sel
|
||||
&& let Some(&disp) = peer_display_idx.get(p)
|
||||
{
|
||||
display_state.select(Some(disp));
|
||||
}
|
||||
frame.render_stateful_widget(table, area, &mut display_state);
|
||||
|
||||
// Scrollbar
|
||||
// Scrollbar tracks the peer position within the peer count.
|
||||
if row_count > 0 {
|
||||
let selected = state.selected().unwrap_or(0);
|
||||
let selected = peer_sel.unwrap_or(0);
|
||||
let mut scrollbar_state = ScrollbarState::new(row_count).position(selected);
|
||||
frame.render_stateful_widget(
|
||||
Scrollbar::new(ScrollbarOrientation::VerticalRight)
|
||||
@@ -329,6 +371,10 @@ fn draw_detail(frame: &mut Frame, app: &App, area: Rect, peers: &[serde_json::Va
|
||||
if let Some(depth) = peer.get("tree_depth").and_then(|v| v.as_u64()) {
|
||||
lines.push(helpers::kv_line("Tree Depth", &depth.to_string()));
|
||||
}
|
||||
lines.push(helpers::kv_line(
|
||||
"Effective Depth",
|
||||
&helpers::opt_f64_field(peer, "effective_depth", 2),
|
||||
));
|
||||
lines.extend([
|
||||
helpers::kv_line("Bloom Filter", if has_bloom { "yes" } else { "no" }),
|
||||
helpers::kv_line("Filter Seq", &helpers::u64_field(peer, "filter_sequence")),
|
||||
|
||||
@@ -2,7 +2,7 @@ use ratatui::Frame;
|
||||
use ratatui::layout::{Constraint, Layout, Rect};
|
||||
use ratatui::style::{Color, Style};
|
||||
use ratatui::text::Line;
|
||||
use ratatui::widgets::{Block, Borders, Paragraph};
|
||||
use ratatui::widgets::Paragraph;
|
||||
|
||||
use crate::app::{App, Tab};
|
||||
|
||||
@@ -26,45 +26,50 @@ pub fn draw(frame: &mut Frame, app: &App, area: Rect) {
|
||||
])
|
||||
.split(area);
|
||||
|
||||
draw_routing_state(frame, data, chunks[0]);
|
||||
draw_coord_cache(frame, app, chunks[1]);
|
||||
draw_routing_stats(frame, data, chunks[2]);
|
||||
let focused = app.focused_pane();
|
||||
draw_routing_state(frame, data, app.pane_scroll(0), focused == 0, chunks[0]);
|
||||
draw_coord_cache(frame, app, app.pane_scroll(1), focused == 1, chunks[1]);
|
||||
draw_routing_stats(frame, data, app.pane_scroll(2), focused == 2, chunks[2]);
|
||||
}
|
||||
|
||||
fn draw_routing_state(frame: &mut Frame, data: &serde_json::Value, area: Rect) {
|
||||
let lines = vec![
|
||||
helpers::kv_line(
|
||||
fn draw_routing_state(
|
||||
frame: &mut Frame,
|
||||
data: &serde_json::Value,
|
||||
scroll: u16,
|
||||
focused: bool,
|
||||
area: Rect,
|
||||
) {
|
||||
let lines = helpers::kv_lines(&[
|
||||
(
|
||||
"Coord Cache",
|
||||
&helpers::u64_field(data, "coord_cache_entries"),
|
||||
helpers::u64_field(data, "coord_cache_entries"),
|
||||
),
|
||||
helpers::kv_line(
|
||||
(
|
||||
"Identity Cache",
|
||||
&helpers::u64_field(data, "identity_cache_entries"),
|
||||
helpers::u64_field(data, "identity_cache_entries"),
|
||||
),
|
||||
helpers::kv_line(
|
||||
(
|
||||
"Pending Lookups",
|
||||
&data
|
||||
.get("pending_lookups")
|
||||
data.get("pending_lookups")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|a| a.len().to_string())
|
||||
.unwrap_or_else(|| "0".into()),
|
||||
),
|
||||
helpers::kv_line(
|
||||
(
|
||||
"Recent Requests",
|
||||
&helpers::u64_field(data, "recent_requests"),
|
||||
helpers::u64_field(data, "recent_requests"),
|
||||
),
|
||||
];
|
||||
]);
|
||||
|
||||
let block = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.title(" Routing State ");
|
||||
let block = helpers::pane_block(" Routing State ", focused);
|
||||
let inner = block.inner(area);
|
||||
frame.render_widget(block, area);
|
||||
frame.render_widget(Paragraph::new(lines), inner);
|
||||
let scroll = helpers::clamp_scroll(scroll, lines.len(), inner.height as usize);
|
||||
frame.render_widget(Paragraph::new(lines).scroll((scroll, 0)), inner);
|
||||
}
|
||||
|
||||
/// Format a forwarding counter as "N pkts (formatted_bytes)".
|
||||
fn fwd_line(data: &serde_json::Value, label: &str, pkt_key: &str, byte_key: &str) -> Line<'static> {
|
||||
fn fwd_value(data: &serde_json::Value, pkt_key: &str, byte_key: &str) -> String {
|
||||
let pkts = data
|
||||
.get("forwarding")
|
||||
.and_then(|f| f.get(pkt_key))
|
||||
@@ -75,187 +80,238 @@ 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)),
|
||||
)
|
||||
format!("{} pkts ({})", pkts, helpers::format_bytes(bytes))
|
||||
}
|
||||
|
||||
fn draw_routing_stats(frame: &mut Frame, data: &serde_json::Value, area: Rect) {
|
||||
let block = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.title(" Routing Statistics ");
|
||||
/// Read a raw forwarding counter as a u64 (0 if missing), for arithmetic
|
||||
/// (percentages, derived totals) that the string-returning helpers can't do.
|
||||
fn fwd_count(data: &serde_json::Value, key: &str) -> u64 {
|
||||
data.get("forwarding")
|
||||
.and_then(|f| f.get(key))
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Total mesh egress = locally-originated + transit-forwarded, formatted as
|
||||
/// "N pkts (B)". There is no single daemon counter for everything this node
|
||||
/// transmits to peers, so it is derived from its two contributors.
|
||||
fn mesh_tx_value(data: &serde_json::Value) -> String {
|
||||
let pkts = fwd_count(data, "originated_packets") + fwd_count(data, "forwarded_packets");
|
||||
let bytes = fwd_count(data, "originated_bytes") + fwd_count(data, "forwarded_bytes");
|
||||
format!("{} pkts ({})", pkts, helpers::format_bytes(bytes))
|
||||
}
|
||||
|
||||
/// Format a route-class count as "N (xx.x%)" where the percentage is the class's
|
||||
/// share of total forwarded (transit) packets. Zero forwarded yields "0.0%".
|
||||
fn route_class_value(count: u64, total_forwarded: u64) -> String {
|
||||
let pct = if total_forwarded > 0 {
|
||||
count as f64 / total_forwarded as f64 * 100.0
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
format!("{count} ({pct:.1}%)")
|
||||
}
|
||||
|
||||
/// Build a section: a styled header line followed by the kv pairs rendered
|
||||
/// through the group helper so the section's values share a left edge.
|
||||
fn section(title: &str, pairs: &[(&str, String)]) -> Vec<Line<'static>> {
|
||||
let mut out = vec![helpers::section_header(title)];
|
||||
out.extend(helpers::kv_lines(pairs));
|
||||
out
|
||||
}
|
||||
|
||||
fn draw_routing_stats(
|
||||
frame: &mut Frame,
|
||||
data: &serde_json::Value,
|
||||
scroll: u16,
|
||||
focused: bool,
|
||||
area: Rect,
|
||||
) {
|
||||
let block = helpers::pane_block(" Routing Statistics ", focused);
|
||||
let inner = block.inner(area);
|
||||
frame.render_widget(block, area);
|
||||
|
||||
let cols =
|
||||
Layout::horizontal([Constraint::Percentage(50), Constraint::Percentage(50)]).split(inner);
|
||||
|
||||
// Left column: Forwarding + Discovery
|
||||
let mut left = vec![
|
||||
helpers::section_header("Forwarding"),
|
||||
fwd_line(data, "Received", "received_packets", "received_bytes"),
|
||||
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",
|
||||
),
|
||||
Line::from(""),
|
||||
helpers::section_header("Discovery Requests"),
|
||||
helpers::kv_line(
|
||||
"Received",
|
||||
&helpers::nested_u64(data, "discovery", "req_received"),
|
||||
),
|
||||
helpers::kv_line(
|
||||
// Shorthand for a nested counter value (e.g. lookup.req_received).
|
||||
let lookup = |key: &str| helpers::nested_u64(data, "lookup", key);
|
||||
let err = |key: &str| helpers::nested_u64(data, "error_signals", key);
|
||||
let cong = |key: &str| helpers::nested_u64(data, "congestion", key);
|
||||
|
||||
// The node is an interface adapter between the local host stack and the
|
||||
// mesh; the left column reads each side as a Transmitted/Received pair.
|
||||
//
|
||||
// Local Stack — traffic crossing the TUN / local-origination boundary:
|
||||
// Transmitted is what the host injects into the mesh (originated), Received
|
||||
// is what the mesh hands up to the host (delivered).
|
||||
let mut left = section(
|
||||
"Local Stack",
|
||||
&[
|
||||
(
|
||||
"Transmitted",
|
||||
fwd_value(data, "originated_packets", "originated_bytes"),
|
||||
),
|
||||
(
|
||||
"Received",
|
||||
fwd_value(data, "delivered_packets", "delivered_bytes"),
|
||||
),
|
||||
],
|
||||
);
|
||||
left.push(Line::from(""));
|
||||
// Mesh — traffic crossing the peer-link boundary: Transmitted is everything
|
||||
// this node puts on the wire (originated + forwarded, derived), Received is
|
||||
// the ingress aggregate from peers (own-delivered + transit + drops).
|
||||
left.extend(section(
|
||||
"Mesh",
|
||||
&[
|
||||
("Transmitted", mesh_tx_value(data)),
|
||||
(
|
||||
"Received",
|
||||
fwd_value(data, "received_packets", "received_bytes"),
|
||||
),
|
||||
],
|
||||
));
|
||||
left.push(Line::from(""));
|
||||
left.extend(section(
|
||||
"Lookup Requests",
|
||||
&[
|
||||
("Received", lookup("req_received")),
|
||||
("Forwarded", lookup("req_forwarded")),
|
||||
("Initiated", lookup("req_initiated")),
|
||||
("Deduplicated", lookup("req_deduplicated")),
|
||||
("Target Is Us", lookup("req_target_is_us")),
|
||||
("Duplicate", lookup("req_duplicate")),
|
||||
("Bloom Miss", lookup("req_bloom_miss")),
|
||||
("Backoff Suppressed", lookup("req_backoff_suppressed")),
|
||||
("Fwd Rate Limited", lookup("req_forward_rate_limited")),
|
||||
("TTL Exhausted", lookup("req_ttl_exhausted")),
|
||||
("Decode Error", lookup("req_decode_error")),
|
||||
],
|
||||
));
|
||||
left.push(Line::from(""));
|
||||
left.extend(section(
|
||||
"Lookup Responses",
|
||||
&[
|
||||
("Received", lookup("resp_received")),
|
||||
("Accepted", lookup("resp_accepted")),
|
||||
("Forwarded", lookup("resp_forwarded")),
|
||||
("Timed Out", lookup("resp_timed_out")),
|
||||
("Identity Miss", lookup("resp_identity_miss")),
|
||||
("Proof Failed", lookup("resp_proof_failed")),
|
||||
("Decode Error", lookup("resp_decode_error")),
|
||||
],
|
||||
));
|
||||
|
||||
// Right column — "Forwarded" (transit / routed through this node).
|
||||
// Forwarded total, then the route-class breakdown (a percentage partition
|
||||
// of the total), then the transit-path drop reasons.
|
||||
let fwd_total = fwd_count(data, "forwarded_packets");
|
||||
let mut right = section(
|
||||
"Forwarded",
|
||||
&[(
|
||||
"Forwarded",
|
||||
&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"),
|
||||
),
|
||||
];
|
||||
fwd_value(data, "forwarded_packets", "forwarded_bytes"),
|
||||
)],
|
||||
);
|
||||
// Blank separator after the Forwarded total, matching the spacing between
|
||||
// every other section pair; the total and its route-class breakdown read
|
||||
// as two distinct groups.
|
||||
right.push(Line::from(""));
|
||||
// Route-class breakdown: a partition of Forwarded, each line annotated with
|
||||
// its share of the total. Tree-down cross — the dive-to-tree-child
|
||||
// cut-through — is the last class; Tree-down + Tree-down cross sum to the
|
||||
// pre-split tree-down total.
|
||||
right.extend(section(
|
||||
"Route Class",
|
||||
&[
|
||||
(
|
||||
"Direct Peer",
|
||||
route_class_value(fwd_count(data, "route_direct_peer"), fwd_total),
|
||||
),
|
||||
(
|
||||
"Tree-down",
|
||||
route_class_value(fwd_count(data, "route_tree_down"), fwd_total),
|
||||
),
|
||||
(
|
||||
"Tree-up",
|
||||
route_class_value(fwd_count(data, "route_tree_up"), fwd_total),
|
||||
),
|
||||
(
|
||||
"Cross-link descend",
|
||||
route_class_value(fwd_count(data, "route_crosslink_descend"), fwd_total),
|
||||
),
|
||||
(
|
||||
"Cross-link ascend",
|
||||
route_class_value(fwd_count(data, "route_crosslink_ascend"), fwd_total),
|
||||
),
|
||||
(
|
||||
"Tree-down cross",
|
||||
route_class_value(fwd_count(data, "route_tree_down_cross"), fwd_total),
|
||||
),
|
||||
],
|
||||
));
|
||||
right.push(Line::from(""));
|
||||
right.extend(section(
|
||||
"Dropped",
|
||||
&[
|
||||
(
|
||||
"No Route",
|
||||
fwd_value(data, "drop_no_route_packets", "drop_no_route_bytes"),
|
||||
),
|
||||
(
|
||||
"TTL Exhausted",
|
||||
fwd_value(data, "ttl_exhausted_packets", "ttl_exhausted_bytes"),
|
||||
),
|
||||
(
|
||||
"Decode Error",
|
||||
fwd_value(data, "decode_error_packets", "decode_error_bytes"),
|
||||
),
|
||||
(
|
||||
"MTU Exceeded",
|
||||
fwd_value(data, "drop_mtu_exceeded_packets", "drop_mtu_exceeded_bytes"),
|
||||
),
|
||||
(
|
||||
"Send Error",
|
||||
fwd_value(data, "drop_send_error_packets", "drop_send_error_bytes"),
|
||||
),
|
||||
],
|
||||
));
|
||||
right.push(Line::from(""));
|
||||
right.extend(section(
|
||||
"Error Signals",
|
||||
&[
|
||||
("Coords Required", err("coords_required")),
|
||||
("Path Broken", err("path_broken")),
|
||||
("MTU Exceeded", err("mtu_exceeded")),
|
||||
],
|
||||
));
|
||||
right.push(Line::from(""));
|
||||
right.extend(section(
|
||||
"Congestion",
|
||||
&[
|
||||
("CE Forwarded", cong("ce_forwarded")),
|
||||
("CE Received", cong("ce_received")),
|
||||
("Congestion Detected", cong("congestion_detected")),
|
||||
("Kernel Drops", cong("kernel_drop_events")),
|
||||
],
|
||||
));
|
||||
|
||||
// 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"),
|
||||
),
|
||||
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"),
|
||||
),
|
||||
];
|
||||
// Both columns scroll together under the focused-pane offset, clamped to
|
||||
// the taller column so neither over-scrolls past its content.
|
||||
let visible = cols[0].height as usize;
|
||||
let content = left.len().max(right.len());
|
||||
let scroll = helpers::clamp_scroll(scroll, content, visible);
|
||||
|
||||
let max_lines = cols[0].height as usize;
|
||||
left.truncate(max_lines);
|
||||
right.truncate(max_lines);
|
||||
|
||||
frame.render_widget(Paragraph::new(left), cols[0]);
|
||||
frame.render_widget(Paragraph::new(right), cols[1]);
|
||||
frame.render_widget(Paragraph::new(left).scroll((scroll, 0)), cols[0]);
|
||||
frame.render_widget(Paragraph::new(right).scroll((scroll, 0)), cols[1]);
|
||||
}
|
||||
|
||||
fn draw_coord_cache(frame: &mut Frame, app: &App, area: Rect) {
|
||||
fn draw_coord_cache(frame: &mut Frame, app: &App, scroll: u16, focused: bool, area: Rect) {
|
||||
let data = match app.data.get(&Tab::Cache) {
|
||||
Some(d) => d,
|
||||
None => {
|
||||
let block = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.title(" Coordinate Cache ");
|
||||
let block = helpers::pane_block(" Coordinate Cache ", focused);
|
||||
let inner = block.inner(area);
|
||||
frame.render_widget(block, area);
|
||||
let msg =
|
||||
@@ -284,18 +340,17 @@ fn draw_coord_cache(frame: &mut Frame, app: &App, area: Rect) {
|
||||
.map(helpers::format_duration_ms)
|
||||
.unwrap_or_else(|| "-".into());
|
||||
|
||||
let lines = vec![
|
||||
helpers::kv_line("Entries", &format!("{entries} / {max_entries}")),
|
||||
helpers::kv_line("Fill Ratio", &fill_pct),
|
||||
helpers::kv_line("Default TTL", &ttl),
|
||||
helpers::kv_line("Expired", &expired),
|
||||
helpers::kv_line("Avg Age", &avg_age),
|
||||
];
|
||||
let lines = helpers::kv_lines(&[
|
||||
("Entries", format!("{entries} / {max_entries}")),
|
||||
("Fill Ratio", fill_pct),
|
||||
("Default TTL", ttl),
|
||||
("Expired", expired),
|
||||
("Avg Age", avg_age),
|
||||
]);
|
||||
|
||||
let block = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.title(" Coordinate Cache ");
|
||||
let block = helpers::pane_block(" Coordinate Cache ", focused);
|
||||
let inner = block.inner(area);
|
||||
frame.render_widget(block, area);
|
||||
frame.render_widget(Paragraph::new(lines), inner);
|
||||
let scroll = helpers::clamp_scroll(scroll, lines.len(), inner.height as usize);
|
||||
frame.render_widget(Paragraph::new(lines).scroll((scroll, 0)), inner);
|
||||
}
|
||||
|
||||
1294
src/bin/fipstop/ui/snapshots.rs
Normal file
1294
src/bin/fipstop/ui/snapshots.rs
Normal file
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user