feat(ota): add RAUC OS update foundation

This commit is contained in:
Mitchell R 2026-05-20 05:15:29 +02:00
parent 444bb4c116
commit 87cde93316
No known key found for this signature in database
13 changed files with 559 additions and 7 deletions

View file

@ -168,6 +168,12 @@ jobs:
deploy/pi-gen/stage-betterframe-client/01-install-kiosk/files/ deploy/pi-gen/stage-betterframe-client/01-install-kiosk/files/
cp deploy/systemd/betterframe-firmware-rollback.sh \ cp deploy/systemd/betterframe-firmware-rollback.sh \
deploy/pi-gen/stage-betterframe-client/01-install-kiosk/files/ 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 \ cp deploy/pam.d/cage \
deploy/pi-gen/stage-betterframe-client/01-install-kiosk/files/cage.pam deploy/pi-gen/stage-betterframe-client/01-install-kiosk/files/cage.pam
cp deploy/plymouth/betterframe/betterframe.plymouth \ cp deploy/plymouth/betterframe/betterframe.plymouth \

View file

@ -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 644 /tmp/bf-files/cage.pam /etc/pam.d/cage
install -m 755 /tmp/bf-files/betterframe-firmware-rollback.sh \ install -m 755 /tmp/bf-files/betterframe-firmware-rollback.sh \
/usr/local/sbin/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. # Default env file — operator may edit on first boot to point at their server.
cat > /etc/default/betterframe-kiosk <<'EOF' cat > /etc/default/betterframe-kiosk <<'EOF'
@ -41,6 +47,7 @@ plymouth-set-default-theme betterframe || true
# --- Enable services, disable noise --- # --- Enable services, disable noise ---
systemctl enable seatd systemctl enable seatd
systemctl enable betterframe-kiosk.service 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. # Boot to multi-user, no display manager, no welcome wizard, no getty on tty1.
systemctl set-default multi-user.target systemctl set-default multi-user.target

View file

@ -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" <<EOF
[all]
tryboot_a_b=1
PARTITION_WALK=1
boot_partition=${primary_part}
[tryboot]
boot_partition=${try_part}
EOF
sync -f "${mountpoint}/autoboot.txt" 2>/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

View file

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

33
deploy/rauc/system.conf Normal file
View file

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

View file

@ -235,6 +235,13 @@ if [ "${INSTALL_KIOSK}" = "1" ]; then
/etc/systemd/system/betterframe-kiosk.service /etc/systemd/system/betterframe-kiosk.service
install -m 755 "${REPO_ROOT}/deploy/systemd/betterframe-firmware-rollback.sh" \ install -m 755 "${REPO_ROOT}/deploy/systemd/betterframe-firmware-rollback.sh" \
/usr/local/sbin/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 if [ ! -e /etc/default/betterframe-kiosk ]; then
cat > /etc/default/betterframe-kiosk <<'EOF' cat > /etc/default/betterframe-kiosk <<'EOF'
@ -245,6 +252,7 @@ EOF
systemctl daemon-reload systemctl daemon-reload
systemctl enable betterframe-kiosk.service systemctl enable betterframe-kiosk.service
systemctl enable betterframe-rauc-mark-good.service
# Restart picks up new binary on re-run. # Restart picks up new binary on re-run.
systemctl restart betterframe-kiosk.service || true systemctl restart betterframe-kiosk.service || true

View file

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

View file

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

View file

@ -0,0 +1 @@
d /run/betterframe 0755 bfkiosk bfkiosk -

71
docs/full-os-ota.md Normal file
View file

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

234
kiosk/Cargo.lock generated
View file

