diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 55b6ea4..c925570 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -35,6 +35,13 @@ on: required: false BF_AUTOIMPORT_API_KEY: required: false + # RAUC bundle signing — generated once via scripts/gen-rauc-signing-keys.sh + # and uploaded to the GH repo secrets. Without both, the OS-bundle step + # is skipped; .img.xz still ships as a release asset for manual flashing. + BF_RAUC_SIGNING_CERT: + required: false + BF_RAUC_SIGNING_KEY: + required: false permissions: contents: write @@ -219,3 +226,71 @@ jobs: with: tag_name: ${{ inputs.tag }} files: ${{ steps.pigen.outputs.image-path }} + + # ---- RAUC bundle (OS OTA) -------------------------------------------- + # Build a signed .raucb bundle from the same partitions baked into the + # .img.xz. Kiosks fetch this from /api/kiosk/os/check + rauc install it + # into the inactive A/B slot. Skipped when signing secrets aren't set + # (image still ships for manual flashing). + - name: Build RAUC bundle + id: raucb + if: ${{ secrets.BF_RAUC_SIGNING_CERT != '' && secrets.BF_RAUC_SIGNING_KEY != '' }} + env: + BF_RAUC_SIGNING_CERT: ${{ secrets.BF_RAUC_SIGNING_CERT }} + BF_RAUC_SIGNING_KEY: ${{ secrets.BF_RAUC_SIGNING_KEY }} + run: | + set -e + sudo apt-get update + sudo apt-get install -y --no-install-recommends rauc e2fsprogs xz-utils util-linux openssl + mkdir -p /tmp/rauc-signing + chmod 700 /tmp/rauc-signing + printf '%s\n' "$BF_RAUC_SIGNING_CERT" > /tmp/rauc-signing/cert.pem + printf '%s\n' "$BF_RAUC_SIGNING_KEY" > /tmp/rauc-signing/key.pem + chmod 600 /tmp/rauc-signing/key.pem + chmod +x deploy/rauc/build-bundle.sh + out="${{ github.workspace }}/betterframe-${{ inputs.version }}.raucb" + deploy/rauc/build-bundle.sh \ + "${{ steps.pigen.outputs.image-path }}" \ + "$out" \ + "${{ inputs.version }}" \ + "${{ github.sha }}" \ + /tmp/rauc-signing/cert.pem \ + /tmp/rauc-signing/key.pem + rm -rf /tmp/rauc-signing + echo "bundle-path=$out" >> "$GITHUB_OUTPUT" + echo "bundle-sha256=$(sha256sum "$out" | cut -d' ' -f1)" >> "$GITHUB_OUTPUT" + + - name: Upload RAUC bundle to GitHub Release + if: ${{ steps.raucb.outputs.bundle-path != '' }} + uses: softprops/action-gh-release@v3 + with: + tag_name: ${{ inputs.tag }} + files: ${{ steps.raucb.outputs.bundle-path }} + + # Auto-import to BF server. Mirrors the kiosk-binary auto-import step. + # Skipped if BF_AUTOIMPORT_* secrets are missing OR no bundle was built. + - name: Auto-import OS bundle into BF server + if: ${{ steps.raucb.outputs.bundle-path != '' && secrets.BF_AUTOIMPORT_URL != '' && secrets.BF_AUTOIMPORT_API_KEY != '' }} + env: + BF_AUTOIMPORT_URL: ${{ secrets.BF_AUTOIMPORT_URL }} + BF_AUTOIMPORT_API_KEY: ${{ secrets.BF_AUTOIMPORT_API_KEY }} + run: | + set -e + tag="${{ inputs.tag }}" + repo="${{ github.repository }}" + # Direct GH release asset URL — server downloads from here. + asset_name="$(basename "${{ steps.raucb.outputs.bundle-path }}")" + asset_url="https://github.com/${repo}/releases/download/${tag}/${asset_name}" + payload="$(jq -nc \ + --arg v "${{ inputs.version }}" \ + --arg c "${{ inputs.channel }}" \ + --arg compat "betterframe-rpi5-aarch64" \ + --arg sha "${{ steps.raucb.outputs.bundle-sha256 }}" \ + --arg url "$asset_url" \ + --arg n "GitHub Actions build of ${{ inputs.tag }} (${{ github.sha }})" \ + '{version:$v, channel:$c, compatibility:$compat, source_url:$url, sha256:$sha, release_notes:$n}')" + curl -sSf -X POST \ + -H "Authorization: Bearer ${BF_AUTOIMPORT_API_KEY}" \ + -H "Content-Type: application/json" \ + -d "$payload" \ + "${BF_AUTOIMPORT_URL}/api/admin/os/import" diff --git a/deploy/rauc/build-bundle.sh b/deploy/rauc/build-bundle.sh new file mode 100755 index 0000000..34cefb6 --- /dev/null +++ b/deploy/rauc/build-bundle.sh @@ -0,0 +1,109 @@ +#!/usr/bin/env bash +# Build a signed RAUC .raucb bundle from a pi-gen-produced .img.xz. +# +# Usage: +# build-bundle.sh \ +# +# +# Approach: decompress the .img.xz, identify its bootfs (vfat) + rootfs +# (ext4) partitions via sfdisk, dd them into bundle-staging/ as +# bootfs.vfat + rootfs.ext4, render the manifest template with version +# + git sha, then `rauc bundle --cert= --key= staging out.raucb`. +# +# We use the FAT and ext4 partitions from a stock pi-gen image — i.e. the +# bundle content matches what's on a freshly-flashed kiosk. The TARGET +# device still needs an A/B partition layout for RAUC to actually install +# (separate workstream); a bundle built today is only consumable by +# kiosks already running the A/B layout. +set -euo pipefail + +IN_IMG_XZ="${1:?input .img.xz required}" +OUT_RAUCB="${2:?output .raucb path required}" +VERSION="${3:?version required}" +GIT_SHA="${4:?git sha required}" +SIGNING_CERT="${5:?signing cert path required}" +SIGNING_KEY="${6:?signing key path required}" + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +MANIFEST_IN="${SCRIPT_DIR}/manifest.raucm.in" + +WORK_DIR="$(mktemp -d)" +trap 'rm -rf "$WORK_DIR"' EXIT + +echo "==> Decompressing $IN_IMG_XZ" +RAW_IMG="${WORK_DIR}/image.img" +xz -d -c "$IN_IMG_XZ" > "$RAW_IMG" + +echo "==> Reading partition table" +# sfdisk -d emits: : start=N, size=N, type=X, name=... +# pi-gen layout: p1 = bootfs (vfat, type=c), p2 = rootfs (ext4, type=83) +BOOT_INFO="$(sfdisk -d "$RAW_IMG" | awk '/img1/ || /img.*: start/ {print}')" +ROOT_INFO="$(sfdisk -d "$RAW_IMG" | awk '/img2/ || /img.*: start/ {print}')" + +# Robust parse: walk partition lines, identify by type code. +parse_part() { + local part_idx="$1" + sfdisk -d "$RAW_IMG" \ + | awk -v idx="$part_idx" ' + /: start=/ { + n++; + if (n == idx) { + for (i = 1; i <= NF; i++) { + if ($i ~ /start=/) { gsub(/[^0-9]/, "", $i); start = $i } + if ($i ~ /size=/) { gsub(/[^0-9]/, "", $i); size = $i } + } + print start, size; + exit + } + }' +} + +read BOOT_START BOOT_SIZE < <(parse_part 1) +read ROOT_START ROOT_SIZE < <(parse_part 2) +if [ -z "${BOOT_START:-}" ] || [ -z "${ROOT_START:-}" ]; then + echo "could not parse pi-gen partition table — expected 2 partitions" >&2 + sfdisk -d "$RAW_IMG" + exit 1 +fi + +echo " bootfs: start=$BOOT_START size=$BOOT_SIZE sectors (512B each)" +echo " rootfs: start=$ROOT_START size=$ROOT_SIZE sectors (512B each)" + +STAGE="${WORK_DIR}/bundle" +mkdir -p "$STAGE" + +echo "==> Extracting bootfs.vfat" +dd if="$RAW_IMG" of="${STAGE}/bootfs.vfat" \ + bs=512 skip="$BOOT_START" count="$BOOT_SIZE" status=none + +echo "==> Extracting rootfs.ext4" +dd if="$RAW_IMG" of="${STAGE}/rootfs.ext4" \ + bs=512 skip="$ROOT_START" count="$ROOT_SIZE" status=none + +# Shrink the rootfs to actual used space so bundles don't ship empty bytes. +# pi-gen's export-image already does this, but verify file integrity first. +echo "==> Checking rootfs.ext4 integrity" +e2fsck -fy "${STAGE}/rootfs.ext4" || true # tolerate "clean but old fs version" warnings + +echo "==> Rendering manifest" +sed -e "s|@VERSION@|${VERSION}|g" \ + -e "s|@GIT_SHA@|${GIT_SHA}|g" \ + "$MANIFEST_IN" > "${STAGE}/manifest.raucm" + +echo "==> Bundle staging contents" +ls -la "$STAGE" +cat "${STAGE}/manifest.raucm" + +echo "==> Building RAUC bundle" +rm -f "$OUT_RAUCB" +rauc bundle \ + --cert="$SIGNING_CERT" \ + --key="$SIGNING_KEY" \ + "$STAGE" "$OUT_RAUCB" + +echo "==> Verifying bundle (uses the signing cert as its own trust anchor)" +rauc info --keyring="$SIGNING_CERT" "$OUT_RAUCB" + +echo +echo "==> Bundle written: $OUT_RAUCB" +ls -la "$OUT_RAUCB" diff --git a/scripts/gen-rauc-signing-keys.sh b/scripts/gen-rauc-signing-keys.sh new file mode 100755 index 0000000..8076175 --- /dev/null +++ b/scripts/gen-rauc-signing-keys.sh @@ -0,0 +1,59 @@ +#!/usr/bin/env bash +# Generate the RAUC signing-cert PAIR used to sign OS bundles. Run ONCE per +# deployment; ALL kiosks built afterward must embed the matching CA cert +# in /etc/rauc/keyring.pem to accept bundles signed with this key. +# +# Outputs (written to ./rauc-signing/, .gitignored): +# ca-cert.pem — embed in kiosk image at /etc/rauc/keyring.pem +# ca-key.pem — KEEP OFFLINE. Only used to issue new signing certs. +# signing-cert.pem — committed to GitHub Actions secret BF_RAUC_SIGNING_CERT +# signing-key.pem — committed to GitHub Actions secret BF_RAUC_SIGNING_KEY +# +# RAUC accepts any OpenSSL-supported key inside an X.509 cert. We use +# Ed25519 because the cert chain stays small and verification is fast on +# the Pi. CA cert is self-signed; signing cert is issued by the CA. If +# the signing cert is ever leaked, revoke by rotating it under the same +# CA — kiosks don't need a re-flash, only a CRL update (future work). +set -euo pipefail + +OUT_DIR="${1:-./rauc-signing}" +mkdir -p "$OUT_DIR" +cd "$OUT_DIR" + +if [ -f ca-cert.pem ]; then + echo "refusing to overwrite existing keys at $OUT_DIR — delete first if intentional" + exit 1 +fi + +echo "==> Generating CA (Ed25519, 10 year validity)" +openssl genpkey -algorithm ED25519 -out ca-key.pem +openssl req -new -x509 -days 3650 -key ca-key.pem \ + -subj "/CN=BetterFrame RAUC CA" -out ca-cert.pem + +echo "==> Generating signing cert (Ed25519, 2 year validity)" +openssl genpkey -algorithm ED25519 -out signing-key.pem +openssl req -new -key signing-key.pem \ + -subj "/CN=BetterFrame RAUC Signing" -out signing.csr +openssl x509 -req -in signing.csr -CA ca-cert.pem -CAkey ca-key.pem \ + -CAcreateserial -days 730 -out signing-cert.pem +rm -f signing.csr ca-cert.srl + +chmod 600 ca-key.pem signing-key.pem + +cat < Done. Next steps: + +1. Embed CA cert in the image at /etc/rauc/keyring.pem + (commit ca-cert.pem to repo; pi-gen stage will install it). + +2. Set GitHub Actions secrets (Settings → Secrets → Actions): + BF_RAUC_SIGNING_CERT = $(realpath signing-cert.pem) contents + BF_RAUC_SIGNING_KEY = $(realpath signing-key.pem) contents + +3. STORE ca-key.pem OFFLINE. It's used only to issue replacement signing + certs if the live signing key is rotated. Treat like a root password. + +Files: + $(ls -la) +EOF