From 0f1fd18c2513814bd01d53c34affca819d4534de Mon Sep 17 00:00:00 2001 From: Arjen <18398758+Origami74@users.noreply.github.com> Date: Sat, 20 Jun 2026 14:39:47 +0000 Subject: [PATCH 1/2] packaging(openwrt): SDK-free .apk build for OpenWrt 25+ and control-socket fix Add OpenWrt .apk packaging 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 like the .ipk: it reuses the cargo-zigbuild cross-compile and the shared installed-filesystem payload under openwrt-ipk/files, and assembles the ADB container with the official `apk mkpkg` applet from apk-tools 3.0.5 built from source, so no OpenWrt SDK image is needed. The package-openwrt workflow is refactored so a single compile-binaries job cross-compiles and strips each arch once; both the .ipk and .apk packagers consume the binaries via a new --bin-dir flag instead of each recompiling. A build-apk job (aarch64, x86_64) builds apk-tools, packages, and structurally verifies the .apk with `apk adbdump`. Releases now publish .apk artifacts and checksums alongside .ipk; the release download is scoped to fips_* so the shared raw-binary artifacts are not swept into the published release. apk-version.sh maps a release tag or commit height to an apk-tools-valid version, covered by a case-table test. Packages are unsigned, installed with `apk add --allow-untrusted`, matching the .ipk posture. Also fix the OpenWrt control socket: the init script now pre-creates /run/fips before starting the daemon, the procd equivalent of the systemd unit's RuntimeDirectory=fips. Without it, on a fresh boot the daemon resolves its control socket to /tmp (since /run/fips does not yet exist), while fips-gateway later creates /run/fips for its own gateway.sock, leaving fipsctl/fipstop resolving a /run/fips/control.sock the daemon never bound. --- .github/workflows/package-openwrt.yml | 393 +++++++++++++++++++- CHANGELOG.md | 10 + packaging/Makefile | 8 +- packaging/README.md | 33 +- packaging/openwrt-apk/README.md | 98 +++++ packaging/openwrt-apk/apk-version.sh | 74 ++++ packaging/openwrt-apk/apk-version.test.sh | 45 +++ packaging/openwrt-apk/build-apk.sh | 289 ++++++++++++++ packaging/openwrt-ipk/build-ipk.sh | 80 ++-- packaging/openwrt-ipk/files/etc/init.d/fips | 11 + 10 files changed, 985 insertions(+), 56 deletions(-) create mode 100644 packaging/openwrt-apk/README.md create mode 100755 packaging/openwrt-apk/apk-version.sh create mode 100755 packaging/openwrt-apk/apk-version.test.sh create mode 100755 packaging/openwrt-apk/build-apk.sh diff --git a/.github/workflows/package-openwrt.yml b/.github/workflows/package-openwrt.yml index 1b39d84..83f882c 100644 --- a/.github/workflows/package-openwrt.yml +++ b/.github/workflows/package-openwrt.yml @@ -24,6 +24,7 @@ jobs: runs-on: ubuntu-latest outputs: package_version: ${{ steps.version.outputs.package_version }} + apk_version: ${{ steps.version.outputs.apk_version }} release_channel: ${{ steps.channel.outputs.release_channel }} steps: - uses: actions/checkout@v6 @@ -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 @@ -100,14 +108,6 @@ jobs: with: fetch-depth: 0 - - name: Set SOURCE_DATE_EPOCH from git - run: echo "SOURCE_DATE_EPOCH=$(git log -1 --format=%ct)" >> "$GITHUB_ENV" - - - name: Initialize - run: | - PACKAGE_FILENAME=${{ env.PACKAGE_NAME }}_${{ needs.determine-versioning.outputs.package_version }}_${{ matrix.openwrt_arch }}.ipk - echo "PACKAGE_FILENAME=$PACKAGE_FILENAME" >> $GITHUB_ENV - - name: Install Rust toolchain (stable) if: matrix.rust_channel == 'stable' uses: actions-rust-lang/setup-rust-toolchain@v1 @@ -153,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: | @@ -201,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 @@ -389,7 +446,7 @@ 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 }} @@ -452,6 +509,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 @@ -509,25 +567,327 @@ 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: "; its files are + # "- name: " nested one indent level deeper under "files:". + # (There are no "path:" keys.) Reconstruct full file paths by keying + # off the indentation of the directory-level list items. + RECON=$(awk ' + /^paths:/ {p=1; diri=-1; next} + p && /^[^ #-]/ {p=0} # a new top-level key ends paths: + !p {next} + match($0, /^ *- /) { + ind=RLENGTH; rest=substr($0, RLENGTH+1) + if (diri==-1) diri=ind # first list item = directory indent + if (ind==diri) { # directory entry (or the root acl: entry) + if (rest ~ /^name: /) { dir=rest; sub(/^name: /,"",dir) } else dir="" + next + } + if (rest ~ /^name: /) { # deeper item = a file under files: + f=rest; sub(/^name: /,"",f); print (dir==""?f:dir"/"f) + } + } + ' "$DUMP") + echo "==> reconstructed paths:"; printf '%s\n' "$RECON" + + for path in \ + usr/bin/fips usr/bin/fipsctl usr/bin/fipstop usr/bin/fips-gateway \ + etc/init.d/fips etc/init.d/fips-gateway \ + etc/fips/fips.yaml etc/fips/firewall.sh etc/dnsmasq.d/fips.conf \ + etc/sysctl.d/fips-gateway.conf etc/sysctl.d/fips-bridge.conf \ + etc/hotplug.d/net/99-fips etc/uci-defaults/90-fips-setup \ + lib/upgrade/keep.d/fips; do + if printf '%s\n' "$RECON" | grep -qxF "$path"; then + echo " PASS path: $path" + else + echo " FAIL path: missing $path"; fail=1 + fi + done + + if [ "$fail" -ne 0 ]; then + echo "apk structural verification FAILED" + exit 1 + fi + echo "apk structural verification PASS" + + - name: SHA-256 hashes + run: | + echo "==> Binaries:" + sha256sum bins/fips bins/fipsctl bins/fipstop + echo "==> Package:" + sha256sum dist/${{ env.PACKAGE_FILENAME }} + + - name: Upload artifact (GitHub only) + if: ${{ env.ACT != 'true' }} + uses: actions/upload-artifact@v7 + with: + name: ${{ env.PACKAGE_FILENAME }} + path: dist/${{ env.PACKAGE_FILENAME }} + retention-days: 30 + + - name: Install nak + shell: bash + run: | + NAK_VERSION="0.16.2" + ARCH=$(uname -m) + case "$ARCH" in + x86_64|amd64) NAK_ARCH="amd64" ;; + aarch64|arm64) NAK_ARCH="arm64" ;; + *) echo "Unsupported architecture: $ARCH"; exit 1 ;; + esac + curl -fsSL "https://github.com/fiatjaf/nak/releases/download/v${NAK_VERSION}/nak-v${NAK_VERSION}-linux-${NAK_ARCH}" \ + -o /usr/local/bin/nak + chmod +x /usr/local/bin/nak + nak --version + + - name: Install jq + run: | + if ! command -v jq &>/dev/null; then + sudo apt-get update && sudo apt-get install -y jq + fi + + # Priority: HIVE_CI_NSEC from env (loom job) > repo secret > generate ephemeral + - name: Resolve signing key + id: keys + shell: bash + env: + SECRET_NSEC: ${{ secrets.HIVE_CI_NSEC }} + run: | + : ${GITHUB_OUTPUT:=/tmp/github_output} + if [ -n "${HIVE_CI_NSEC:-}" ]; then + echo "Using HIVE_CI_NSEC from loom job environment" + NSEC="$HIVE_CI_NSEC" + elif [ -n "$SECRET_NSEC" ]; then + echo "Using HIVE_CI_NSEC from repository secrets" + NSEC="$SECRET_NSEC" + else + echo "No nsec provided -- generating ephemeral keypair" + NSEC=$(nak key generate) + fi + + PUBKEY=$(echo "$NSEC" | nak key public) + echo "::add-mask::$NSEC" + echo "nsec=$NSEC" >> "$GITHUB_OUTPUT" + echo "pubkey=$PUBKEY" >> "$GITHUB_OUTPUT" + echo "Publisher pubkey (hex): $PUBKEY" + + - name: Upload to Blossom + id: blossom_upload + shell: bash + env: + BLOSSOM_SERVER: "https://blossom.primal.net" + NSEC: ${{ steps.keys.outputs.nsec }} + run: | + : ${GITHUB_OUTPUT:=/tmp/github_output} + + UPLOAD_RESPONSE=$(nak blossom upload \ + --server "$BLOSSOM_SERVER" \ + --sec "$NSEC" \ + "dist/${{ env.PACKAGE_FILENAME }}" < /dev/null) + + echo "Upload response:" + echo "$UPLOAD_RESPONSE" + + FILE_HASH=$(echo "$UPLOAD_RESPONSE" | jq -r '.sha256') + if [ -z "$FILE_HASH" ] || [ "$FILE_HASH" = "null" ]; then + echo "Failed to extract hash from upload response" + exit 1 + fi + + BLOSSOM_URL="${BLOSSOM_SERVER}/${FILE_HASH}" + echo "url=$BLOSSOM_URL" >> "$GITHUB_OUTPUT" + echo "hash=$FILE_HASH" >> "$GITHUB_OUTPUT" + echo "Uploaded to Blossom: $BLOSSOM_URL" + + - name: Publish NIP-94 release event + id: publish + shell: bash + env: + RELAYS: "wss://relay.damus.io wss://nos.lol wss://nostr.mom wss://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 + - name: Download package artifacts uses: actions/download-artifact@v8 with: + # Only the .ipk/.apk packages (named fips__.*), 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' -printf '%P\n' \ + find . -maxdepth 1 -type f \( -name '*.ipk' -o -name '*.apk' \) -printf '%P\n' \ | LC_ALL=C sort \ | xargs sha256sum \ > checksums-openwrt.txt @@ -537,5 +897,6 @@ jobs: with: files: | dist/*.ipk + dist/*.apk dist/checksums-openwrt.txt generate_release_notes: true diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b1c82e..bcd885a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- 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. - 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 diff --git a/packaging/Makefile b/packaging/Makefile index 70d0d4d..9f5d4ba 100644 --- a/packaging/Makefile +++ b/packaging/Makefile @@ -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 diff --git a/packaging/README.md b/packaging/README.md index 045fee4..553bf07 100644 --- a/packaging/README.md +++ b/packaging/README.md @@ -8,7 +8,8 @@ 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 @@ -46,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 ``` @@ -100,7 +102,7 @@ sudo ./fips--linux-/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. @@ -110,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 diff --git a/packaging/openwrt-apk/README.md b/packaging/openwrt-apk/README.md new file mode 100644 index 0000000..5410249 --- /dev/null +++ b/packaging/openwrt-apk/README.md @@ -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 +(`(.)*(_*)*(-r)`). `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__.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__.apk root@192.168.1.1:/tmp/ +ssh root@192.168.1.1 apk add --allow-untrusted /tmp/fips__.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. diff --git a/packaging/openwrt-apk/apk-version.sh b/packaging/openwrt-apk/apk-version.sh new file mode 100755 index 0000000..791ac99 --- /dev/null +++ b/packaging/openwrt-apk/apk-version.sh @@ -0,0 +1,74 @@ +#!/bin/sh +# Emit an apk-tools-compatible version string for FIPS. +# +# apk-tools enforces a strict version grammar: +# (.)*(_*)*(-r) +# where 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 # e.g. v1.2.3, v1.2.3-rc1 +# apk-version.sh dev # 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 | dev ]" >&2 + exit 2 + ;; +esac + +if [ -n "$raw_tag" ]; then + # Release tag: vX.Y.Z or vX.Y.Z-
. 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
diff --git a/packaging/openwrt-apk/apk-version.test.sh b/packaging/openwrt-apk/apk-version.test.sh
new file mode 100755
index 0000000..a9b7865
--- /dev/null
+++ b/packaging/openwrt-apk/apk-version.test.sh
@@ -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="$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"
diff --git a/packaging/openwrt-apk/build-apk.sh b/packaging/openwrt-apk/build-apk.sh
new file mode 100755
index 0000000..862e802
--- /dev/null
+++ b/packaging/openwrt-apk/build-apk.sh
@@ -0,0 +1,289 @@
+#!/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 ]
+#
+# 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__.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__.apk root@192.168.1.1:/tmp/
+#   ssh root@192.168.1.1 apk add --allow-untrusted /tmp/fips__.apk
+
+set -euo pipefail
+
+# ---------------------------------------------------------------------------
+# Arguments
+# ---------------------------------------------------------------------------
+
+ARCH="aarch64"
+BIN_DIR=""   # if set, use prebuilt binaries from here instead of compiling
+
+while [[ $# -gt 0 ]]; do
+    case "$1" in
+        --arch) ARCH="$2"; shift 2 ;;
+        --arch=*) ARCH="${1#*=}"; shift ;;
+        --bin-dir) BIN_DIR="$2"; shift 2 ;;
+        --bin-dir=*) BIN_DIR="${1#*=}"; shift ;;
+        *) echo "Unknown argument: $1" >&2; exit 1 ;;
+    esac
+done
+
+# ---------------------------------------------------------------------------
+# Architecture mapping
+#
+# RUST_TARGET   — passed to cargo --target
+# OPENWRT_ARCH  — apk "arch:" field and the package filename
+#
+# Kept in sync with ../openwrt-ipk/build-ipk.sh (same target table).
+# ---------------------------------------------------------------------------
+
+case "$ARCH" in
+    aarch64)
+        RUST_TARGET="aarch64-unknown-linux-musl"
+        OPENWRT_ARCH="aarch64_cortex-a53"
+        ;;
+    mipsel)
+        RUST_TARGET="mipsel-unknown-linux-musl"
+        OPENWRT_ARCH="mipsel_24kc"
+        ;;
+    mips)
+        RUST_TARGET="mips-unknown-linux-musl"
+        OPENWRT_ARCH="mips_24kc"
+        ;;
+    arm)
+        RUST_TARGET="arm-unknown-linux-musleabihf"
+        OPENWRT_ARCH="arm_cortex-a7"
+        ;;
+    x86_64)
+        RUST_TARGET="x86_64-unknown-linux-musl"
+        OPENWRT_ARCH="x86_64"
+        ;;
+    *)
+        echo "Unknown arch: $ARCH" >&2
+        echo "Valid: aarch64, mipsel, mips, arm, x86_64" >&2
+        exit 1
+        ;;
+esac
+
+# ---------------------------------------------------------------------------
+# Paths
+# ---------------------------------------------------------------------------
+
+SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
+PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
+# The installed-filesystem payload (init scripts, config, sysctl, etc.) is
+# shared with the .ipk package; there is one canonical copy in openwrt-ipk/.
+FILES_DIR="$PROJECT_ROOT/packaging/openwrt-ipk/files"
+DIST_DIR="$PROJECT_ROOT/dist"
+
+PKG_NAME="fips"
+# Human-readable version for the filename (e.g. v0.4.0 or master.123.abcdef0),
+# mirroring the .ipk artifacts and the CI/NIP-94 plumbing.
+PKG_VERSION="${PKG_VERSION:-$(cd "$PROJECT_ROOT" && git describe --tags --always --dirty 2>/dev/null || echo "0.1.0")}"
+# apk-tools-compatible version embedded inside the package metadata.
+APK_VERSION="${APK_VERSION:-$(cd "$PROJECT_ROOT" && sh "$SCRIPT_DIR/apk-version.sh" auto)}"
+
+APK_BIN="${APK_BIN:-apk}"
+if ! command -v "$APK_BIN" >/dev/null 2>&1; then
+    echo "Error: apk-tools binary not found (looked for '$APK_BIN')." >&2
+    echo "  Build apk-tools v3 from source or set APK_BIN=/path/to/apk." >&2
+    echo "  See packaging/openwrt-apk/README.md." >&2
+    exit 1
+fi
+
+echo "==> Building $PKG_NAME $PKG_VERSION (apk version $APK_VERSION) for $OPENWRT_ARCH ($RUST_TARGET)"
+
+# ---------------------------------------------------------------------------
+# 1. Obtain binaries
+#
+# Either use a directory of prebuilt binaries (--bin-dir; CI cross-compiles
+# once in a shared job and hands them to both the .ipk and .apk packagers), or
+# compile from source here for a self-contained local build.
+# ---------------------------------------------------------------------------
+
+if [ -n "$BIN_DIR" ]; then
+    RELEASE_DIR="$BIN_DIR"
+    echo "==> Using prebuilt binaries from $RELEASE_DIR"
+    for bin in fips fipsctl fipstop fips-gateway; do
+        [ -f "$RELEASE_DIR/$bin" ] || {
+            echo "Error: prebuilt binary not found: $RELEASE_DIR/$bin" >&2
+            exit 1
+        }
+    done
+else
+    if ! command -v cargo-zigbuild &>/dev/null; then
+        echo "Error: cargo-zigbuild not found." >&2
+        echo "  Install: cargo install cargo-zigbuild" >&2
+        exit 1
+    fi
+
+    if ! rustup target list --installed | grep -q "^$RUST_TARGET$"; then
+        echo "==> Adding Rust target $RUST_TARGET..."
+        rustup target add "$RUST_TARGET"
+    fi
+
+    echo "==> Compiling..."
+    cd "$PROJECT_ROOT"
+    cargo zigbuild \
+        --release \
+        --target "$RUST_TARGET" \
+        --bin fips \
+        --bin fipsctl \
+        --bin fipstop \
+        --bin fips-gateway
+
+    RELEASE_DIR="$PROJECT_ROOT/target/$RUST_TARGET/release"
+
+    echo "==> Stripping binaries..."
+    STRIP="${LLVM_STRIP:-strip}"
+    for bin in fips fipsctl fipstop fips-gateway; do
+        "$STRIP" "$RELEASE_DIR/$bin" 2>/dev/null || true
+    done
+fi
+
+SIZE=$(du -sh "$RELEASE_DIR/fips" | cut -f1)
+echo "    fips: $SIZE"
+
+# ---------------------------------------------------------------------------
+# 2. Stage the installed filesystem tree (--files root for apk mkpkg)
+# ---------------------------------------------------------------------------
+# This block is the same payload as ../openwrt-ipk/build-ipk.sh; keep the two
+# in sync. The CI apk structural check asserts every path below is present.
+
+WORK_DIR="$(mktemp -d)"
+trap 'rm -rf "$WORK_DIR"' EXIT
+
+STAGE_DIR="$WORK_DIR/root"        # becomes the package's filesystem
+SCRIPTS_DIR="$WORK_DIR/scripts"   # maintainer scripts (metadata, not payload)
+mkdir -p "$STAGE_DIR" "$SCRIPTS_DIR"
+
+install -d "$STAGE_DIR/usr/bin"
+install -m 0755 "$RELEASE_DIR/fips"         "$STAGE_DIR/usr/bin/fips"
+install -m 0755 "$RELEASE_DIR/fipsctl"      "$STAGE_DIR/usr/bin/fipsctl"
+install -m 0755 "$RELEASE_DIR/fipstop"      "$STAGE_DIR/usr/bin/fipstop"
+install -m 0755 "$RELEASE_DIR/fips-gateway" "$STAGE_DIR/usr/bin/fips-gateway"
+
+install -d "$STAGE_DIR/etc/init.d"
+install -m 0755 "$FILES_DIR/etc/init.d/fips"         "$STAGE_DIR/etc/init.d/fips"
+install -m 0755 "$FILES_DIR/etc/init.d/fips-gateway" "$STAGE_DIR/etc/init.d/fips-gateway"
+
+install -d "$STAGE_DIR/etc/fips"
+install -m 0600 "$FILES_DIR/etc/fips/fips.yaml"   "$STAGE_DIR/etc/fips/fips.yaml"
+install -m 0755 "$FILES_DIR/etc/fips/firewall.sh" "$STAGE_DIR/etc/fips/firewall.sh"
+
+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/.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"
diff --git a/packaging/openwrt-ipk/build-ipk.sh b/packaging/openwrt-ipk/build-ipk.sh
index 0bf37b1..b20cf65 100755
--- a/packaging/openwrt-ipk/build-ipk.sh
+++ b/packaging/openwrt-ipk/build-ipk.sh
@@ -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
diff --git a/packaging/openwrt-ipk/files/etc/init.d/fips b/packaging/openwrt-ipk/files/etc/init.d/fips
index a5e3151..77d21d2 100644
--- a/packaging/openwrt-ipk/files/etc/init.d/fips
+++ b/packaging/openwrt-ipk/files/etc/init.d/fips
@@ -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

