Commit graph

326 commits

Author SHA1 Message Date
Mitchell R
76f725c149
fix(coordinator): use config.cookieName directly, not envStr 2026-05-22 20:42:48 +02:00
Mitchell R
14ee081f61
fix(config): add cookieName to coordinator-ws sec-config (was null → 401) 2026-05-22 20:41:42 +02:00
Mitchell R
5198a681eb
debug(ws): log admin debug WS auth failure details 2026-05-22 20:39:19 +02:00
Mitchell R
31ba05b703
fix(debug-ws): route via /admin/ws/debug/ so Angie forwards correctly 2026-05-22 20:28:26 +02:00
Mitchell R
aff76b41f9
fix(kiosk): report os_version in heartbeat (was never sent) 2026-05-22 20:25:29 +02:00
Mitchell R
1f0bcd1084
fix(remote-debug): successful auth resets lockout + drop empty WS token param 2026-05-22 20:23:20 +02:00
Mitchell R
c5068615ee
feat(remote-debug): journal streaming + secure terminal via WebSocket
Kiosk side (remote_debug.rs + ws_client.rs refactor):
  - Journal streaming: server sends journal-start → kiosk spawns
    journalctl -f, pipes lines back as journal-line messages via WS.
    journal-stop kills the process. On-demand, not always-on.
  - Terminal: server sends terminal-request → kiosk checks lockout +
    firmware_channel == "dev" → generates 8-char code displayed on
    screen as fullscreen overlay (NOT logged) → server relays admin's
    code via terminal-auth → kiosk validates with constant-time compare
    → on success spawns bash, relays I/O as base64 terminal-data.
  - Lockout: 3 failed codes per boot → lockout_count++. 3 lockouts
    (9 total failures) → permanent (reflash only). Reboot resets
    attempt counter, not lockout counter. Successful pairing resets all.
  - ws_client.rs rewritten with split reader/writer + tokio::select!
    for multiplexing incoming WS messages with outbound journal/terminal
    data from sync threads.

Server side (coordinator-ws + routes-admin):
  - New admin debug WS endpoint: /ws/admin/debug/:kioskId. Authenticated
    via admin API key (query param) or session cookie. Relays messages
    bidirectionally between admin browser ↔ kiosk.
  - Admin pages: /admin/kiosks/:id/logs (journal viewer with start/
    stop/clear) and /admin/kiosks/:id/terminal (code entry + terminal
    area). Both open in new tabs from the kiosk detail page.
  - Angie proxy config updated with /ws/admin/debug/ location block.

Security:
  - Terminal only on dev channel
  - Code displayed physically on screen, never logged or stored server-side
  - Lockout: 3/boot, 3 lockouts = permanent, pairing resets
  - Kiosk responds "locked" without specifying which lockout triggered
2026-05-22 20:13:39 +02:00
Mitchell R
e0b9955522
fix(admin): only show Live Events panel for ONVIF cameras 2026-05-22 19:48:41 +02:00
Mitchell R
90a8f256d5
fix(docker): remove COPY .git — Coolify excludes it from build context
Coolify doesn't include .git in Docker build context, causing build
failure. Revert to ARG-based version stamping: compose passes
BF_SERVER_VERSION from Coolify's SOURCE_COMMIT/COOLIFY_GIT_COMMIT
env vars as a build arg, Dockerfile writes it to .bf-version. Removed
git from builder apt install (no longer needed).
2026-05-22 19:30:18 +02:00
Mitchell R
ee281fc9dc
fix(ci): always build kiosk binary + image on every master push 2026-05-22 18:37:23 +02:00
Mitchell R
05ca368f29
fix(onvif): import discovered cameras as type=onvif with credentials
importDiscoveredCamera was hardcoded to type="rtsp", losing ONVIF
identity. Camera edit showed RTSP fields, ONVIF event subscription
skipped (checks cam_type=="onvif"), re-discovery impossible.

Now creates type="onvif" with onvif_host/port/username/password stored
on the camera row. Streams still go into camera_streams (unchanged).
Bundle ships onvif fields → kiosk subscribes to PullPoint events.

