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] 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