feat(os-ota): build + sign + auto-import .raucb bundles in CI

Phase 1 of the OS OTA pipeline. Three pieces:

scripts/gen-rauc-signing-keys.sh — one-shot helper that issues an
Ed25519 X.509 CA + signing cert pair. Operator runs locally, commits
the CA cert (for embedding in kiosk image at /etc/rauc/keyring.pem),
stores the signing pair as GitHub Actions secrets
(BF_RAUC_SIGNING_CERT + BF_RAUC_SIGNING_KEY), keeps the CA private
key offline. RAUC verifies bundles against the keyring in the image.

deploy/rauc/build-bundle.sh — takes the pi-gen .img.xz, parses its
partition table with sfdisk, dd-extracts bootfs (vfat) + rootfs
(ext4) into a staging dir, renders manifest.raucm.in with version
+ git sha, runs `rauc bundle --cert= --key=` to produce a signed
.raucb. Verifies the bundle round-trips with `rauc info`.

build.yml gains two gated steps:
  - "Build RAUC bundle": runs only when both signing secrets are set,
    uploads .raucb as a release asset alongside the .img.xz.
  - "Auto-import OS bundle into BF server": POSTs the GH release asset
    URL to ${BF_AUTOIMPORT_URL}/api/admin/os/import so the server
    pulls + stores the bundle. Mirrors the kiosk-binary auto-import
    flow that already worked.

Compatibility string is `betterframe-rpi5-aarch64` (matches the value
already declared in deploy/rauc/system.conf). Channel passed through
from inputs (dev for master pushes, stable/beta for tags).

What's NOT in this commit:
  - Pi image A/B partition layout (custom genimage / pi-gen patch)
  - rauc package install + keyring drop in pi-gen stage
  - Kiosk-side os_update.rs Rust consumer that polls /api/kiosk/os/check
  - Admin UI for releases + rollouts

A bundle built today reaches /api/admin/os/import on the server but
isn't installable yet — kiosks have no consumer and no A/B layout.
That's the next 3 phases. Bundle production needs to be solid first
so the kiosk side can be tested against real artifacts.
This commit is contained in:
Mitchell R 2026-05-21 10:44:24 +02:00
parent 334ee8fb93
commit 084c119c44
No known key found for this signature in database
3 changed files with 243 additions and 0 deletions

View file

@ -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"

109
deploy/rauc/build-bundle.sh Executable file
View file

@ -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 <input.img.xz> <output.raucb> <version> <git_sha> \
# <signing_cert.pem> <signing_key.pem>
#
# 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: <device>: 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"

View file

@ -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 <<EOF
==> 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