Also passes host + port as hidden form fields from discover results
page so the add handler has them available. Basic manual camera
creation via UI stays rtsp-only (simpler); discovery flow produces
onvif type.
2026-05-22 18:30:41 +02:00
Mitchell R
2e40e78413
fix(admin): mask passwords in stream RTSP URIs on camera detail page 2026-05-21 16:29:24 +02:00
Mitchell R
4870426158
fix(rauc): use CA cert for bundle verify + don't fail build on verify error 2026-05-21 16:22:36 +02:00
Mitchell R
516a4ca4a0
fix(firmware): grant bfkiosk write access to binary dir + align marker path
/opt/betterframe/kiosk/ now owned bfkiosk:bfkiosk so OTA can write
.new/.prev files. Marker path in Rust code aligned with rollback
script expectation (/var/lib/betterframe/kiosk/firmware-applying.json).
2026-05-21 16:03:42 +02:00
Mitchell R
7d81891b0e
fix(version): derive server version from git at Docker build time
Coolify pulls from GitHub and runs docker compose build — no guaranteed
env vars like SOURCE_COMMIT. Previous approach relied on ARG/ENV
passthrough that silently defaulted to "dev".

Fix: install git in the builder stage, COPY .git into context, run
git describe --tags --always to derive the version, write it to
/app/server/.bf-version. version.ts reads this file as a fallback
between env vars and the "dev" literal.

Chain: BF_SERVER_VERSION env → BF_BUILD_VERSION env → .bf-version file
→ COOLIFY_GIT_COMMIT env → SOURCE_COMMIT env → "dev".

Also: fix .gitignore for rauc-signing/ (was under wrong path).
2026-05-21 16:02:21 +02:00
Mitchell R
653f2ce910
chore(rauc): regenerate CA cert as ECDSA P-256 2026-05-21 15:49:15 +02:00
Mitchell R
c4ce9e7880
fix(rauc): switch signing keys from Ed25519 to ECDSA P-256
RAUC uses OpenSSL CMS signing. CMS doesn't support Ed25519 on
OpenSSL < 3.2 — Ubuntu 24.04 ships 3.0.13 → "pkey nid=1087" error.
ECDSA P-256 is universally supported in CMS, fast, and small.

Operator must regenerate keys + re-set GitHub secrets:
  rm -rf rauc-signing
  bash scripts/gen-rauc-signing-keys.sh
  cp rauc-signing/ca-cert.pem deploy/rauc/ca-cert.pem
  git add + commit + push
  Update BF_RAUC_SIGNING_CERT + BF_RAUC_SIGNING_KEY secrets
