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 9c0199d..9a2e63b 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 @@ -105,6 +105,9 @@ plymouth-set-default-theme betterframe || true # pixel so there's literally nothing to render. CURSOR_DIR=/usr/share/icons/betterframe-empty/cursors install -d -m 755 "$CURSOR_DIR" +# XCursor/wlroots looks for index.theme (not cursor.theme) when resolving +# XCURSOR_THEME. Install as both so update-alternatives and XCursor agree. +install -m 644 /tmp/bf-files/cursor.theme /usr/share/icons/betterframe-empty/index.theme install -m 644 /tmp/bf-files/cursor.theme /usr/share/icons/betterframe-empty/cursor.theme # Generate valid 1x1 transparent Xcursor files. Previous generator had a # missing version field → malformed → wlroots fell back to default cursor. @@ -172,19 +175,31 @@ systemctl mask bluetooth.service hciuart.service 2>/dev/null || true # packages AND nuke any leftover desktop/autostart files so nothing # survives to flash "configure your raspberry" on screen. apt-get -y purge piwiz userconf-pi pi-greeter rpd-plym-splash \ - initial-setup initial-setup-gui 2>/dev/null || true + initial-setup initial-setup-gui rpi-first-boot-wizard 2>/dev/null || true +# Nuke ALL autostart entries related to setup/wizard/greeter. rm -f /etc/xdg/autostart/piwiz.desktop rm -f /etc/xdg/autostart/setup-wizard.desktop rm -f /etc/xdg/autostart/initial-setup*.desktop +rm -f /etc/xdg/autostart/*wizard*.desktop +rm -f /etc/xdg/autostart/*setup*.desktop +rm -f /etc/xdg/autostart/*greeter*.desktop rm -rf /usr/share/applications/piwiz.desktop rm -rf /usr/share/applications/initial-setup*.desktop # userconf-pi drops a first-boot service that prompts for user/pass. systemctl disable userconfig.service 2>/dev/null || true systemctl mask userconfig.service 2>/dev/null || true -# Pi OS Bookworm+ uses rpi-first-boot-wizard. -apt-get -y purge rpi-first-boot-wizard 2>/dev/null || true systemctl disable rpi-first-boot-wizard.service 2>/dev/null || true systemctl mask rpi-first-boot-wizard.service 2>/dev/null || true +# raspi-config has first-boot triggers via /etc/profile.d and init scripts. +rm -f /etc/profile.d/raspi-config.sh 2>/dev/null || true +rm -f /etc/init.d/resize2fs_once 2>/dev/null || true +systemctl disable raspi-config.service 2>/dev/null || true +systemctl mask raspi-config.service 2>/dev/null || true +# Mark first-boot as done so any surviving checks think setup completed. +mkdir -p /var/lib/userconf-pi +touch /var/lib/userconf-pi/userconf +# Prevent pi-gen firstrun.sh from triggering on boot. +if [ -f /boot/firmware/firstrun.sh ]; then rm -f /boot/firmware/firstrun.sh; fi # Remove any login program on console. rm -f /etc/systemd/system/getty.target.wants/* 2>/dev/null || true diff --git a/deploy/scripts/setup-pi-kiosk.sh b/deploy/scripts/setup-pi-kiosk.sh index 5faa44c..9153eb3 100755 --- a/deploy/scripts/setup-pi-kiosk.sh +++ b/deploy/scripts/setup-pi-kiosk.sh @@ -226,10 +226,19 @@ if [ "${INSTALL_KIOSK}" = "1" ]; then # piwiz = "Welcome to Raspberry Pi" first-run wizard. userconf-pi runs at # first boot if no user is configured. Purge both so they can't fire. - DEBIAN_FRONTEND=noninteractive apt-get purge -y piwiz userconf-pi 2>/dev/null || true - rm -f /etc/xdg/autostart/piwiz.desktop + DEBIAN_FRONTEND=noninteractive apt-get purge -y piwiz userconf-pi \ + rpi-first-boot-wizard pi-greeter rpd-plym-splash 2>/dev/null || true + rm -f /etc/xdg/autostart/piwiz.desktop /etc/xdg/autostart/*wizard*.desktop \ + /etc/xdg/autostart/*setup*.desktop /etc/xdg/autostart/*greeter*.desktop systemctl disable --now userconf.service userconf-pi.service 2>/dev/null || true systemctl mask userconf.service userconf-pi.service 2>/dev/null || true + systemctl disable --now rpi-first-boot-wizard.service 2>/dev/null || true + systemctl mask rpi-first-boot-wizard.service 2>/dev/null || true + rm -f /etc/profile.d/raspi-config.sh 2>/dev/null || true + systemctl disable --now raspi-config.service 2>/dev/null || true + systemctl mask raspi-config.service 2>/dev/null || true + mkdir -p /var/lib/userconf-pi && touch /var/lib/userconf-pi/userconf + rm -f /boot/firmware/firstrun.sh 2>/dev/null || true # Suppress the Debian/Pi console motd and /etc/issue text on tty. : > /etc/motd diff --git a/deploy/systemd/betterframe-kiosk.service b/deploy/systemd/betterframe-kiosk.service index 5196bb7..c3b5486 100644 --- a/deploy/systemd/betterframe-kiosk.service +++ b/deploy/systemd/betterframe-kiosk.service @@ -36,6 +36,7 @@ Environment=GST_DEBUG=1 # Invisible cursor: transparent theme + 1px size + software fallback. # Three layers because Pi 5 GPU ignores XCURSOR_SIZE for HW cursors. Environment=XCURSOR_THEME=betterframe-empty +Environment=WLR_XCURSOR_THEME=betterframe-empty Environment=XCURSOR_SIZE=1 Environment=WLR_NO_HARDWARE_CURSORS=1 # Let the unprivileged kiosk process control the Pi fan PWM sysfs files. diff --git a/server/src/plugins/service-admin-http/index.ts b/server/src/plugins/service-admin-http/index.ts index 0e7f52d..5360e59 100644 --- a/server/src/plugins/service-admin-http/index.ts +++ b/server/src/plugins/service-admin-http/index.ts @@ -105,6 +105,7 @@ export class Plugin extends BSBService, typeof Event private server?: Server; private cameraHealthChecker?: { stop: () => void }; + private artifactCleanup?: { stop: () => void }; constructor(cfg: BSBServiceConstructor, typeof EventSchemas>) { super(cfg); @@ -238,6 +239,13 @@ export class Plugin extends BSBService, typeof Event warn: (m) => obs.log.warn(m as any, {}), }); + // Artifact cleanup — prune yanked + old firmware/OS files every 6h. + const { startArtifactCleanup } = await import("../../shared/artifact-cleanup.js"); + this.artifactCleanup = startArtifactCleanup(repo, { + info: (m) => obs.log.info(m as any, {}), + warn: (m) => obs.log.warn(m as any, {}), + }); + // Auto-provision the Node-RED bf-server-config so the user doesn't have // to set server URL + API key manually. Best-effort with retries because // Node-RED may still be starting. @@ -307,6 +315,7 @@ export class Plugin extends BSBService, typeof Event async dispose(): Promise { this.cameraHealthChecker?.stop(); + this.artifactCleanup?.stop(); if (this.server) { await this.server.close(); } diff --git a/server/src/plugins/service-store/migrations.ts b/server/src/plugins/service-store/migrations.ts index 242f6af..11989c6 100644 --- a/server/src/plugins/service-store/migrations.ts +++ b/server/src/plugins/service-store/migrations.ts @@ -958,6 +958,28 @@ export const MIGRATIONS: readonly MigrationEntry[] = [ // --- display active layout --- addColumnIfNotExists(db, "displays", "active_layout_id", "INTEGER REFERENCES layouts(id) ON DELETE SET NULL"); + + // --- per-kiosk encryption key --- + addColumnIfNotExists(db, "kiosks", "encrypt_key_encrypted", "TEXT"); + + // --- ONVIF event routing --- + addColumnIfNotExists(db, "cameras", "event_source", "TEXT NOT NULL DEFAULT 'auto'"); + addColumnIfNotExists(db, "cameras", "event_sink", "TEXT NOT NULL DEFAULT 'auto'"); + addColumnIfNotExists(db, "cameras", "supported_event_topics", "TEXT NOT NULL DEFAULT '[]'"); + + // --- cloud accounts table --- + db.exec(`CREATE TABLE IF NOT EXISTS cloud_accounts ( + id TEXT PRIMARY KEY, + vendor TEXT NOT NULL, + name TEXT NOT NULL, + credentials_encrypted TEXT NOT NULL, + is_active INTEGER NOT NULL DEFAULT 1, + last_sync_at TEXT, + last_sync_error TEXT, + camera_count INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) + ) STRICT`); + db.exec(`CREATE INDEX IF NOT EXISTS idx_cloud_accounts_vendor ON cloud_accounts(vendor)`); }, // Per-kiosk encryption key. Replaces shared cluster_key for bundle diff --git a/server/src/plugins/service-store/repository.ts b/server/src/plugins/service-store/repository.ts index 2591765..9d2ec7e 100644 --- a/server/src/plugins/service-store/repository.ts +++ b/server/src/plugins/service-store/repository.ts @@ -1365,6 +1365,18 @@ export class Repository { void this.notify("firmware_releases", "update", id); } + async deleteFirmwareRelease(id: string): Promise { + await this._run("DELETE FROM firmware_releases WHERE id = ?", [id]); + void this.notify("firmware_releases", "delete", id); + } + + async listYankedFirmwareReleases(): Promise { + const rs = await this._all( + "SELECT * FROM firmware_releases WHERE yanked_at IS NOT NULL ORDER BY yanked_at ASC", + ); + return rs.map((r) => rowToFirmwareRelease(r as Record)); + } + /** Mark the per-kiosk firmware attempt state (called from /api/kiosk/firmware/applied). */ async recordKioskFirmwareAttempt( kioskId: number, @@ -1549,6 +1561,18 @@ export class Repository { void this.notify("os_update_releases", "update", id); } + async deleteOsUpdateRelease(id: string): Promise { + await this._run("DELETE FROM os_update_releases WHERE id = ?", [id]); + void this.notify("os_update_releases", "delete", id); + } + + async listYankedOsUpdateReleases(): Promise { + const rs = await this._all( + "SELECT * FROM os_update_releases WHERE yanked_at IS NOT NULL ORDER BY yanked_at ASC", + ); + return rs.map((r) => rowToOsUpdateRelease(r as Record)); + } + async recordKioskOsUpdateAttempt( kioskId: number, version: string, diff --git a/server/src/shared/artifact-cleanup.ts b/server/src/shared/artifact-cleanup.ts new file mode 100644 index 0000000..47e185c --- /dev/null +++ b/server/src/shared/artifact-cleanup.ts @@ -0,0 +1,126 @@ +/** + * Periodic cleanup of firmware + OS update artifact files. + * + * Strategy: + * 1. Delete artifact files for yanked releases (DB row kept for audit). + * 2. For each channel, keep only the N most recent non-yanked releases. + * Older ones are yanked + artifact deleted. + * + * Runs every 6 hours. Safe to call concurrently — worst case is two passes + * trying to delete the same file (ENOENT is swallowed). + */ +import { unlink } from "node:fs/promises"; +import type { Repository } from "../plugins/service-store/repository.js"; + +interface CleanupLog { + info(msg: string): void; + warn(msg: string): void; +} + +const KEEP_PER_CHANNEL = 5; +const INTERVAL_MS = 6 * 60 * 60 * 1000; + +async function removeFile(path: string): Promise { + try { + await unlink(path); + return true; + } catch (err) { + if ((err as NodeJS.ErrnoException).code === "ENOENT") return false; + throw err; + } +} + +async function cleanupFirmware(repo: Repository, log: CleanupLog): Promise { + let cleaned = 0; + + const yanked = await repo.listYankedFirmwareReleases(); + for (const r of yanked) { + if (await removeFile(r.artifact_path)) { + log.info(`firmware cleanup: deleted artifact for yanked ${r.version} (${r.arch})`); + cleaned++; + } + await repo.deleteFirmwareRelease(r.id); + } + + const all = await repo.listFirmwareReleases(); + const active = all.filter((r) => !r.yanked_at); + const byKey = new Map(); + for (const r of active) { + const key = `${r.channel}:${r.arch}`; + const list = byKey.get(key) ?? []; + list.push(r); + byKey.set(key, list); + } + + for (const [, releases] of byKey) { + releases.sort((a, b) => b.uploaded_at.localeCompare(a.uploaded_at)); + const excess = releases.slice(KEEP_PER_CHANNEL); + for (const r of excess) { + if (await removeFile(r.artifact_path)) { + log.info(`firmware cleanup: pruned old ${r.version} (${r.arch})`); + cleaned++; + } + await repo.deleteFirmwareRelease(r.id); + } + } + + return cleaned; +} + +async function cleanupOsUpdates(repo: Repository, log: CleanupLog): Promise { + let cleaned = 0; + + const yanked = await repo.listYankedOsUpdateReleases(); + for (const r of yanked) { + if (await removeFile(r.artifact_path)) { + log.info(`os-update cleanup: deleted artifact for yanked ${r.version}`); + cleaned++; + } + await repo.deleteOsUpdateRelease(r.id); + } + + const all = await repo.listOsUpdateReleases(); + const active = all.filter((r) => !r.yanked_at); + const byKey = new Map(); + for (const r of active) { + const key = `${r.channel}:${r.compatibility}`; + const list = byKey.get(key) ?? []; + list.push(r); + byKey.set(key, list); + } + + for (const [, releases] of byKey) { + releases.sort((a, b) => b.uploaded_at.localeCompare(a.uploaded_at)); + const excess = releases.slice(KEEP_PER_CHANNEL); + for (const r of excess) { + if (await removeFile(r.artifact_path)) { + log.info(`os-update cleanup: pruned old ${r.version}`); + cleaned++; + } + await repo.deleteOsUpdateRelease(r.id); + } + } + + return cleaned; +} + +export function startArtifactCleanup( + repo: Repository, + log: CleanupLog, +): { stop: () => void } { + async function run() { + try { + const fw = await cleanupFirmware(repo, log); + const os = await cleanupOsUpdates(repo, log); + if (fw + os > 0) { + log.info(`artifact cleanup: removed ${fw} firmware + ${os} OS artifacts`); + } + } catch (err) { + log.warn(`artifact cleanup failed: ${(err as Error).message}`); + } + } + + void run(); + const timer = setInterval(() => void run(), INTERVAL_MS); + return { stop: () => clearInterval(timer) }; +}