From 274b09d4ff125a9f2a771ca3bea973de89944aa0 Mon Sep 17 00:00:00 2001
From: Johnathan Corgan 
Date: Thu, 18 Jun 2026 18:19:55 +0000
Subject: [PATCH 2/2] node: classify transit forwards by route class; regroup
 fipstop routing tab

Add six forwarding 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. Classification is computed from tree coordinates at the transit
chokepoint, so the error-signal routing callers are excluded.

The two "outside the chosen peer's subtree" classes are both up-and-over
forwards but differ in what they depend on. Tree-down-cross is the
dive-to-tree-child cut-through: we forward down to our own child for a
destination not beneath it, which is only possible because the child
advertised cross-link reach upward to us, beyond its own subtree. Its count
measures how much forwarding depends on that upward advertisement, i.e. what
would change if cross-link advertisements were narrowed to subtree-entry only.
Cross-link ascend, by contrast, uses the node's own lateral cross-link learned
from a peer's split-horizon advertisement, so it does not depend on any upward
advertisement.

Surface the counters through the forwarding stats snapshot (control socket,
show_routing and show_status) and reorganize the fipstop routing tab so its
two columns separate own/endpoint traffic (received, delivered, originated)
from forwarded/transit traffic (the route-class breakdown and drop reasons),
with the tree-down-cross line visually flagged.
---
 src/bin/fipstop/ui/routing.rs           | 169 ++++++++++++++----
 src/bin/fipstop/ui/snapshots.rs         |   7 +-
 src/control/snapshots/show_routing.json |   6 +
 src/control/snapshots/show_status.json  |   6 +
 src/node/handlers/forwarding.rs         |   5 +
 src/node/metrics.rs                     |  66 +++++++
 src/node/mod.rs                         |  80 +++++++++
 src/node/stats.rs                       |   6 +
 src/node/tests/routing.rs               | 223 ++++++++++++++++++++++++
 src/tree/state.rs                       |   9 +
 10 files changed, 538 insertions(+), 39 deletions(-)

