feat: deployment artifacts + CEC relay + auth-check endpoint

Deployment (deploy/):
- systemd units for server (system) and kiosk (user session)
- Angie/nginx proxy config — routes admin, api, ws, node-red
- Dockerfile + docker-compose for containerized deployment
- deploy/README.md with install instructions

Auth:
- /api/admin/_check endpoint for proxy auth_request subrequest
- Returns 200 if admin session valid, 401/403 otherwise
- Sets X-BetterFrame-User header for upstream

CEC (Pi5 HDMI control):
- kiosk/src/cec.rs wraps cec-ctl subprocess
- Standby/wake/active-source commands
- WS message types "standby" / "wake" dispatched to CEC
- Admin UI: Wake/Standby buttons on kiosk edit page
- Server sendToKiosk via coordinator
This commit is contained in:
Mitchell R 2026-05-10 22:45:56 +02:00
parent 766bf8dee0
commit cbb1683c5d
13 changed files with 460 additions and 1 deletions

84
deploy/README.md Normal file
View file

@ -0,0 +1,84 @@
# BetterFrame deployment
## Native install (Raspberry Pi)
### Server
```bash
# Install Node.js 23
curl -fsSL https://deb.nodesource.com/setup_23.x | sudo bash -
sudo apt install -y nodejs build-essential
# Create user + dirs
sudo useradd -r -m -d /var/lib/betterframe betterframe
sudo mkdir -p /opt/betterframe /var/log/betterframe /etc/betterframe
sudo chown betterframe:betterframe /var/lib/betterframe /var/log/betterframe
# Deploy code
sudo git clone https://github.com/BetterCorp/BetterFrame.git /opt/betterframe
cd /opt/betterframe
sudo -u betterframe npm install
sudo cp sec-config.yaml /opt/betterframe/server/sec-config.yaml
# Install systemd unit
sudo cp deploy/systemd/betterframe-server.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now betterframe-server
```
### Kiosk
```bash
# Install GTK4 + GStreamer + WebKit
sudo apt install -y libgtk-4-dev libgstreamer1.0-dev \
libgstreamer-plugins-base1.0-dev gstreamer1.0-plugins-good \
gstreamer1.0-plugins-bad gstreamer1.0-libav \
gstreamer1.0-gtk4 libwebkitgtk-6.0-dev libssl-dev
# Install Rust
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
source ~/.cargo/env
# Build
cd ~/betterframe/kiosk
cargo build --release
sudo install -Dm755 target/release/betterframe-kiosk /opt/betterframe/kiosk/betterframe-kiosk
# Install systemd user unit
mkdir -p ~/.config/systemd/user
cp deploy/systemd/betterframe-kiosk.service ~/.config/systemd/user/
systemctl --user daemon-reload
systemctl --user enable --now betterframe-kiosk
```
### Angie proxy
```bash
sudo apt install -y angie # or nginx
sudo cp deploy/angie/betterframe.conf /etc/angie/conf.d/
sudo systemctl reload angie
```
## Docker
```bash
docker compose -f deploy/docker/docker-compose.yml up -d
```
Kiosk still runs natively on the Pi (needs Wayland/HDMI), not in Docker.
Access: `http://<pi-ip>/setup` for first-run.
## Production secrets
For production, store the server key via `systemd-creds`:
```bash
sudo systemd-creds encrypt --name=betterframe-secret \
/etc/betterframe/secret.key.plain /etc/betterframe/secret.key
sudo chmod 0600 /etc/betterframe/secret.key
sudo chown root:root /etc/betterframe/secret.key
```
The systemd unit's `LoadCredential=` directive injects this into the
service's `$CREDENTIALS_DIRECTORY`.

View file