2026-05-21 15:45:26 +02:00
Mitchell R
6e10913380
fix(admin): cell edit no longer corrupts grid when spans change
Use hx-retarget/hx-reswap response headers to replace full grid
when cell dimensions change (overlap resolution may move other cells).
Single-cell swap when only content fields change.
2026-05-21 15:12:55 +02:00
Mitchell R
b05cdfc153
fix(ci): skip kiosk+image build when only server code changes
Master pushes now check git diff for kiosk/, deploy/, .github/ changes.
Server-only commits skip the expensive Rust cross-compile + pi-gen image.
Tag pushes and workflow_dispatch always build everything.
2026-05-21 15:12:48 +02:00
Mitchell R
157bdd49bb
fix(repartition): label rootfs ext4 + bootfs FAT before dd into image
Kernel dropped to initramfs because root=LABEL=BF_ROOT_A in cmdline.txt
but the ext4 filesystem had no label set (pi-gen's default is unlabeled).
dd copies raw bytes — any label must be set on the standalone file BEFORE
writing into the output image.

Add e2label BF_ROOT_A on rootfs.ext4 + fatlabel BF_BOOT_A / BF_BOOT_B
on each bootfs copy after patching cmdline.txt but before dd.
2026-05-21 15:10:22 +02:00
Mitchell R
78538cef9c
fix(rauc): remove invalid type=image from manifest (RAUC rejects it) 2026-05-21 14:49:20 +02:00
Mitchell R
1056219a96
fix(ci): forward RAUC signing secrets from release.yml to build.yml 2026-05-21 14:23:18 +02:00
Mitchell R
785eefbbdf
chore(rauc): commit CA cert for OS bundle verification 2026-05-21 13:54:08 +02:00
Mitchell R
547974a7eb
fix(scripts): prevent Git Bash path mangling on -subj /CN=... 2026-05-21 13:50:32 +02:00
Mitchell R
f339fe8e67
chore: add Windows PowerShell version of RAUC key gen script 2026-05-21 13:40:05 +02:00
Mitchell R
87c4dbb2bc
fix(repartition): use sfdisk -J + dd offsets instead of losetup -fP
losetup -fP partition scanning failed on CI runner ("failed to open
partition 1"). Rewrite to parse partition start/size from sfdisk -J
(JSON output) via jq, then dd with skip/seek at exact sector offsets.
Only uses losetup for individual file images (selector.vfat, rootfs,
bootfs) where partition scanning isn't needed.

Also: add jq to CI apt install, drop xz compression from -9 to -6
(faster, still ~85% ratio on rootfs), free source image earlier to
avoid disk exhaustion on runners with tight scratch.
2026-05-21 13:38:47 +02:00
Mitchell R
ecd8f07f70
fix(kiosk): add missing use crate::onvif_events import 2026-05-21 12:17:16 +02:00
Mitchell R
01a1aad2fd
fix(kiosk): rename reserved keyword gen, clean warnings
gen is reserved in Rust 2024 edition. Also remove unused
serde_json::Value import and prefix unused end_idx variable.
2026-05-21 12:14:24 +02:00
Mitchell R
9f382775a7
feat(cameras): live ONVIF event feed on camera detail page
Camera edit page gains a "Live Events" panel that auto-refreshes every
5s via htmx. Shows last 20 events for this camera from event_log:
topic, source type, timestamp, and raw payload JSON. Surfaces ALL
ONVIF topics including unknown ones — if a camera produces an event
type we haven't seen before, it shows up here immediately.

queryEvents gains camera_id + source_type filters. Route
GET /admin/cameras/:id/events returns an HTML fragment with the event
table rows.
2026-05-21 12:09:09 +02:00
Mitchell R
74a871cd9b
fix(store): use display_layouts join table in listKiosksWithCameraInBundle
Old query referenced removed layouts.display_id column.
2026-05-21 12:08:56 +02:00
Mitchell R
e770e48f76
fix(layout): resolve cell overlaps on resize/expand
Replace naive per-cell shift with iterative overlap resolver. When any
cell grows (direction expand, dim/delta resize, or cell edit with new
spans), all overlapping cells get pushed along the expansion axis, with
cascading to prevent pushed cells from overlapping each other.

Fixes: expanding left block goes under right block instead of pushing it.
2026-05-21 12:07:32 +02:00
Mitchell R
8e75ed379d
feat(nodered): install Dashboard 2.0 + auto-sync on entities page
- Bake @flowfuse/node-red-dashboard into Node-RED Docker image
- Fire-and-forget syncDashboardsFromNodered() on GET /admin/entities
  so dashboard tabs appear without manual sync button click
2026-05-21 12:05:12 +02:00
Mitchell R
0ae161173a
feat(admin): clone layout with cells, labels, and display attachments
Adds Clone Layout button to layout edit page. Duplicates the layout
with all cells, label bindings, and display attachments. Name gets
"(copy)" suffix with dedup.
2026-05-21 12:04:11 +02:00
Mitchell R
991c2f0cd5
feat(onvif-events): PullPoint subscription for all ONVIF cameras
New kiosk/src/onvif_events.rs: for each ONVIF camera in the bundle,
creates a PullPoint subscription, polls every 3s, parses
NotificationMessage XML into structured JSON (topic + source key/values
+ data key/values + timestamp), and POSTs to /api/kiosk/event with
source_type=onvif + camera_id.

Forwards ALL event topics: motion, ANPR (LicensePlateRecognition),
line crossing, intrusion, digital input, analytics, tamper — everything
the camera exposes. Node-RED sorts what matters.

Subscription lifecycle:
  - CreatePullPointSubscription with 60s InitialTerminationTime
  - Renew every 55s before timeout
  - Unsubscribe on bundle change / shutdown
  - Auto-resubscribe on pull/renew failure (30s backoff)
  - Generation tracking via Weak<()> so old workers self-terminate
    when start() is called with a new bundle

WSSE PasswordDigest auth for SOAP calls — same scheme the server's
onvif.ts uses. sha1 crate added.

BundleCamera extended with onvif_host/port/username/password_encrypted
fields (server already ships them; kiosk just wasn't deserializing).

Gated by BF_ENABLE_ONVIF_EVENTS=1. Enabled by default in the pi-gen
image env file.

TODO: cluster-key-based decryption of onvif_password_encrypted. For
now relies on the RTSP URI having plaintext credentials embedded (which
the ONVIF import path already ensures via rtspWithCredentials).
2026-05-21 12:03:30 +02:00
Mitchell R
32382beb3b
fix(image): install gstreamer1.0-tools for gst-launch-1.0 snapshot pipeline 2026-05-21 11:58:41 +02:00
Mitchell R
9129613920
feat(cameras): sync entity name on rename + ONVIF device name from GetDeviceInformation
Two fixes:

1. When admin renames a camera, the linked entity's name now syncs
   automatically so the entity list doesn't drift from the camera list.

2. ONVIF discovery now calls GetDeviceInformation before GetProfiles
   (best-effort, catches auth-gated devices). Pulls Manufacturer + Model
   and uses the combined string as the camera's proposed name instead of
   the raw IP. E.g. "Hikvision DS-2CD2146G2" instead of "192.168.74.8".
   Falls back to host IP when the device omits the info.
2026-05-21 11:57:38 +02:00
Mitchell R
5edf9d4b0b
feat(cameras): show kiosk subscriptions on camera detail page
Camera edit page now shows a "Kiosk Subscriptions" table: every kiosk
whose layouts reference this camera, which specific layout names, and
whether the camera is in the kiosk's active layout (green "active"
badge) or just bundled (gray "bundled" badge).

Snapshot route switched from listKiosksRenderingCamera (active-only)
to listKiosksWithCameraInBundle (any layout). The kiosk's LAN endpoint
opens a one-shot RTSP connection from its own network position even when
the camera isn't on screen — no warm pipeline needed. Server falls back
to direct pull only when NO kiosk has the camera in any layout at all.
2026-05-21 11:54:25 +02:00
Mitchell R
4c1edbd3b2
fix(migrations): catch-all backfill for all missing tables/columns
Every column + table added inside an already-passed PRAGMA user_version
entry is re-created here with IF NOT EXISTS / addColumnIfNotExists so
existing deploys finally pick them up:

  - kiosks: reported_hostname, network_interfaces_json
  - kiosks: os_update_channel, os_update_target_version, os_update_last_*
  - kiosks: managed_image, managed_config_*
  - displays: active_layout_id
  - os_update_releases table + indices
  - os_update_rollouts table + indices

Rule going forward: NEVER add columns/tables inside existing migration
entries. Always append a NEW entry at the end of the MIGRATIONS array.
2026-05-21 11:46:20 +02:00
Mitchell R
45c7ef26b2
fix(kiosk): add missing os_update import + guard Option<UCM>
1. ui.rs lacked `use crate::os_update` — module declared in main.rs
   but ui.rs couldn't resolve os_update::check/apply calls.
2. webkit6::WebView::user_content_manager() returns Option<UCM> not
   UCM directly — guard with if-let instead of calling method on Option.
2026-05-21 11:40:39 +02:00
Mitchell R
a7d661ca42
fix(ci): use job-level env vars for secret-gated step conditions
`secrets` context isn't available in step-level `if:` expressions inside
a reusable workflow_call. Move the secret-presence check to job-level
env (HAS_RAUC_SECRETS, HAS_AUTOIMPORT) and reference those in step if:.
2026-05-21 11:36:50 +02:00
Mitchell R
436d2d730c
feat(harden): hardware-bound at-rest encryption of kiosk state files
New module kiosk/src/at_rest.rs. Derives an AES-256-GCM key via HKDF
from a Pi-bound value:
  1. /proc/device-tree/serial-number  (Pi 5 firmware exposes it)
  2. /proc/cpuinfo Serial line          (older kernels)
  3. /etc/machine-id                    (non-Pi dev fallback)

File format: "BFE1" magic || 12-byte random nonce || ciphertext+tag.
Atomic write via tempfile + rename so a crash mid-write can't leave a
half-encrypted file.

Wired into kiosk/src/server.rs at every file I/O touching sensitive
state:
  - kiosk.key      (bearer token to BF server)
  - local.key      (LAN-side API auth key)
  - bundle.json    (cached bundle with RTSP credentials in URL form)

Migration: read paths tolerate legacy plaintext (kiosks upgraded from a
pre-at_rest build) AND re-store as ciphertext on the first read. One-
shot upgrade — subsequent boots skip the migration write.

Threat model defended: SD card extraction. Attacker who pulls the card
can't decrypt without also having the same physical Pi (CPU serial is
hardware-bound). Doesn't defeat an attacker who has both — at that
point they ARE the kiosk. Bar is raised from "trivially extract every
camera password" to "must steal the device intact."

Not defended: TPM-style attestation, remote attestation, sealed boot.
Pi 5 has no TPM and we don't ship a secure-boot config.

Tests in-module: round-trip short bytes, round-trip JSON, legacy
plaintext passthrough.
2026-05-21 11:34:29 +02:00
Mitchell R
90346f4efd
feat(os-ota-ui): admin pages for OS releases + rollouts + per-kiosk panel
Mirrors the kiosk-firmware admin shape one-for-one against OS RAUC
bundles:

  /admin/os-updates                   release list, yank
  /admin/os-updates/rollouts          rollout list + create
  /admin/os-updates/rollouts/:id/state pause/resume/complete
  /admin/kiosks/:id/os-update         per-kiosk channel + pin

Templates: OsUpdatePage, OsUpdateRolloutsPage, KioskOsUpdatePanel.
KioskOsUpdatePanel is rendered next to the existing KioskFirmwarePanel
on the kiosk detail page so OS + app state sit side-by-side. The
"how bundles get here" sidebar on the list page documents the four
GitHub secrets needed (signing cert/key + autoimport URL/key) so a
new operator doesn't have to dig through scripts/ to find them.

Nav gains an OS Updates entry between Firmware and Labels. Activates
on activeNav="os-updates".

Repo + import endpoint already existed (audit confirmed earlier). All
admin routes use them as-is.
2026-05-21 11:30:33 +02:00
Mitchell R
d149ed68e5
feat(harden): nftables default-drop firewall + first-boot password rotation
Two image-side hardening pieces both small enough to ship together.

deploy/nftables/nftables.conf — single ruleset installed at /etc/nftables.conf.
Default-drop input. Allowed: loopback, established/related, ratelimited
ICMP, kiosk local API :18090 from RFC1918 / RFC4193 / link-local sources
only. SSH stays gated by sshd-disabled (image build sets enable-ssh: 0
and 01-run-chroot masks it); the firewall rule for :22 is left commented
in for triage scenarios. Forward dropped. Output left wide open — kiosk
needs to dial out to arbitrary RTSP cameras + the BF server (which may
live on the public internet) without explicit allowlisting.

deploy/systemd/betterframe-firstboot.{service,sh} — runs once per device
before betterframe-kiosk starts. Generates a 24-char unambiguous-glyph
password, applies via chpasswd, stores at /etc/betterframe/admin-password
(0400 root), and prints a banner to tty1 so an HDMI-attached operator
can transcribe it during the boot window before cage takes over the
screen. Marker at /var/lib/betterframe/.firstboot-complete prevents
re-run on subsequent boots. Without this, every kiosk built from the
same image shipped with bfadmin:betterframe — a single password leak
compromises the entire fleet.

Future follow-up: post the rotated password (encrypted with cluster_key)
to the BF server via heartbeat so admin UI can surface it. Not in this
commit; the local file + tty banner are the only retrieval paths today.
2026-05-21 11:18:28 +02:00
Mitchell R
0fa797adfa
feat(os-ota): install RAUC + system.conf + boot backend in pi-gen image
Phase 2b. Bake the runtime side of RAUC into the curated image so a
freshly-flashed kiosk can accept .raucb bundles immediately:

- Add `rauc` + `dosfstools` to the apt package list.
- Drop deploy/rauc/system.conf to /etc/rauc/system.conf (already declares
  the A/B slot layout that repartition-image.sh produces).
- Drop deploy/rauc/betterframe-rauc-boot.sh to
  /usr/local/sbin/betterframe-rauc-boot.sh — the custom bootloader
  backend that flips the BF_BOOTSEL autoboot.txt to point at the
  freshly-installed slot via Pi 5 tryboot.
- Drop deploy/rauc/ca-cert.pem (operator-supplied, committed) to
  /etc/rauc/keyring.pem so rauc can verify CMS signatures. If the cert
  isn't committed yet, image build emits a workflow warning and the
  kiosk image installs but refuses every bundle — image still flashes,
  just no OS OTA until the cert is committed.
- Enable BF_ENABLE_OS_OTA=1 in /etc/default/betterframe-kiosk so the
  kiosk Rust consumer actually polls for bundles. Set to 0 to pin OS
  version for a specific kiosk.

mark-good was already wired (deploy/systemd/betterframe-rauc-mark-good.{service,sh}).
The kiosk's heartbeat loop also calls `rauc status mark-good` as a
belt+suspenders backup; both are idempotent.
2026-05-21 11:09:10 +02:00
Mitchell R
3575f1169b
feat(os-ota): A/B image repartition + bigger Blacksmith binary runners
Phase 2a of OS OTA: post-process pi-gen output into a RAUC-compatible
A/B layout. New deploy/rauc/repartition-image.sh:

- Decompresses the stock pi-gen 2-partition image
- Extracts bootfs (vfat) + rootfs (ext4) blobs
- Compacts rootfs with resize2fs -M and grows back with 25% headroom
- Patches /etc/fstab inside rootfs to use LABEL=BF_BOOT_A /
  LABEL=BF_ROOT_A / LABEL=BF_DATA (slot-agnostic; RAUC re-labels per
  slot on install)
- Stamps /etc/betterframe/{os-version,os-compatibility} for the kiosk's
  os_update.rs to read at runtime
- Builds two bootfs copies, each with cmdline.txt root= rewritten to
  the matching ROOT slot
- Lays out 6 GPT partitions: BF_BOOTSEL (autoboot.txt with tryboot
  pointing at boot_partition=2 / [tryboot] boot_partition=3), BF_BOOT_A,
  BF_BOOT_B, BF_ROOT_A (populated), BF_ROOT_B (empty, RAUC fills on
  first install), BF_DATA
- Recompresses with xz -T0

build-bundle.sh now takes the already-extracted slot images so the
.raucb bundle re-uses the exact same blobs that ship inside the A/B
initial-flash image — no duplication, no drift.

CI wires the repartition step between pi-gen output and the GitHub
Release upload. Ships the A/B image (not the stock pi-gen one).

Also: bump Blacksmith binary builders from 2/4 vCPU to 8 vCPU each.
Image job stays on GitHub's ubuntu-24.04-arm (Blacksmith arm kernel
6.5 doesn't ship binfmt_misc as a loadable module, which pi-gen-action's
defensive modprobe step still requires).