diff --git a/src/bin/fipstop/ui/routing.rs b/src/bin/fipstop/ui/routing.rs
index 2ab57de..d03cbab 100644
--- a/src/bin/fipstop/ui/routing.rs
+++ b/src/bin/fipstop/ui/routing.rs
@@ -83,6 +83,35 @@ fn fwd_value(data: &serde_json::Value, pkt_key: &str, byte_key: &str) -> String
     format!("{} pkts ({})", pkts, helpers::format_bytes(bytes))
 }
 
+/// Read a raw forwarding counter as a u64 (0 if missing), for arithmetic
+/// (percentages, derived totals) that the string-returning helpers can't do.
+fn fwd_count(data: &serde_json::Value, key: &str) -> u64 {
+    data.get("forwarding")
+        .and_then(|f| f.get(key))
+        .and_then(|v| v.as_u64())
+        .unwrap_or(0)
+}
+
+/// Total mesh egress = locally-originated + transit-forwarded, formatted as
+/// "N pkts (B)". There is no single daemon counter for everything this node
+/// transmits to peers, so it is derived from its two contributors.
+fn mesh_tx_value(data: &serde_json::Value) -> String {
+    let pkts = fwd_count(data, "originated_packets") + fwd_count(data, "forwarded_packets");
+    let bytes = fwd_count(data, "originated_bytes") + fwd_count(data, "forwarded_bytes");
+    format!("{} pkts ({})", pkts, helpers::format_bytes(bytes))
+}
+
+/// Format a route-class count as "N (xx.x%)" where the percentage is the class's
+/// share of total forwarded (transit) packets. Zero forwarded yields "0.0%".
+fn route_class_value(count: u64, total_forwarded: u64) -> String {
+    let pct = if total_forwarded > 0 {
+        count as f64 / total_forwarded as f64 * 100.0
+    } else {
+        0.0
+    };
+    format!("{count} ({pct:.1}%)")
+}
+
 /// Build a section: a styled header line followed by the kv pairs rendered
 /// through the group helper so the section's values share a left edge.
 fn section(title: &str, pairs: &[(&str, String)]) -> Vec> {
@@ -110,49 +139,39 @@ fn draw_routing_stats(
     let err = |key: &str| helpers::nested_u64(data, "error_signals", key);
     let cong = |key: &str| helpers::nested_u64(data, "congestion", key);
 
-    // Left column: Forwarding + Discovery. Each section's values share a left
-    // edge via the kv_lines group helper.
+    // 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(
-        "Forwarding",
+        "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"),
             ),
