Static file path now uses BSB pluginCwd instead of import.meta.dirname.
Added info log with method+path on every request via per-request trace.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Each HTTP request gets a fresh BSB trace (not a child span of init).
onRequest creates trace, stores on event.context.obs. onError logs
with trace context. onResponse ends the trace. 4xx logged as warn,
5xx as error. H3EventContext typed with obs field.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Both admin-http and api-http now log HTTP 500+ errors with status,
path, and error message to BSB observable (warn level). Makes
server-side errors visible in Coolify/container logs.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
BSB bsb-plugin-cli build extracts schemas statically and cannot
resolve cross-file imports. Inlined the anyvali db config schema
in each plugin's ConfigSchema. Shared DbConfig type stays in
shared/db/config.ts (type-only imports work fine).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Each service plugin now independently initializes its own DB connection
via shared/db/init.ts instead of depending on a central service-store
plugin. This removes the inter-plugin dependency ordering and the
plugin-registry singleton, making each service self-contained.
- Move db-adapter, repository, mappers, migrations, adapters to shared/db/
- Create shared/db/config.ts (reusable dbConfigSchema) and init.ts
- Delete service-store plugin and plugin-registry
- Add db config block to each service's ConfigSchema + sec-config template
- Move event_log purge timer into service-admin-http
- Update all import paths across shared modules and plugins
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
All runtime config now flows exclusively through BSB plugin config
(this.config.*) or shared module parameters. No more env var overrides.
Changes:
- Delete shared/env-overrides.ts (envStr/envBool/envInt helpers)
- version.ts: remove env var chain, keep only .bf-version file + "dev"
- firmware.ts: replace BF_FIRMWARE_SIGNING_KEY env with config.signingKeyPem
parameter, remove tryParsePrivateKey helper
- secrets.ts: replace process.env.CREDENTIALS_DIRECTORY with
config.systemdCredsDir
- mqtt-bridge.ts: accept MqttConfig object instead of reading process.env
- service-store: replace envStr calls with this.config.*, build pgUrl from
config fields, add pgPoolMax config
- pg-adapter.ts: accept poolMax constructor param instead of env var
- service-admin-http: add firmwareSigningKey, firmwareImportApiKey,
otaImportApiKey, systemdCredsDir config fields; pass to shared modules
- middleware.ts: replace tokenMatchesEnv with tokenMatchesExpected using
deps.firmwareImportApiKey/otaImportApiKey
- service-api-http: add mqttUrl/mqttUsername/mqttPassword/mqttTopicPrefix
config fields; pass to initMqttBridge
- service-coordinator-ws: replace envStr calls with this.config.*
- sec-config.yaml: add all new config fields with sensible defaults
- docker-compose.coolify.yml: remove all BF_* env vars from server service
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Cloud cameras are now a distinct type ('cloud') managed entirely by
sync. Bidirectional: cameras added in vendor cloud appear automatically,
removed cameras get deleted. Cloud cameras and their entities are
read-only in admin UI — no manual editing.
- Camera type CHECK widened to include 'cloud'
- New columns: cloud_account_id, cloud_vendor_camera_id,
cloud_stream_url, cloud_stream_type
- Repo: upsertCloudCamera, deleteCloudCamerasNotIn,
listCloudCamerasByAccount
- Sync replaces import: full reconciliation per account
- Hik-Connect: fetch HLS preview URLs via previewURLs endpoint
- Tuya: fetch stream URLs during sync (not just on demand)
- Kiosk API: GET /api/kiosk/cameras/:id/stream returns fresh
relay URL from vendor (session-based URLs expire)
- Cloud cameras show read-only detail page with cloud badge
- Coolify compose: postgres 18 as default, BF_DB=postgres,
server depends_on postgres healthy
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Cursor: install theme as index.theme (XCursor spec) not just
cursor.theme. Add WLR_XCURSOR_THEME env var for wlroots compat.
Piwiz: broader purge (rpi-first-boot-wizard, raspi-config triggers,
profile.d scripts, firstrun.sh). Mark first-boot done via userconf
marker file.
Migration: add encrypt_key_encrypted, cloud_accounts, and ONVIF event
columns to catch-all backfill so PRAGMA user_version skips can't miss
them.
Artifact cleanup: delete yanked firmware/OS files + prune to 5 most
recent per channel. Runs every 6h. Stops disk from filling up.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Cloud accounts page was raw HTML with no sidebar/topbar. Converted to
jsx-htmx CloudAccountsPage component matching all other admin pages.
Docker postgres bumped 16→18. npm: @types/node, tsx, ws updated.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Full ONVIF event management overhaul:
DB: cameras gain event_source (auto|server|kiosk:<id>), event_sink
(auto|server|kiosk:<id>), and supported_event_topics (JSON array).
Server:
- GetEventProperties SOAP call in onvif.ts — queries camera for all
supported event topics (motion, ANPR, line crossing, etc.)
- POST /admin/cameras/:id/refresh-events route — runs GetEventProperties
via designated event source (kiosk WS relay or server direct)
- Camera edit form: event_source + event_sink dropdowns
- Camera detail: supported event topics table with refresh button
- Bundle includes event_source + event_sink so kiosk knows its role
Kiosk:
- onvif_events.rs respects event_source: only subscribes when "auto"
or "kiosk:<this_id>", skips when "server"
- Subscription status tracking: state (subscribing/active/failed),
last_event_at, error — reported in heartbeat for admin visibility
- BundleCamera gains event_source + event_sink fields
Auto logic for source: camera in kiosk's bundle → kiosk subscribes.
Auto logic for sink: TODO — same-subnet detection for WSBaseNotification.
Currently PullPoint only; push model is the next step.
Previous generator packed 5 fields in the image chunk header but Xcursor
format needs 9 (header_size, type, nominal, version, w, h, xhot, yhot,
delay). Missing version field → malformed → wlroots ignored it → fell
back to default visible cursor. Now writes correct 68-byte Xcursor with
all 9 header fields. Added more cursor names (x_cursor, pirate, sides).
Also: terminal UI shows bash-style cwd$ prompt, separates command from
output visually, auto-detects pwd after each command for prompt update.
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
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.
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.
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.
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.
- 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
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.
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.
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.
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.
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.
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).
Replacing a kiosk now sanity-checks the incoming device:
- hardware_model must match (Pi 5 swapping in for Pi 5, not Pi 3)
- managed_image flag must match (don't silently switch BYO-OS ↔ image)
- capabilities can narrow legitimately but a "lost capabilities" diff is
surfaced anyway so the operator notices.
Mismatch raises an error listing what changed; "Force replace" checkbox
on the pair form bypasses for legitimate hardware upgrades. Pending codes
panel also now renders proposed_name / hw_model / capabilities /
managed-image badge so the operator can eyeball the inbound device
before picking a replace target.
ONVIF-imported cameras with rtsp_url but no camera_streams rows showed
"(no stream)" in the kiosk because the bundle fallback was gated to
type=rtsp only. Drop the type check + backfill existing rows so old
imports get a main stream row created.
feat(kiosk-mgmt): report hostname + all network interfaces
Behind Docker/Angie the server only saw the proxy bridge IP (172.31.0.2).
Kiosk now shells `ip -j addr show`, reports every non-loopback IPv4/v6
with CIDR, MAC, and operstate. Plus `hostname` for verifying that
managed-config applies landed. Admin UI renders interface list with
LAN IPs preferred for the copy-paste local-LAN endpoint.
feat(managed-config): auto-sync hostname from kiosk name
When admin renames a managed-image kiosk, slugify the name → DNS-safe
hostname and bump managed_config_version so the kiosk applies it on
next heartbeat. Empty form hostname now falls back to slug too, so
DHCP shows the friendly name.
feat(events): forward firmware + OS update outcomes as kiosk.log
Kiosk POSTs `/api/kiosk/event` with topic=kiosk.log on firmware-apply
attempts. Server-side firmware/os-update endpoints also insert into
event_log so admins can audit upgrades without correlating per-source.
Wire schema heartbeat gains reported_hostname + network_interfaces for
Rust import parity.
Kiosks running our pre-built image (managed_image=true at pairing) can
have their hostname, timezone, network (DHCP/static + VLAN), and Wi-Fi
configured from the admin UI. Pull-model: server stores desired-state
JSON, kiosk heartbeat returns pending_config when version exceeds
applied_version, kiosk echoes applied_version back. Wi-Fi PSK encrypted
with the cluster key so ciphertext at rest is shipped to the kiosk
without per-kiosk re-encryption.
Server side only — kiosk Rust applier (betterframe-apply-config helper
+ rollback timer) and pair-initiate marker file are next.
ci(pi-gen): use action's image-path output for asset upload
pi-gen writes the .img.xz into pi-gen-action's own working dir, not our
repo deploy/. Glob never matched. Use steps.pigen.outputs.image-path
directly — no glob needed.