What's still pending:
  - In-image RAUC install (rauc package + drop system.conf + CA cert
    at /etc/rauc/keyring.pem). Without this, the image boots A/B-laid-
    out but rauc install commands have no daemon to talk to.
  - Admin UI for OS releases + rollouts (task #4).
2026-05-21 10:57:00 +02:00
Mitchell R
659670b494
feat(os-ota): kiosk-side RAUC bundle consumer
Phase 3 of the OS OTA pipeline. New module kiosk/src/os_update.rs polls
/api/kiosk/os/check with the kiosk's compatibility string and current OS
version (read from /etc/betterframe/os-compatibility +
/etc/betterframe/os-version, both written by the image build), downloads
the bundle, sha256-verifies the transport, and hands off to
`rauc install`. RAUC takes it from there: CMS signature verify against
/etc/rauc/keyring.pem, copy into inactive A/B slot, arm tryboot via the
custom bootloader backend, return. We then post /api/kiosk/os/applied
and `systemctl reboot` into the new slot.

Wired into the existing 60s heartbeat loop in ui.rs, gated by
BF_ENABLE_OS_OTA=1 (default OFF so dev kiosks on non-A/B images don't
keep trying + failing). Runs BEFORE the kiosk-binary check on each tick
so an OS bundle that ships an updated kiosk binary doesn't race the
firmware path.

On clean-boot heartbeat success we now also call `rauc status
mark-good` so the boot-attempts counter resets — three bad boots in a
row will auto-roll back without us needing a separate rollback path.

What's NOT in this commit:
  - A/B partition layout in the pi-gen image (task #6, blocks actual
    deployment — bundles can be served + accepted but `rauc install`
    will refuse without two valid slots).
  - Admin UI for managing releases + rollouts (task #4).
2026-05-21 10:47:45 +02:00
Mitchell R
084c119c44
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.
2026-05-21 10:44:24 +02:00
Mitchell R
334ee8fb93
feat(preview): pull entity snapshot from active kiosk first
When admin opens an entity preview, find a kiosk whose active layout
references the camera (new repo.listKiosksRenderingCamera). Probe each
candidate's LAN snapshot endpoint with a 4s timeout. On success, stream
the bytes back with x-bf-snapshot-source: kiosk:<id>. Falls through to
the existing server-direct ffmpeg/gst pull only when no kiosk is reachable
or has the camera in its active layout.

Kiosk side adds /local/snapshot/:camera_id?key=<local_key>. Spawns a
one-shot gst-launch (rtspsrc → decodebin → jpegenc ! filesink
num-buffers=1) on a blocking worker so axum's reactor stays free.
Prefers sub stream for snapshots to keep bandwidth low. Single-frame
pipeline tears down after the first JPEG.

LAN IP picking extracted to shared/kiosk-lan.ts so route handler and
KioskLocalPanel agree on which interface to talk to (the previously-
duplicated logic in admin-pages stays for now since it also renders the
interface list).

Why a parallel pipeline instead of teeing the warm one: cross-thread
gtk4paintablesink → appsink sample extraction is non-trivial. A 1-frame
parallel pull is cheap when the kiosk's RTSP session to that camera is
already known to work (precondition: it's in the active layout).
2026-05-21 10:35:27 +02:00
Mitchell R
88bbe040e5
feat(image): enable kiosk-app OTA by default in pi-gen build
BF_ENABLE_APP_OTA gates the periodic firmware check in ui.rs:449.
Our curated image is the place where it should be on — operator can
flip to 0 in /etc/default/betterframe-kiosk to pin a specific build.
Without this, a fresh image never picked up new dev releases even when
the auto-import secrets were configured.
2026-05-21 10:23:02 +02:00
Mitchell R
e26af6c0cc
fix(kiosk): hide cursor inside WebViews + at compositor level
WebView showed a grey/black half-square in the top-left — that's GDK's
"none" cursor rendered by WebKit's own surface, which ignores the
GTK-window CSS we set elsewhere. Inject a WebKit UserStyleSheet that
applies cursor:none !important to every page + frame at User priority,
overriding page-author CSS.

For the boot gap (cage start → first kiosk frame), set XCURSOR_SIZE=1
and WLR_NO_HARDWARE_CURSORS=1 in the systemd unit. SW fallback honors
the 1-pixel size; HW cursors don't, which is why a default arrow leaks
through on some Pi GPUs.
2026-05-21 10:21:17 +02:00