@ -38,6 +38,17 @@ dependencies = [
"pin-project-lite", "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]] [[package]]
name = "atomic-waker" name = "atomic-waker"
version = "1.1.2" version = "1.1.2"
@ -56,29 +67,97 @@ version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" 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]] [[package]]
name = "base64" name = "base64"
version = "0.22.1" version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]]
name = "base64ct"
version = "1.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
[[package]] [[package]]
name = "betterframe-kiosk" name = "betterframe-kiosk"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"axum",
"base64",
"dirs", "dirs",
"ed25519-dalek",
"futures-util", "futures-util",
"gpiod", "gpiod",
"gst-plugin-gtk4", "gst-plugin-gtk4",
"gstreamer", "gstreamer",
"gstreamer-video", "gstreamer-video",
"gtk4", "gtk4",
"hex",
"hostname", "hostname",
"rand",
"reqwest", "reqwest",
"serde", "serde",
"serde_json", "serde_json",
"sha2",
"tokio", "tokio",
"tokio-tungstenite", "tokio-tungstenite",
"tower",
"tracing", "tracing",
"tracing-subscriber", "tracing-subscriber",
"url", "url",
@ -193,6 +272,12 @@ dependencies = [
"crossbeam-utils", "crossbeam-utils",
] ]
[[package]]
name = "const-oid"
version = "0.9.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
[[package]] [[package]]
name = "core-foundation" name = "core-foundation"
version = "0.9.4" version = "0.9.4"
@ -244,12 +329,50 @@ dependencies = [
"typenum", "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]] [[package]]
name = "data-encoding" name = "data-encoding"
version = "2.11.0" version = "2.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" 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]] [[package]]
name = "digest" name = "digest"
version = "0.10.7" version = "0.10.7"
@ -292,6 +415,30 @@ dependencies = [
"syn", "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]] [[package]]
name = "either" name = "either"
version = "1.15.0" version = "1.15.0"
@ -350,6 +497,12 @@ version = "2.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6"
[[package]]
name = "fiat-crypto"
version = "0.2.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d"
[[package]] [[package]]
name = "field-offset" name = "field-offset"
version = "0.3.6" version = "0.3.6"
@ -992,6 +1145,12 @@ version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "hex"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
[[package]] [[package]]
name = "hostname" name = "hostname"
version = "0.4.2" version = "0.4.2"
@ -1042,6 +1201,12 @@ version = "1.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
[[package]]
name = "httpdate"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
[[package]] [[package]]
name = "hyper" name = "hyper"
version = "1.9.0" version = "1.9.0"
@ -1056,6 +1221,7 @@ dependencies = [
"http", "http",
"http-body", "http-body",
"httparse", "httparse",
"httpdate",
"itoa", "itoa",
"pin-project-lite", "pin-project-lite",
"smallvec", "smallvec",
@ -1374,6 +1540,12 @@ dependencies = [
"regex-automata", "regex-automata",
] ]
[[package]]
name = "matchit"
version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94"
[[package]] [[package]]
name = "memchr" name = "memchr"
version = "2.8.0" version = "2.8.0"
@ -1577,6 +1749,15 @@ version = "1.0.15"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" 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]] [[package]]
name = "percent-encoding" name = "percent-encoding"
version = "2.3.2" version = "2.3.2"
@ -1589,6 +1770,16 @@ version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" 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]] [[package]]
name = "pkg-config" name = "pkg-config"
version = "0.3.33" version = "0.3.33"
@ -1918,6 +2109,17 @@ dependencies = [
"zmij", "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]] [[package]]
name = "serde_spanned" name = "serde_spanned"
version = "1.1.1" version = "1.1.1"
@ -1950,6 +2152,17 @@ dependencies = [
"digest", "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]] [[package]]
name = "sharded-slab" name = "sharded-slab"
version = "0.1.7" version = "0.1.7"
@ -1965,6 +2178,15 @@ version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" 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]] [[package]]
name = "slab" name = "slab"
version = "0.4.12" version = "0.4.12"
@ -2013,6 +2235,16 @@ dependencies = [
"system-deps", "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]] [[package]]
name = "stable_deref_trait" name = "stable_deref_trait"
version = "1.2.1" version = "1.2.1"
@ -2326,6 +2558,7 @@ dependencies = [
"tokio", "tokio",
"tower-layer", "tower-layer",
"tower-service", "tower-service",
"tracing",
] ]
[[package]] [[package]]
@ -2364,6 +2597,7 @@ version = "0.1.44"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
dependencies = [ dependencies = [
"log",
"pin-project-lite", "pin-project-lite",
"tracing-attributes", "tracing-attributes",
"tracing-core", "tracing-core",

View file

@ -246,7 +246,7 @@ pub fn heartbeat(
key: &str, key: &str,
displays: &[(String, u32, u32)], displays: &[(String, u32, u32)],
hw: &crate::hwmon::HwInfo, hw: &crate::hwmon::HwInfo,
) { ) -> bool {
let client = reqwest::blocking::Client::new(); let client = reqwest::blocking::Client::new();
let display_info: Vec<_> = displays.iter().enumerate().map(|(index, (name, w, h))| { 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 }) 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_key = load_or_create_local_key();
let local_port: u16 = std::env::var("BF_KIOSK_LOCAL_PORT") let local_port: u16 = std::env::var("BF_KIOSK_LOCAL_PORT")
.ok().and_then(|s| s.parse().ok()).unwrap_or(18090); .ok().and_then(|s| s.parse().ok()).unwrap_or(18090);
let _ = client client
.post(format!("{server}/api/kiosk/heartbeat")) .post(format!("{server}/api/kiosk/heartbeat"))
.header("Authorization", format!("Bearer {key}")) .header("Authorization", format!("Bearer {key}"))
.json(&serde_json::json!({ .json(&serde_json::json!({
@ -269,5 +269,7 @@ pub fn heartbeat(
"local_port": local_port, "local_port": local_port,
})) }))
.timeout(Duration::from_secs(5)) .timeout(Duration::from_secs(5))
.send(); .send()
.map(|r| r.status().is_success())
.unwrap_or(false)
} }

View file

@ -1,5 +1,6 @@
use std::cell::{Cell, RefCell}; use std::cell::{Cell, RefCell};
use std::collections::HashMap; use std::collections::HashMap;
use std::fs;
use std::sync::mpsc; use std::sync::mpsc;
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use url::Url; use url::Url;
@ -254,12 +255,13 @@ fn activate(app: &Application) {
// firmware updates so kiosks pick up new builds without admin push. // firmware updates so kiosks pick up new builds without admin push.
let mut first_iter = true; let mut first_iter = true;
loop { loop {
send_heartbeat_now(&server, &key); let heartbeat_ok = send_heartbeat_now(&server, &key);
if first_iter { if first_iter && heartbeat_ok {
// Successfully heart-beat at least once → consider this boot a // Successfully heart-beat at least once → consider this boot a
// healthy one. Clears the rollback-pending marker so the next // healthy one. Clears the rollback-pending marker so the next
// start doesn't try to roll back a healthy install. // start doesn't try to roll back a healthy install.
firmware::mark_firmware_applied(); firmware::mark_firmware_applied();
mark_kiosk_healthy();
first_iter = false; first_iter = false;
} }
maybe_apply_firmware_update(&server, &key); 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 displays = query_displays();
let hw = hwmon::read(); 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 /// Ask the server whether an update is available. On hit, download + verify
/// + swap + report + exit (systemd brings up the new binary). On miss or /// + 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. /// error: log + keep running. Designed to be safe to call from any thread.
fn maybe_apply_firmware_update(server_url: &str, kiosk_key: &str) { 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 current = env!("CARGO_PKG_VERSION");
let Some(info) = firmware::check(server_url, kiosk_key, current) else { return }; let Some(info) = firmware::check(server_url, kiosk_key, current) else { return };
info!("firmware: update {} → {} available", current, info.version); info!("firmware: update {} → {} available", current, info.version);