@ -0,0 +1,113 @@
# BetterFrame Angie/nginx config — routes admin, kiosk-api, ws, node-red.
#
# Place in /etc/angie/conf.d/betterframe.conf or /etc/nginx/conf.d/betterframe.conf
# Run on the Pi alongside the server. TLS termination here; backend services
# bind to 0.0.0.0 but firewall should restrict to localhost in production.
# Upstreams (BSB services)
upstream betterframe_admin { server 127.0.0.1:18080; keepalive 16; }
upstream betterframe_api { server 127.0.0.1:18081; keepalive 16; }
upstream betterframe_ws { server 127.0.0.1:18082; }
upstream betterframe_nodered { server 127.0.0.1:1880; keepalive 8; }
# Rate limiting for public endpoints
limit_req_zone $binary_remote_addr zone=bf_public:10m rate=30r/s;
server {
listen 80;
listen [::]:80;
server_name betterframe.local _;
# In production: redirect to HTTPS
# return 301 https://$host$request_uri;
# For now: serve plain HTTP
client_max_body_size 16M;
# ---- Admin UI + admin API (session-authenticated) ----
location /admin/ {
proxy_pass http://betterframe_admin;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_http_version 1.1;
proxy_set_header Connection "";
}
location = /admin { return 301 /admin/; }
location /setup { proxy_pass http://betterframe_admin; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; }
location /auth/ { proxy_pass http://betterframe_admin; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; }
location /static/ { proxy_pass http://betterframe_admin; }
# ---- Kiosk REST API (Bearer kiosk-key) ----
location /api/kiosk/ {
proxy_pass http://betterframe_api;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_http_version 1.1;
proxy_set_header Connection "";
}
location /api/pair/ {
# Rate-limit pairing initiate to deter brute force
limit_req zone=bf_public burst=10 nodelay;
proxy_pass http://betterframe_api;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
# ---- Admin API (session-authenticated) ----
location /api/admin/ {
proxy_pass http://betterframe_admin;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
# ---- Live kiosk WebSocket channel ----
location /ws/kiosk {
proxy_pass http://betterframe_ws;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_read_timeout 86400s; # long-lived
proxy_send_timeout 86400s;
}
# ---- Node-RED dashboard (admin-only) ----
location /nrdp/ {
# auth_request /api/admin/_check; # enable when auth-check endpoint ready
rewrite ^/nrdp/(.*) /$1 break;
proxy_pass http://betterframe_nodered;
proxy_set_header Host $host;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
# ---- Node-RED HTTP-in (public, rate-limited) ----
location /in/public/ {
limit_req zone=bf_public burst=20 nodelay;
rewrite ^/in/public/(.*) /public/$1 break;
proxy_pass http://betterframe_nodered;
}
# ---- Node-RED HTTP-in (kiosk-gated) ----
location /in/kiosk/ {
# Bearer kiosk-key validated by Node-RED flow
rewrite ^/in/kiosk/(.*) /kiosk/$1 break;
proxy_pass http://betterframe_nodered;
}
# ---- Health/readiness/version (public) ----
location ~ ^/(healthz|readyz|version)$ {
proxy_pass http://betterframe_admin;
}
# ---- Root redirect ----
location = / {
proxy_pass http://betterframe_admin;
}
}

View file

@ -0,0 +1,45 @@
# BetterFrame server image — Node 23 + native deps for argon2/sqlite
FROM node:23-bookworm-slim AS builder
WORKDIR /app
# Install build deps for argon2
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential python3 \
&& rm -rf /var/lib/apt/lists/*
COPY package.json package-lock.json ./
COPY server/package.json ./server/
COPY tsconfig.base.json ./
RUN npm ci --ignore-scripts && npm rebuild argon2
COPY server ./server
# ---- Runtime image ----
FROM node:23-bookworm-slim
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates \
&& rm -rf /var/lib/apt/lists/*
RUN useradd -m -d /var/lib/betterframe -s /bin/false betterframe
WORKDIR /app
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/server ./server
COPY --from=builder /app/tsconfig.base.json ./
COPY --from=builder /app/package.json ./
# Default data dir
RUN mkdir -p /var/lib/betterframe && chown betterframe:betterframe /var/lib/betterframe
VOLUME /var/lib/betterframe
EXPOSE 18080 18081 18082
USER betterframe
WORKDIR /app/server
ENV NODE_OPTIONS=--import=tsx
CMD ["node", "--import", "tsx", "/app/node_modules/@bsb/base/lib/scripts/bsb-plugin-cli.js", "start"]

View file

@ -0,0 +1,65 @@
# BetterFrame stack — server + Angie proxy + Node-RED.
# Kiosk runs on the Pi natively (not in Docker, needs Wayland/HDMI).
#
# Usage:
# docker compose -f deploy/docker/docker-compose.yml up -d
#
# Volumes:
# betterframe-data — sqlite DB + secret.key
# nodered-data — Node-RED flows
#
# Bind 0.0.0.0:80 on the host (Angie). Backend services only reachable
# from within the Docker network.
version: "3.8"
services:
server:
build:
context: ../..
dockerfile: deploy/docker/Dockerfile.server
container_name: betterframe-server
restart: unless-stopped
volumes:
- betterframe-data:/var/lib/betterframe
- ../../sec-config.yaml:/app/sec-config.yaml:ro
expose:
- "18080"
- "18081"
- "18082"
networks:
- betterframe
angie:
image: nginx:alpine
container_name: betterframe-angie
restart: unless-stopped
depends_on:
- server
- nodered
ports:
- "80:80"
volumes:
- ../angie/betterframe.conf:/etc/nginx/conf.d/default.conf:ro
networks:
- betterframe
nodered:
image: nodered/node-red:latest
container_name: betterframe-nodered
restart: unless-stopped
environment:
- TZ=UTC
volumes:
- nodered-data:/data
expose:
- "1880"
networks:
- betterframe
volumes:
betterframe-data:
nodered-data:
networks:
betterframe:
driver: bridge

View file

@ -0,0 +1,21 @@
[Unit]
Description=BetterFrame Kiosk
Documentation=https://github.com/BetterCorp/BetterFrame
After=graphical-session.target network-online.target
Wants=network-online.target
PartOf=graphical-session.target
[Service]
Type=simple
WorkingDirectory=%h/.betterframe-kiosk
Environment=XDG_RUNTIME_DIR=/run/user/%U
Environment=WAYLAND_DISPLAY=wayland-0
Environment=GST_DEBUG=1
ExecStart=/opt/betterframe/kiosk/betterframe-kiosk
Restart=on-failure
RestartSec=3
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=graphical-session.target

View file

@ -0,0 +1,29 @@
[Unit]
Description=BetterFrame Server (BSB)
Documentation=https://github.com/BetterCorp/BetterFrame
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=betterframe
Group=betterframe
WorkingDirectory=/opt/betterframe/server
Environment=NODE_ENV=production
Environment=NODE_OPTIONS=--import tsx
ExecStart=/usr/bin/node --import tsx /opt/betterframe/node_modules/@bsb/base/lib/scripts/bsb-plugin-cli.js start
Restart=on-failure
RestartSec=5
StandardOutput=journal
StandardError=journal
# Security hardening
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
ReadWritePaths=/var/lib/betterframe /var/log/betterframe
LoadCredential=betterframe-secret:/etc/betterframe/secret.key
[Install]
WantedBy=multi-user.target

49
kiosk/src/cec.rs Normal file
View file

@ -0,0 +1,49 @@
//! CEC (HDMI Consumer Electronics Control) — manages display power state
//! via `cec-ctl` subprocess. v4l-utils package provides cec-ctl on Pi5.
//!
//! Commands:
//! - standby: tell TV to sleep
//! - image-view-on: wake TV
//! - is-active-source: query state
use std::process::Command;
use tracing::{info, warn};
const CEC_DEVICE: &str = "/dev/cec0";
/// Send CEC standby (sleep) to all connected devices.
pub fn standby() -> bool {
info!("cec: standby");
run_cec(&["--standby", "--to", "0"])
}
/// Send CEC image-view-on (wake) to TV.
pub fn wake() -> bool {
info!("cec: wake");
run_cec(&["--image-view-on", "--to", "0"])
}
/// Switch HDMI input on TV to this device.
pub fn become_active_source() -> bool {
info!("cec: become active source");
run_cec(&["--active-source", "phys-addr=0.0.0.0"])
}
fn run_cec(args: &[&str]) -> bool {
match Command::new("cec-ctl")
.arg("-d")
.arg(CEC_DEVICE)
.args(args)
.output()
{
Ok(out) if out.status.success() => true,
Ok(out) => {
warn!("cec-ctl failed: {}", String::from_utf8_lossy(&out.stderr));
false
}
Err(e) => {
warn!("cec-ctl not available: {e}");
false
}
}
}

View file

@ -1,11 +1,14 @@
mod server; mod server;
mod bundle; mod bundle;
mod cec;
mod pipeline; mod pipeline;
mod ui; mod ui;
mod ws_client; mod ws_client;
pub enum ServerMsg { pub enum ServerMsg {
ReloadBundle, ReloadBundle,
Standby,
Wake,
} }
use gtk4::prelude::{ApplicationExt, ApplicationExtManual}; use gtk4::prelude::{ApplicationExt, ApplicationExtManual};

View file

@ -11,6 +11,7 @@ use gtk4::{self as gtk, Application, ApplicationWindow, Box as GtkBox, Grid, Lab
use tracing::{info, warn}; use tracing::{info, warn};
use crate::bundle::KioskBundle; use crate::bundle::KioskBundle;
use crate::cec;
use crate::pipeline; use crate::pipeline;
use crate::server; use crate::server;
use crate::ws_client; use crate::ws_client;
@ -79,7 +80,7 @@ fn activate(app: &Application) {
ws_client::run(&server_ws, &key_ws, ws_tx); ws_client::run(&server_ws, &key_ws, ws_tx);
}); });
// Listen for WS messages and re-fetch bundle on reload // Listen for WS messages and dispatch
std::thread::spawn(move || { std::thread::spawn(move || {
for msg in ws_rx { for msg in ws_rx {
match msg { match msg {
@ -88,6 +89,8 @@ fn activate(app: &Application) {
let bundle = server::fetch_bundle(&server_for_reload, &key_for_reload); let bundle = server::fetch_bundle(&server_for_reload, &key_for_reload);
let _ = tx_for_reload.send(WorkerMsg::RenderBundle(bundle)); let _ = tx_for_reload.send(WorkerMsg::RenderBundle(bundle));
} }
ServerMsg::Standby => { cec::standby(); }
ServerMsg::Wake => { cec::wake(); }
} }
} }
}); });

View file

@ -40,6 +40,12 @@ pub fn run(server_url: &str, kiosk_key: &str, tx: Sender<ServerMsg>) {
} else if text.contains("\"type\":\"reload-bundle\"") { } else if text.contains("\"type\":\"reload-bundle\"") {
info!("ws: reload-bundle received"); info!("ws: reload-bundle received");
let _ = tx.send(ServerMsg::ReloadBundle); let _ = tx.send(ServerMsg::ReloadBundle);
} else if text.contains("\"type\":\"standby\"") {
info!("ws: standby received");
let _ = tx.send(ServerMsg::Standby);
} else if text.contains("\"type\":\"wake\"") {
info!("ws: wake received");
let _ = tx.send(ServerMsg::Wake);
} else { } else {
info!("ws: msg: {text}"); info!("ws: msg: {text}");
} }

View file

@ -129,6 +129,25 @@ export class Plugin extends BSBService<InstanceType<typeof Config>, typeof Event
registerAdminRoutes(app, deps); registerAdminRoutes(app, deps);
registerAccountRoutes(app, deps); registerAccountRoutes(app, deps);
// Auth-check endpoint for Angie auth_request subrequest.
// Returns 200 if session cookie is valid + admin role, 401 otherwise.
app.get("/api/admin/_check", (event) => {
const cookie = event.req.headers.get("cookie") ?? "";
const match = cookie.match(new RegExp(`${deps.cookieName}=([^;]+)`));
if (!match) return new Response(null, { status: 401 });
const resolved = deps.auth.resolveSession(match[1]!);
if (!resolved || resolved.session.totp_pending) {
return new Response(null, { status: 401 });
}
if (resolved.user.role !== "admin") {
return new Response(null, { status: 403 });
}
return new Response(null, {
status: 200,
headers: { "x-betterframe-user": resolved.user.username },
});
});
app.get("/healthz", () => ({ status: "ok" })); app.get("/healthz", () => ({ status: "ok" }));
app.get("/readyz", () => { app.get("/readyz", () => {
try { try {

View file

@ -748,4 +748,17 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
deps.repo.deleteKiosk(id); deps.repo.deleteKiosk(id);
return new Response(null, { status: 302, headers: { location: "/admin/kiosks" } }); return new Response(null, { status: 302, headers: { location: "/admin/kiosks" } });
}); });
// ---- CEC power commands -----------------------------------------------
app.post("/admin/kiosks/:id/power/standby", (event) => {
const id = Number(getRouterParam(event, "id"));
getCoordinator().sendToKiosk(id, { type: "standby" });
return new Response(null, { status: 302, headers: { location: `/admin/kiosks/${id}` } });
});
app.post("/admin/kiosks/:id/power/wake", (event) => {
const id = Number(getRouterParam(event, "id"));
getCoordinator().sendToKiosk(id, { type: "wake" });
return new Response(null, { status: 302, headers: { location: `/admin/kiosks/${id}` } });
});
} }

View file

@ -750,6 +750,15 @@ export function KioskEditPage(props: KioskEditProps) {
<div>Paired: {k.paired_at ? formatTime(k.paired_at) : "—"}</div> <div>Paired: {k.paired_at ? formatTime(k.paired_at) : "—"}</div>
<div>Last seen: {k.last_seen_at ? formatTime(k.last_seen_at) : "Never"}</div> <div>Last seen: {k.last_seen_at ? formatTime(k.last_seen_at) : "Never"}</div>
</div> </div>
<div style="margin-top:1rem; padding-top:1rem; border-top:1px solid #eee">
<div style="font-size:0.85rem; font-weight:600; margin-bottom:0.5rem">Display Power (CEC)</div>
<form method="post" action={`/admin/kiosks/${k.id}/power/wake`} style="display:inline">
<button type="submit" class="btn btn-sm">Wake</button>
</form>
<form method="post" action={`/admin/kiosks/${k.id}/power/standby`} style="display:inline; margin-left:0.5rem">
<button type="submit" class="btn btn-sm btn-ghost">Standby</button>
</form>
</div>
</div> </div>
{/* Associated displays */} {/* Associated displays */}