-            (
-                "Delivered",
-                fwd_value(data, "delivered_packets", "delivered_bytes"),
-            ),
-            (
-                "Forwarded",
-                fwd_value(data, "forwarded_packets", "forwarded_bytes"),
-            ),
-            (
-                "Originated",
-                fwd_value(data, "originated_packets", "originated_bytes"),
-            ),
-            (
-                "Decode Error",
-                fwd_value(data, "decode_error_packets", "decode_error_bytes"),
-            ),
-            (
-                "TTL Exhausted",
-                fwd_value(data, "ttl_exhausted_packets", "ttl_exhausted_bytes"),
-            ),
-            (
-                "No Route",
-                fwd_value(data, "drop_no_route_packets", "drop_no_route_bytes"),
-            ),
-            (
-                "MTU Exceeded",
-                fwd_value(data, "drop_mtu_exceeded_packets", "drop_mtu_exceeded_bytes"),
-            ),
-            (
-                "Send Error",
-                fwd_value(data, "drop_send_error_packets", "drop_send_error_bytes"),
-            ),
         ],
-    );
+    ));
     left.push(Line::from(""));
     left.extend(section(
         "Discovery Requests",
@@ -184,15 +203,89 @@ fn draw_routing_stats(
         ],
     ));
 
-    // Right column: Error Signals + Congestion
+    // Right column — "Forwarded" (transit / routed through this node).
+    // Forwarded total, then the route-class breakdown (a percentage partition
+    // of the total), then the transit-path drop reasons.
+    let fwd_total = fwd_count(data, "forwarded_packets");
     let mut right = section(
+        "Forwarded",
+        &[(
+            "Forwarded",
+            fwd_value(data, "forwarded_packets", "forwarded_bytes"),
+        )],
+    );
+    // Blank separator after the Forwarded total, matching the spacing between
+    // every other section pair; the total and its route-class breakdown read
+    // as two distinct groups.
+    right.push(Line::from(""));
+    // Route-class breakdown: a partition of Forwarded, each line annotated with
+    // its share of the total. Tree-down cross — the dive-to-tree-child
+    // cut-through — is the last class; Tree-down + Tree-down cross sum to the
+    // pre-split tree-down total.
+    right.extend(section(
+        "Route Class",
+        &[
+            (
+                "Direct Peer",
+                route_class_value(fwd_count(data, "route_direct_peer"), fwd_total),
+            ),
+            (
+                "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",
diff --git a/src/bin/fipstop/ui/snapshots.rs b/src/bin/fipstop/ui/snapshots.rs
index 7515311..2bfe1c0 100644
--- a/src/bin/fipstop/ui/snapshots.rs
+++ b/src/bin/fipstop/ui/snapshots.rs
@@ -1134,7 +1134,12 @@ fn routing_focused_pane_scrolls() {
     let mut app1 = app_with(Tab::Routing, data);
     app1.data.insert(Tab::Cache, json!({}));
     app1.focused_pane.insert(Tab::Routing, 2);
-    app1.scroll_offsets.insert((Tab::Routing, 2), 6);
+    // Congestion is the last section of the right ("Forwarded") column, below
+    // the route-class breakdown, Dropped, and Error Signals groups. The left
+    // column is the taller of the two, so scrolling fully to the bottom would
+    // over-scroll the right column past Congestion; this offset lands the
+    // Congestion region inside the short window instead.
+    app1.scroll_offsets.insert((Tab::Routing, 2), 24);
     let buf1 = testkit::render(100, 20, |frame, area| {
         super::routing::draw(frame, &app1, area);
     });
diff --git a/src/control/snapshots/show_routing.json b/src/control/snapshots/show_routing.json
index ac6e2a3..52c3f5a 100644
--- a/src/control/snapshots/show_routing.json
+++ b/src/control/snapshots/show_routing.json
@@ -53,6 +53,12 @@
       "originated_packets": 0,
       "received_bytes": 0,
       "received_packets": 0,
+      "route_crosslink_ascend": 0,
+      "route_crosslink_descend": 0,
+      "route_direct_peer": 0,
+      "route_tree_down": 0,
+      "route_tree_down_cross": 0,
+      "route_tree_up": 0,
       "ttl_exhausted_bytes": 0,
       "ttl_exhausted_packets": 0
     },
diff --git a/src/control/snapshots/show_status.json b/src/control/snapshots/show_status.json
index ef1b811..a67008a 100644
--- a/src/control/snapshots/show_status.json
+++ b/src/control/snapshots/show_status.json
@@ -22,6 +22,12 @@
       "originated_packets": 0,
       "received_bytes": 0,
       "received_packets": 0,
+      "route_crosslink_ascend": 0,
+      "route_crosslink_descend": 0,
+      "route_direct_peer": 0,
+      "route_tree_down": 0,
+      "route_tree_down_cross": 0,
+      "route_tree_up": 0,
       "ttl_exhausted_bytes": 0,
       "ttl_exhausted_packets": 0
     },
diff --git a/src/node/handlers/forwarding.rs b/src/node/handlers/forwarding.rs
index 3e01778..8d6f060 100644
--- a/src/node/handlers/forwarding.rs
+++ b/src/node/handlers/forwarding.rs
@@ -151,6 +151,11 @@ impl Node {
             }
         } else {
             self.metrics().forwarding.record_forwarded(encoded.len());
+            // Classify this transit forward by route class (partition of
+            // forwarded_packets). Done here, at the data-plane chokepoint, so
+            // the error-signal routing callers of find_next_hop are excluded.
+            let class = self.classify_forward(&datagram.dest_addr, &next_hop_addr);
+            self.metrics().forwarding.record_route_class(class);
             if outgoing_ce {
                 self.metrics().congestion.ce_forwarded.inc();
             }
diff --git a/src/node/metrics.rs b/src/node/metrics.rs
index b58de38..84f5ffc 100644
--- a/src/node/metrics.rs
+++ b/src/node/metrics.rs
@@ -81,6 +81,51 @@ pub struct ForwardingMetrics {
     pub drop_send_error_bytes: Counter,
     pub originated_packets: Counter,
     pub originated_bytes: Counter,
+    pub route_tree_up: Counter,
+    pub route_tree_down: Counter,
+    pub route_tree_down_cross: Counter,
+    pub route_crosslink_descend: Counter,
+    pub route_crosslink_ascend: Counter,
+    pub route_direct_peer: Counter,
+}
+
+/// Route class of a transit-forwarded packet, classified from tree
+/// coordinates at the forwarding decision point. The six variants
+/// partition `forwarded_packets` exactly.
+///
+/// Two variants are up-and-over forwards (destination not in the chosen
+/// peer's subtree); they differ in whether they depend on a child
+/// advertising cross-link reach *upward* to its parent:
+/// - `TreeDownCross`: the chosen peer is our tree descendant, but the
+///   destination is *not* in that child's subtree. The forward only fired
+///   because the child advertised cross-link reach upward to us, beyond its
+///   own subtree. If children advertised only their subtree upward, this
+///   forward would route up instead, so its count measures how much
+///   forwarding depends on the upward cross-link advertisement — the
+///   dive-to-tree-child cut-through.
+/// - `CrosslinkAscend`: the chosen peer is lateral (neither ancestor nor
+///   descendant) and the destination is not in its subtree. This is a node
+///   using its *own* cross-link, learned via the peer's split-horizon
+///   advertisement to its neighbors, so it does not depend on any upward
+///   advertisement. Tracked alongside `TreeDownCross` as the lateral
+///   up-and-over contrast.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum RouteClass {
+    /// Chosen peer is our ancestor (tree-up).
+    TreeUp,
+    /// Chosen peer is our descendant and dest is in its subtree (canonical
+    /// tree-down).
+    TreeDown,
+    /// Chosen peer is our descendant but dest is *not* in its subtree: the
+    /// dive-to-tree-child cut-through enabled by upward cross-link
+    /// advertisement.
+    TreeDownCross,
+    /// Chosen peer is lateral and dest is in its subtree (subtree entry).
+    CrosslinkDescend,
+    /// Chosen peer is lateral and dest is not in its subtree (up-and-over).
+    CrosslinkAscend,
+    /// Chosen peer is the destination itself (degenerate direct hop).
+    DirectPeer,
 }
 
 impl ForwardingMetrics {
@@ -112,6 +157,21 @@ impl ForwardingMetrics {
         self.originated_bytes.add(bytes as u64);
     }
 
+    /// Record the route class of a transit-forwarded packet. The five
+    /// classes partition `forwarded_packets`, so this is called exactly
+    /// once per `record_forwarded` (transit chokepoint only).
+    #[inline]
+    pub fn record_route_class(&self, class: RouteClass) {
+        match class {
+            RouteClass::TreeUp => self.route_tree_up.inc(),
+            RouteClass::TreeDown => self.route_tree_down.inc(),
+            RouteClass::TreeDownCross => self.route_tree_down_cross.inc(),
+            RouteClass::CrosslinkDescend => self.route_crosslink_descend.inc(),
+            RouteClass::CrosslinkAscend => self.route_crosslink_ascend.inc(),
+            RouteClass::DirectPeer => self.route_direct_peer.inc(),
+        }
+    }
+
     /// Mirror of `ForwardingStats::record_reject_bytes`: route a typed
     /// forwarding rejection of `bytes` payload to its packet and byte
     /// counters.
@@ -163,6 +223,12 @@ impl ForwardingMetrics {
             drop_send_error_bytes: self.drop_send_error_bytes.get(),
             originated_packets: self.originated_packets.get(),
             originated_bytes: self.originated_bytes.get(),
+            route_tree_up: self.route_tree_up.get(),
+            route_tree_down: self.route_tree_down.get(),
+            route_tree_down_cross: self.route_tree_down_cross.get(),
+            route_crosslink_descend: self.route_crosslink_descend.get(),
+            route_crosslink_ascend: self.route_crosslink_ascend.get(),
+            route_direct_peer: self.route_direct_peer.get(),
         }
     }
 }
diff --git a/src/node/mod.rs b/src/node/mod.rs
index e9bb248..177c36c 100644
--- a/src/node/mod.rs
+++ b/src/node/mod.rs
@@ -2666,6 +2666,86 @@ impl Node {
         self.peers.get(&next_hop_id).filter(|p| p.can_send())
     }
 
+    /// Classify a transit forward by route class from tree coordinates.
+    ///
+    /// Called at the transit chokepoint after `find_next_hop` returns a peer,
+    /// so the six classes partition `forwarded_packets` exactly. The branch
+    /// that `find_next_hop` took (bloom vs greedy-tree) is *not* the route
+    /// class: a peer can be selected by either, so the cut-through splits
+    /// (`TreeDownCross`, `CrosslinkAscend`) are decided here from coordinates,
+    /// not from which branch fired.
+    ///
+    /// Inputs: our coords (`tree_state.my_coords`), the chosen peer's coords
+    /// (`tree_state.peer_coords`), and the destination coords (re-read from the
+    /// coord cache, which `find_next_hop` just touched). Both the tree-down and
+    /// cross-link branches split on whether the destination is in the chosen
+    /// peer's subtree; when the dest coords are unavailable that test defaults
+    /// to "not in subtree", i.e. the up-and-over variant (`TreeDownCross` for a
+    /// descendant peer, `CrosslinkAscend` for a lateral one).
+    pub(crate) fn classify_forward(
+        &self,
+        dest: &NodeAddr,
+        chosen_peer: &NodeAddr,
+    ) -> metrics::RouteClass {
+        // Degenerate: the next hop is the destination itself (Branch 2).
+        if chosen_peer == dest {
+            return metrics::RouteClass::DirectPeer;
+        }
+
+        let my_addr = self.node_addr();
+        let my_coords = self.tree_state.my_coords();
+
+        // Tree-up: the chosen peer is our ancestor.
+        if my_coords.has_ancestor(chosen_peer) {
+            return metrics::RouteClass::TreeUp;
+        }
+
+        // Whether the destination is in the chosen peer's subtree. Both the
+        // tree-down and cross-link splits below turn on this same test, so it
+        // is computed once. On the live transit path the dest coords are
+        // always present here: `find_next_hop` looks them up with an early
+        // return, so a coord-cache miss yields no next hop to classify (the
+        // caller signals `CoordsRequired` instead of forwarding). The miss
+        // branch below is therefore defensive — reachable only by direct
+        // unit-test calls — and defaults the test to "not in subtree", i.e.
+        // the up-and-over variant of whichever branch fires (TreeDownCross for
+        // a descendant peer, CrosslinkAscend for a lateral one), matching the
+        // original cross-link default-to-ascend.
+        let now_ms = std::time::SystemTime::now()
+            .duration_since(std::time::UNIX_EPOCH)
+            .map(|d| d.as_millis() as u64)
+            .unwrap_or(0);
+        let dest_in_peer_subtree = self
+            .coord_cache
+            .get(dest, now_ms)
+            .is_some_and(|dest_coords| dest_coords.has_ancestor(chosen_peer));
+
+        // Tree-down: the chosen peer is our descendant (we are its ancestor).
+        // Split by subtree membership: a dest genuinely below the child is the
+        // canonical tree-down; a dest *not* below it means we only forwarded
+        // down because the child advertised cross-link reach upward, beyond its
+        // own subtree — the dive-to-tree-child cut-through (TreeDownCross).
+        if let Some(peer_coords) = self.tree_state.peer_coords(chosen_peer)
+            && peer_coords.has_ancestor(my_addr)
+        {
+            return if dest_in_peer_subtree {
+                metrics::RouteClass::TreeDown
+            } else {
+                metrics::RouteClass::TreeDownCross
+            };
+        }
+
+        // Cross-link (lateral): split by whether the destination is in the
+        // chosen peer's subtree. Descend = subtree entry; ascend = up-and-over
+        // via the node's own cross-link (learned from the peer's split-horizon
+        // advertisement, independent of any upward advertisement).
+        if dest_in_peer_subtree {
+            return metrics::RouteClass::CrosslinkDescend;
+        }
+
+        metrics::RouteClass::CrosslinkAscend
+    }
+
     /// Select the best peer from a set of bloom filter candidates.
     ///
     /// Uses distance from each candidate's tree coordinates to the destination
diff --git a/src/node/stats.rs b/src/node/stats.rs
index f6db420..2315ad9 100644
--- a/src/node/stats.rs
+++ b/src/node/stats.rs
@@ -229,6 +229,12 @@ pub struct ForwardingStatsSnapshot {
     pub drop_send_error_bytes: u64,
     pub originated_packets: u64,
     pub originated_bytes: u64,
+    pub route_tree_up: u64,
+    pub route_tree_down: u64,
+    pub route_tree_down_cross: u64,
+    pub route_crosslink_descend: u64,
+    pub route_crosslink_ascend: u64,
+    pub route_direct_peer: u64,
 }
 
 #[derive(Clone, Debug, Default, Serialize)]
diff --git a/src/node/tests/routing.rs b/src/node/tests/routing.rs
index 0fb4449..4143ed9 100644
--- a/src/node/tests/routing.rs
+++ b/src/node/tests/routing.rs
@@ -1110,3 +1110,226 @@ async fn test_routing_source_only_coords_100_nodes() {
 
     cleanup_nodes(&mut nodes).await;
 }
+
+// === Route-class classification (transit-forward partition) ===
+
+use crate::node::metrics::{ForwardingMetrics, RouteClass};
+
+/// Current epoch millis, matching the cache-insert idiom used above.
+fn now_ms() -> u64 {
+    std::time::SystemTime::now()
+        .duration_since(std::time::UNIX_EPOCH)
+        .map(|d| d.as_millis() as u64)
+        .unwrap_or(0)
+}
+
+#[test]
+fn test_classify_forward_tree_up() {
+    // my_coords = [me, parent, root]; the chosen peer is our parent (an
+    // ancestor in our path) → tree-up.
+    let mut node = make_node();
+    let me = *node.node_addr();
+    let parent = make_node_addr(10);
+    let root = make_node_addr(1);
+    node.tree_state_mut()
+        .set_my_coords_for_test(TreeCoordinate::from_addrs(vec![me, parent, root]).unwrap());
+
+    // Destination somewhere above us; routed via the parent.
+    let dest = make_node_addr(50);
+    node.coord_cache_mut().insert(
+        dest,
+        TreeCoordinate::from_addrs(vec![dest, root]).unwrap(),
+        now_ms(),
+    );
+
+    assert_eq!(
+        node.classify_forward(&dest, &parent),
+        RouteClass::TreeUp,
+        "chosen peer is our ancestor"
+    );
+}
+
+#[test]
+fn test_classify_forward_tree_down() {
+    // Chosen peer is our descendant: its coords name us as an ancestor.
+    let mut node = make_node();
+    let me = *node.node_addr();
+    let root = make_node_addr(1);
+    node.tree_state_mut()
+        .set_my_coords_for_test(TreeCoordinate::from_addrs(vec![me, root]).unwrap());
+
+    let child = make_node_addr(20);
+    node.tree_state_mut().update_peer(
+        ParentDeclaration::new(child, me, 1, 1000),
+        TreeCoordinate::from_addrs(vec![child, me, root]).unwrap(),
+    );
+
+    // Destination below the child; routed down to it.
+    let dest = make_node_addr(60);
+    node.coord_cache_mut().insert(
+        dest,
+        TreeCoordinate::from_addrs(vec![dest, child, me, root]).unwrap(),
+        now_ms(),
+    );
+
+    assert_eq!(
+        node.classify_forward(&dest, &child),
+        RouteClass::TreeDown,
+        "chosen peer is our descendant, dest in its subtree"
+    );
+}
+
+#[test]
+fn test_classify_forward_tree_down_cross() {
+    // Chosen peer is our descendant (a tree child), but the destination is NOT
+    // in that child's subtree: we are diving down to the child only because it
+    // advertised cross-link reach upward, beyond its own subtree. This is the
+    // dive-to-tree-child cut-through.
+    let mut node = make_node();
+    let me = *node.node_addr();
+    let root = make_node_addr(1);
+    node.tree_state_mut()
+        .set_my_coords_for_test(TreeCoordinate::from_addrs(vec![me, root]).unwrap());
+
+    let child = make_node_addr(20);
+    node.tree_state_mut().update_peer(
+        ParentDeclaration::new(child, me, 1, 1000),
+        TreeCoordinate::from_addrs(vec![child, me, root]).unwrap(),
+    );
+
+    // Destination lives elsewhere (directly under root), NOT under the child;
+    // reachable from the child only via a cross-link.
+    let dest = make_node_addr(60);
+    node.coord_cache_mut().insert(
+        dest,
+        TreeCoordinate::from_addrs(vec![dest, root]).unwrap(),
+        now_ms(),
+    );
+
+    assert_eq!(
+        node.classify_forward(&dest, &child),
+        RouteClass::TreeDownCross,
+        "descendant peer, dest not in its subtree (dive-to-tree-child cut-through)"
+    );
+}
+
+#[test]
+fn test_classify_forward_crosslink_descend() {
+    // Chosen peer is lateral (not in our path, we are not in its path) and the
+    // destination is inside the peer's subtree → cross-link descend.
+    let mut node = make_node();
+    let me = *node.node_addr();
+    let root = make_node_addr(1);
+    let sibling_parent = make_node_addr(2);
+    node.tree_state_mut()
+        .set_my_coords_for_test(TreeCoordinate::from_addrs(vec![me, root]).unwrap());
+
+    let peer = make_node_addr(30);
+    node.tree_state_mut().update_peer(
+        ParentDeclaration::new(peer, sibling_parent, 1, 1000),
+        TreeCoordinate::from_addrs(vec![peer, sibling_parent, root]).unwrap(),
+    );
+
+    // Destination is under the cross-link peer.
+    let dest = make_node_addr(70);
+    node.coord_cache_mut().insert(
+        dest,
+        TreeCoordinate::from_addrs(vec![dest, peer, sibling_parent, root]).unwrap(),
+        now_ms(),
+    );
+
+    assert_eq!(
+        node.classify_forward(&dest, &peer),
+        RouteClass::CrosslinkDescend,
+        "lateral peer, dest in its subtree"
+    );
+}
+
+#[test]
+fn test_classify_forward_crosslink_ascend() {
+    // Chosen peer is lateral and the destination is NOT in its subtree → the
+    // up-and-over case (the Bloom v2 behavior delta).
+    let mut node = make_node();
+    let me = *node.node_addr();
+    let root = make_node_addr(1);
+    let sibling_parent = make_node_addr(2);
+    node.tree_state_mut()
+        .set_my_coords_for_test(TreeCoordinate::from_addrs(vec![me, root]).unwrap());
+
+    let peer = make_node_addr(40);
+    node.tree_state_mut().update_peer(
+        ParentDeclaration::new(peer, sibling_parent, 1, 1000),
+        TreeCoordinate::from_addrs(vec![peer, sibling_parent, root]).unwrap(),
+    );
+
+    // Destination lives elsewhere (under root directly), NOT under the peer.
+    let dest = make_node_addr(80);
+    node.coord_cache_mut().insert(
+        dest,
+        TreeCoordinate::from_addrs(vec![dest, root]).unwrap(),
+        now_ms(),
+    );
+
+    assert_eq!(
+        node.classify_forward(&dest, &peer),
+        RouteClass::CrosslinkAscend,
+        "lateral peer, dest not in its subtree"
+    );
+}
+
+#[test]
+fn test_classify_forward_direct_peer() {
+    // Degenerate case: the next hop is the destination itself.
+    let mut node = make_node();
+    let me = *node.node_addr();
+    let root = make_node_addr(1);
+    node.tree_state_mut()
+        .set_my_coords_for_test(TreeCoordinate::from_addrs(vec![me, root]).unwrap());
+
+    let dest = make_node_addr(90);
+    assert_eq!(
+        node.classify_forward(&dest, &dest),
+        RouteClass::DirectPeer,
+        "next hop is the destination"
+    );
+}
+
+#[test]
+fn test_route_class_partition_sums_to_forwarded() {
+    // The six route classes partition forwarded_packets: bumping
+    // record_forwarded once per record_route_class keeps the sum of the class
+    // counters equal to forwarded_packets.
+    let m = ForwardingMetrics::default();
+    let classes = [
+        RouteClass::TreeUp,
+        RouteClass::TreeUp,
+        RouteClass::TreeDown,
+        RouteClass::TreeDownCross,
+        RouteClass::TreeDownCross,
+        RouteClass::CrosslinkDescend,
+        RouteClass::CrosslinkAscend,
+        RouteClass::CrosslinkAscend,
+        RouteClass::CrosslinkAscend,
+        RouteClass::DirectPeer,
+    ];
+    for &c in &classes {
+        m.record_forwarded(100);
+        m.record_route_class(c);
+    }
+
+    let snap = m.snapshot();
+    let class_sum = snap.route_tree_up
+        + snap.route_tree_down
+        + snap.route_tree_down_cross
+        + snap.route_crosslink_descend
+        + snap.route_crosslink_ascend
+        + snap.route_direct_peer;
+    assert_eq!(
+        class_sum, snap.forwarded_packets,
+        "route classes must partition forwarded_packets"
+    );
+    assert_eq!(snap.route_tree_up, 2);
+    assert_eq!(snap.route_tree_down_cross, 2);
+    assert_eq!(snap.route_crosslink_ascend, 3);
+    assert_eq!(snap.route_direct_peer, 1);
+}
diff --git a/src/tree/state.rs b/src/tree/state.rs
index 274db56..0641865 100644
--- a/src/tree/state.rs
+++ b/src/tree/state.rs
@@ -92,6 +92,15 @@ impl TreeState {
         &self.my_coords
     }
 
+    /// Test-only override of this node's coordinates, bypassing the
+    /// parent/declaration state machine. Lets routing tests place the node at
+    /// an arbitrary tree position to exercise coordinate-based classification.
+    #[cfg(test)]
+    pub(crate) fn set_my_coords_for_test(&mut self, coords: TreeCoordinate) {
+        self.root = *coords.root_id();
+        self.my_coords = coords;
+    }
+
     /// Get the current root.
     pub fn root(&self) -> &NodeAddr {
         &self.root