From 87cde933164ae3e9ddf7d0a8cff28cab5135bf5c Mon Sep 17 00:00:00 2001 From: Mitchell R Date: Wed, 20 May 2026 05:15:29 +0200 Subject: [PATCH] feat(ota): add RAUC OS update foundation --- .github/workflows/build.yml | 6 + .../01-install-kiosk/01-run-chroot.sh | 7 + deploy/rauc/betterframe-rauc-boot.sh | 127 ++++++++++ deploy/rauc/manifest.raucm.in | 16 ++ deploy/rauc/system.conf | 33 +++ deploy/scripts/setup-pi-kiosk.sh | 8 + .../betterframe-rauc-mark-good.service | 15 ++ deploy/systemd/betterframe-rauc-mark-good.sh | 20 ++ deploy/tmpfiles/betterframe-kiosk.conf | 1 + docs/full-os-ota.md | 71 ++++++ kiosk/Cargo.lock | 234 ++++++++++++++++++ kiosk/src/server.rs | 8 +- kiosk/src/ui.rs | 20 +- 13 files changed, 559 insertions(+), 7 deletions(-) create mode 100644 deploy/rauc/betterframe-rauc-boot.sh create mode 100644 deploy/rauc/manifest.raucm.in create mode 100644 deploy/rauc/system.conf create mode 100644 deploy/systemd/betterframe-rauc-mark-good.service create mode 100644 deploy/systemd/betterframe-rauc-mark-good.sh create mode 100644 deploy/tmpfiles/betterframe-kiosk.conf create mode 100644 docs/full-os-ota.md diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 00f1104..32f7819 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -168,6 +168,12 @@ jobs: deploy/pi-gen/stage-betterframe-client/01-install-kiosk/files/ cp deploy/systemd/betterframe-firmware-rollback.sh \ deploy/pi-gen/stage-betterframe-client/01-install-kiosk/files/ + cp deploy/systemd/betterframe-rauc-mark-good.service \ + deploy/pi-gen/stage-betterframe-client/01-install-kiosk/files/ + cp deploy/systemd/betterframe-rauc-mark-good.sh \ + deploy/pi-gen/stage-betterframe-client/01-install-kiosk/files/ + cp deploy/tmpfiles/betterframe-kiosk.conf \ + deploy/pi-gen/stage-betterframe-client/01-install-kiosk/files/ cp deploy/pam.d/cage \ deploy/pi-gen/stage-betterframe-client/01-install-kiosk/files/cage.pam cp deploy/plymouth/betterframe/betterframe.plymouth \ diff --git a/deploy/pi-gen/stage-betterframe-client/01-install-kiosk/01-run-chroot.sh b/deploy/pi-gen/stage-betterframe-client/01-install-kiosk/01-run-chroot.sh index af9ad84..c926d8d 100755 --- a/deploy/pi-gen/stage-betterframe-client/01-install-kiosk/01-run-chroot.sh +++ b/deploy/pi-gen/stage-betterframe-client/01-install-kiosk/01-run-chroot.sh @@ -22,6 +22,12 @@ install -m 644 /tmp/bf-files/betterframe-kiosk.service /etc/systemd/system/bette install -m 644 /tmp/bf-files/cage.pam /etc/pam.d/cage install -m 755 /tmp/bf-files/betterframe-firmware-rollback.sh \ /usr/local/sbin/betterframe-firmware-rollback.sh +install -m 644 /tmp/bf-files/betterframe-rauc-mark-good.service \ + /etc/systemd/system/betterframe-rauc-mark-good.service +install -m 755 /tmp/bf-files/betterframe-rauc-mark-good.sh \ + /usr/local/sbin/betterframe-rauc-mark-good.sh +install -d -m 755 /etc/tmpfiles.d +install -m 644 /tmp/bf-files/betterframe-kiosk.conf /etc/tmpfiles.d/betterframe-kiosk.conf # Default env file — operator may edit on first boot to point at their server. cat > /etc/default/betterframe-kiosk <<'EOF' @@ -41,6 +47,7 @@ plymouth-set-default-theme betterframe || true # --- Enable services, disable noise --- systemctl enable seatd systemctl enable betterframe-kiosk.service +systemctl enable betterframe-rauc-mark-good.service # Boot to multi-user, no display manager, no welcome wizard, no getty on tty1. systemctl set-default multi-user.target diff --git a/deploy/rauc/betterframe-rauc-boot.sh b/deploy/rauc/betterframe-rauc-boot.sh new file mode 100644 index 0000000..7b5e1ba --- /dev/null +++ b/deploy/rauc/betterframe-rauc-boot.sh @@ -0,0 +1,127 @@ +#!/usr/bin/env bash +set -euo pipefail + +SELECTOR_DEV="${BF_RAUC_SELECTOR_DEV:-/dev/disk/by-partlabel/BF_BOOTSEL}" +STATE_DIR="${BF_RAUC_STATE_DIR:-/var/lib/rauc/betterframe}" +STATE_FILE="${STATE_DIR}/slot-state" + +slot_to_part() { + case "$1" in + A) printf '2' ;; + B) printf '3' ;; + *) exit 2 ;; + esac +} + +part_to_slot() { + case "$1" in + 2) printf 'A' ;; + 3) printf 'B' ;; + *) exit 2 ;; + esac +} + +other_slot() { + case "$1" in + A) printf 'B' ;; + B) printf 'A' ;; + *) exit 2 ;; + esac +} + +read_current_slot() { + local part_file="/proc/device-tree/chosen/bootloader/partition" + if [ -r "$part_file" ]; then + local part + part="$(tr -d '\000\n\r ' < "$part_file")" + case "$part" in + 2|3) printf '%s' "$part"; return ;; + *) exit 2 ;; + esac + fi + exit 2 +} + +with_selector_mounted() { + local fn="$1" + shift + local mountpoint + mountpoint="$(mktemp -d)" + mount "$SELECTOR_DEV" "$mountpoint" + set +e + "$fn" "$mountpoint" "$@" + local rc=$? + set -e + umount "$mountpoint" + rmdir "$mountpoint" + return "$rc" +} + +get_primary_from_mount() { + local mountpoint="$1" + awk ' + /^\[all\]$/ { in_all=1; next } + /^\[/ { in_all=0; next } + in_all && /^boot_partition=/ { sub(/^boot_partition=/, ""); print; exit } + ' "${mountpoint}/autoboot.txt" +} + +write_primary_to_mount() { + local mountpoint="$1" + local primary_slot="$2" + local primary_part try_part + primary_part="$(slot_to_part "$primary_slot")" + try_part="$(slot_to_part "$(other_slot "$primary_slot")")" + cat > "${mountpoint}/autoboot.txt" </dev/null || sync +} + +get_primary() { + local part + part="$(with_selector_mounted get_primary_from_mount)" + part_to_slot "$part" +} + +set_primary() { + with_selector_mounted write_primary_to_mount "$1" +} + +get_state() { + local slot="$1" + if [ -f "$STATE_FILE" ] && grep -qx "${slot}=bad" "$STATE_FILE"; then + printf 'bad\n' + else + printf 'good\n' + fi +} + +set_state() { + local slot="$1" + local state="$2" + mkdir -p "$STATE_DIR" + if [ "$state" = "bad" ]; then + grep -vx "${slot}=good" "$STATE_FILE" 2>/dev/null | grep -vx "${slot}=bad" > "${STATE_FILE}.tmp" || true + printf '%s=bad\n' "$slot" >> "${STATE_FILE}.tmp" + mv "${STATE_FILE}.tmp" "$STATE_FILE" + else + grep -vx "${slot}=bad" "$STATE_FILE" 2>/dev/null > "${STATE_FILE}.tmp" || true + mv "${STATE_FILE}.tmp" "$STATE_FILE" + fi +} + +case "${1:-}" in + get-primary) get_primary ;; + set-primary) set_primary "${2:?slot required}" ;; + get-state) get_state "${2:?slot required}" ;; + set-state) set_state "${2:?slot required}" "${3:?state required}" ;; + get-current) part_to_slot "$(read_current_slot)" ;; + *) exit 2 ;; +esac diff --git a/deploy/rauc/manifest.raucm.in b/deploy/rauc/manifest.raucm.in new file mode 100644 index 0000000..410fe76 --- /dev/null +++ b/deploy/rauc/manifest.raucm.in @@ -0,0 +1,16 @@ +[update] +compatible=betterframe-rpi5-aarch64 +version=@VERSION@ +description=BetterFrame OS @VERSION@ +build=@GIT_SHA@ + +[bundle] +format=verity + +[image.bootfs] +filename=bootfs.vfat +type=image + +[image.rootfs] +filename=rootfs.ext4 +type=image diff --git a/deploy/rauc/system.conf b/deploy/rauc/system.conf new file mode 100644 index 0000000..afe2a5c --- /dev/null +++ b/deploy/rauc/system.conf @@ -0,0 +1,33 @@ +[system] +compatible=betterframe-rpi5-aarch64 +bootloader=custom +data-directory=/var/lib/rauc +bundle-formats=verity +boot-attempts=3 +boot-attempts-primary=3 + +[keyring] +path=/etc/rauc/keyring.pem + +[handlers] +bootloader-custom-backend=/usr/local/sbin/betterframe-rauc-boot.sh + +[slot.bootfs.0] +device=/dev/disk/by-partlabel/BF_BOOT_A +type=vfat +bootname=A + +[slot.rootfs.0] +device=/dev/disk/by-partlabel/BF_ROOT_A +type=ext4 +parent=bootfs.0 + +[slot.bootfs.1] +device=/dev/disk/by-partlabel/BF_BOOT_B +type=vfat +bootname=B + +[slot.rootfs.1] +device=/dev/disk/by-partlabel/BF_ROOT_B +type=ext4 +parent=bootfs.1 diff --git a/deploy/scripts/setup-pi-kiosk.sh b/deploy/scripts/setup-pi-kiosk.sh index 28a3b1a..e1b7758 100755 --- a/deploy/scripts/setup-pi-kiosk.sh +++ b/deploy/scripts/setup-pi-kiosk.sh @@ -235,6 +235,13 @@ if [ "${INSTALL_KIOSK}" = "1" ]; then /etc/systemd/system/betterframe-kiosk.service install -m 755 "${REPO_ROOT}/deploy/systemd/betterframe-firmware-rollback.sh" \ /usr/local/sbin/betterframe-firmware-rollback.sh + install -m 644 "${REPO_ROOT}/deploy/systemd/betterframe-rauc-mark-good.service" \ + /etc/systemd/system/betterframe-rauc-mark-good.service + install -m 755 "${REPO_ROOT}/deploy/systemd/betterframe-rauc-mark-good.sh" \ + /usr/local/sbin/betterframe-rauc-mark-good.sh + install -d -m 755 /etc/tmpfiles.d + install -m 644 "${REPO_ROOT}/deploy/tmpfiles/betterframe-kiosk.conf" \ + /etc/tmpfiles.d/betterframe-kiosk.conf if [ ! -e /etc/default/betterframe-kiosk ]; then cat > /etc/default/betterframe-kiosk <<'EOF' @@ -245,6 +252,7 @@ EOF systemctl daemon-reload systemctl enable betterframe-kiosk.service + systemctl enable betterframe-rauc-mark-good.service # Restart picks up new binary on re-run. systemctl restart betterframe-kiosk.service || true diff --git a/deploy/systemd/betterframe-rauc-mark-good.service b/deploy/systemd/betterframe-rauc-mark-good.service new file mode 100644 index 0000000..0545c18 --- /dev/null +++ b/deploy/systemd/betterframe-rauc-mark-good.service @@ -0,0 +1,15 @@ +[Unit] +Description=Mark BetterFrame RAUC slot good after kiosk heartbeat +Documentation=https://github.com/BetterCorp/BetterFrame +After=betterframe-kiosk.service network-online.target +Wants=betterframe-kiosk.service network-online.target +ConditionPathExists=/etc/rauc/system.conf +ConditionPathExists=/usr/bin/rauc + +[Service] +Type=oneshot +ExecStart=/usr/local/sbin/betterframe-rauc-mark-good.sh +RemainAfterExit=yes + +[Install] +WantedBy=multi-user.target diff --git a/deploy/systemd/betterframe-rauc-mark-good.sh b/deploy/systemd/betterframe-rauc-mark-good.sh new file mode 100644 index 0000000..19055c3 --- /dev/null +++ b/deploy/systemd/betterframe-rauc-mark-good.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +set -euo pipefail + +MARKER="/run/betterframe/kiosk-healthy" +TIMEOUT="${BF_RAUC_MARK_GOOD_TIMEOUT:-300}" + +if ! command -v rauc >/dev/null 2>&1; then + exit 0 +fi + +deadline=$(($(date +%s) + TIMEOUT)) +while [ "$(date +%s)" -lt "$deadline" ]; do + if [ -s "$MARKER" ]; then + exec rauc status mark-good + fi + sleep 2 +done + +echo "[betterframe-rauc-mark-good] kiosk health marker did not appear within ${TIMEOUT}s" >&2 +exit 1 diff --git a/deploy/tmpfiles/betterframe-kiosk.conf b/deploy/tmpfiles/betterframe-kiosk.conf new file mode 100644 index 0000000..2fb9a1a --- /dev/null +++ b/deploy/tmpfiles/betterframe-kiosk.conf @@ -0,0 +1 @@ +d /run/betterframe 0755 bfkiosk bfkiosk - diff --git a/docs/full-os-ota.md b/docs/full-os-ota.md new file mode 100644 index 0000000..501d940 --- /dev/null +++ b/docs/full-os-ota.md @@ -0,0 +1,71 @@ +# BetterFrame Full OS OTA + +BetterFrame field devices must use full-image A/B OTA for OS, package, +kernel, firmware, GTK/WebKit/GStreamer, and kiosk runtime changes. App-only +binary replacement is not sufficient for production field deployments. +The legacy kiosk binary updater is gated behind `BF_ENABLE_APP_OTA=1` and +should stay disabled in production. + +## Target Design + +- Update engine: RAUC. +- Boot selection: Raspberry Pi firmware `autoboot.txt` / `tryboot` via a + BetterFrame RAUC custom bootloader backend. +- Bundle format: RAUC `verity`. +- Device compatibility: `betterframe-rpi5-aarch64`. +- Slot layout: + - `BF_BOOTSEL`: small FAT partition containing only `autoboot.txt`. + - `BF_BOOT_A`: FAT boot files for slot A. + - `BF_ROOT_A`: ext4 root filesystem for slot A. + - `BF_BOOT_B`: FAT boot files for slot B. + - `BF_ROOT_B`: ext4 root filesystem for slot B. + - `BF_DATA`: persistent ext4 data partition for pairing state, logs, + RAUC state, and local kiosk cache. + +## Safety Flow + +1. Kiosk checks the BetterFrame server for an OS bundle matching channel, + rollout, architecture, and current OS version. +2. Device installs the signed RAUC bundle to the inactive slot. +3. RAUC marks the inactive slot as primary. +4. Device reboots using Pi `tryboot`. +5. If the new slot fails to boot, Pi firmware falls back to the previous + normal slot. +6. If the new slot boots and the kiosk successfully heartbeats to the server, + `betterframe-rauc-mark-good.service` runs `rauc status mark-good`. + +## Signing + +RAUC uses X.509 bundle signing. The public certificate must be baked into the +image as `/etc/rauc/keyring.pem`. The private key is a CI secret used only to +produce `.raucb` bundles. + +Generate a production keypair: + +```bash +openssl req -x509 -newkey rsa:4096 -nodes \ + -keyout betterframe-rauc.key.pem \ + -out betterframe-rauc.cert.pem \ + -days 3650 \ + -subj "/CN=BetterFrame RAUC Production/" +``` + +Required CI/Coolify secrets for full OS OTA: + +- `BF_RAUC_CERT_PEM`: public certificate, baked into the client image. +- `BF_RAUC_KEY_PEM`: private signing key, used only by GitHub Actions. +- `BF_AUTOIMPORT_URL`: BetterFrame server public base URL. +- `BF_AUTOIMPORT_API_KEY`: import token matching server + `BF_FIRMWARE_IMPORT_API_KEY`. + +## Current State + +This repository now contains the RAUC target config and boot backend +scaffolding. The remaining production work is: + +1. Replace the single-root pi-gen output with a GPT A/B image layout. +2. Generate `.raucb` bundles from the same boot/root artifacts used for the + flashable image. +3. Add server-side OS release metadata separate from app-binary firmware. +4. Add kiosk-side OS update polling/install orchestration. +5. Lock down local input devices and mutable OS paths. diff --git a/kiosk/Cargo.lock b/kiosk/Cargo.lock index c58551e..52704e4 100644 --- a/kiosk/Cargo.lock +++ b/kiosk/Cargo.lock @@ -38,6 +38,17 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "atomic-waker" version = "1.1.2" @@ -56,29 +67,97 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "axum" +version = "0.7.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f" +dependencies = [ + "async-trait", + "axum-core", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "rustversion", + "serde", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199" +dependencies = [ + "async-trait", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "rustversion", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + [[package]] name = "base64" version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + [[package]] name = "betterframe-kiosk" version = "0.1.0" dependencies = [ + "axum", + "base64", "dirs", + "ed25519-dalek", "futures-util", "gpiod", "gst-plugin-gtk4", "gstreamer", "gstreamer-video", "gtk4", + "hex", "hostname", + "rand", "reqwest", "serde", "serde_json", + "sha2", "tokio", "tokio-tungstenite", + "tower", "tracing", "tracing-subscriber", "url", @@ -193,6 +272,12 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + [[package]] name = "core-foundation" version = "0.9.4" @@ -244,12 +329,50 @@ dependencies = [ "typenum", ] +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures", + "curve25519-dalek-derive", + "digest", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "data-encoding" version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "pem-rfc7468", + "zeroize", +] + [[package]] name = "digest" version = "0.10.7" @@ -292,6 +415,30 @@ dependencies = [ "syn", ] +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "serde", + "sha2", + "subtle", + "zeroize", +] + [[package]] name = "either" version = "1.15.0" @@ -350,6 +497,12 @@ version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + [[package]] name = "field-offset" version = "0.3.6" @@ -992,6 +1145,12 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + [[package]] name = "hostname" version = "0.4.2" @@ -1042,6 +1201,12 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + [[package]] name = "hyper" version = "1.9.0" @@ -1056,6 +1221,7 @@ dependencies = [ "http", "http-body", "httparse", + "httpdate", "itoa", "pin-project-lite", "smallvec", @@ -1374,6 +1540,12 @@ dependencies = [ "regex-automata", ] +[[package]] +name = "matchit" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" + [[package]] name = "memchr" version = "2.8.0" @@ -1577,6 +1749,15 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -1589,6 +1770,16 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + [[package]] name = "pkg-config" version = "0.3.33" @@ -1918,6 +2109,17 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + [[package]] name = "serde_spanned" version = "1.1.1" @@ -1950,6 +2152,17 @@ dependencies = [ "digest", ] +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "sharded-slab" version = "0.1.7" @@ -1965,6 +2178,15 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "rand_core", +] + [[package]] name = "slab" version = "0.4.12" @@ -2013,6 +2235,16 @@ dependencies = [ "system-deps", ] +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -2326,6 +2558,7 @@ dependencies = [ "tokio", "tower-layer", "tower-service", + "tracing", ] [[package]] @@ -2364,6 +2597,7 @@ version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ + "log", "pin-project-lite", "tracing-attributes", "tracing-core", diff --git a/kiosk/src/server.rs b/kiosk/src/server.rs index e34eec7..6448bc2 100644 --- a/kiosk/src/server.rs +++ b/kiosk/src/server.rs @@ -246,7 +246,7 @@ pub fn heartbeat( key: &str, displays: &[(String, u32, u32)], hw: &crate::hwmon::HwInfo, -) { +) -> bool { let client = reqwest::blocking::Client::new(); let display_info: Vec<_> = displays.iter().enumerate().map(|(index, (name, w, h))| { serde_json::json!({ "index": index, "name": name, "width_px": w, "height_px": h }) @@ -256,7 +256,7 @@ pub fn heartbeat( let local_key = load_or_create_local_key(); let local_port: u16 = std::env::var("BF_KIOSK_LOCAL_PORT") .ok().and_then(|s| s.parse().ok()).unwrap_or(18090); - let _ = client + client .post(format!("{server}/api/kiosk/heartbeat")) .header("Authorization", format!("Bearer {key}")) .json(&serde_json::json!({ @@ -269,5 +269,7 @@ pub fn heartbeat( "local_port": local_port, })) .timeout(Duration::from_secs(5)) - .send(); + .send() + .map(|r| r.status().is_success()) + .unwrap_or(false) } diff --git a/kiosk/src/ui.rs b/kiosk/src/ui.rs index d7da3de..79cd003 100644 --- a/kiosk/src/ui.rs +++ b/kiosk/src/ui.rs @@ -1,5 +1,6 @@ use std::cell::{Cell, RefCell}; use std::collections::HashMap; +use std::fs; use std::sync::mpsc; use std::time::{Duration, Instant}; use url::Url; @@ -254,12 +255,13 @@ fn activate(app: &Application) { // firmware updates so kiosks pick up new builds without admin push. let mut first_iter = true; loop { - send_heartbeat_now(&server, &key); - if first_iter { + let heartbeat_ok = send_heartbeat_now(&server, &key); + if first_iter && heartbeat_ok { // Successfully heart-beat at least once → consider this boot a // healthy one. Clears the rollback-pending marker so the next // start doesn't try to roll back a healthy install. firmware::mark_firmware_applied(); + mark_kiosk_healthy(); first_iter = false; } maybe_apply_firmware_update(&server, &key); @@ -317,16 +319,26 @@ fn mark_activity(display_id: u32) { }); } -fn send_heartbeat_now(server_url: &str, kiosk_key: &str) { +fn send_heartbeat_now(server_url: &str, kiosk_key: &str) -> bool { let displays = query_displays(); let hw = hwmon::read(); - server::heartbeat(server_url, kiosk_key, &displays, &hw); + server::heartbeat(server_url, kiosk_key, &displays, &hw) +} + +fn mark_kiosk_healthy() { + let _ = fs::create_dir_all("/run/betterframe"); + if let Err(err) = fs::write("/run/betterframe/kiosk-healthy", b"ok\n") { + warn!("failed to write health marker: {err}"); + } } /// Ask the server whether an update is available. On hit, download + verify /// + swap + report + exit (systemd brings up the new binary). On miss or /// error: log + keep running. Designed to be safe to call from any thread. fn maybe_apply_firmware_update(server_url: &str, kiosk_key: &str) { + if std::env::var("BF_ENABLE_APP_OTA").as_deref() != Ok("1") { + return; + } let current = env!("CARGO_PKG_VERSION"); let Some(info) = firmware::check(server_url, kiosk_key, current) else { return }; info!("firmware: update {} → {} available", current, info.version);