diff --git a/deploy/README.md b/deploy/README.md new file mode 100644 index 0000000..a420061 --- /dev/null +++ b/deploy/README.md @@ -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:///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`. diff --git a/deploy/angie/betterframe.conf b/deploy/angie/betterframe.conf new file mode 100644 index 0000000..b0b8d14 --- /dev/null +++ b/deploy/angie/betterframe.conf @@ -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; + } +} diff --git a/deploy/docker/Dockerfile.server b/deploy/docker/Dockerfile.server new file mode 100644 index 0000000..0622f31 --- /dev/null +++ b/deploy/docker/Dockerfile.server @@ -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"] diff --git a/deploy/docker/docker-compose.yml b/deploy/docker/docker-compose.yml new file mode 100644 index 0000000..28bc226 --- /dev/null +++ b/deploy/docker/docker-compose.yml @@ -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 diff --git a/deploy/systemd/betterframe-kiosk.service b/deploy/systemd/betterframe-kiosk.service new file mode 100644 index 0000000..f71d60c --- /dev/null +++ b/deploy/systemd/betterframe-kiosk.service @@ -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 diff --git a/deploy/systemd/betterframe-server.service b/deploy/systemd/betterframe-server.service new file mode 100644 index 0000000..5d34cb3 --- /dev/null +++ b/deploy/systemd/betterframe-server.service @@ -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 diff --git a/kiosk/src/cec.rs b/kiosk/src/cec.rs new file mode 100644 index 0000000..98ecf3e --- /dev/null +++ b/kiosk/src/cec.rs @@ -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 + } + } +} diff --git a/kiosk/src/main.rs b/kiosk/src/main.rs index e098181..0ed7a61 100644 --- a/kiosk/src/main.rs +++ b/kiosk/src/main.rs @@ -1,11 +1,14 @@ mod server; mod bundle; +mod cec; mod pipeline; mod ui; mod ws_client; pub enum ServerMsg { ReloadBundle, + Standby, + Wake, } use gtk4::prelude::{ApplicationExt, ApplicationExtManual}; diff --git a/kiosk/src/ui.rs b/kiosk/src/ui.rs index 12e8b5e..485af6c 100644 --- a/kiosk/src/ui.rs +++ b/kiosk/src/ui.rs @@ -11,6 +11,7 @@ use gtk4::{self as gtk, Application, ApplicationWindow, Box as GtkBox, Grid, Lab use tracing::{info, warn}; use crate::bundle::KioskBundle; +use crate::cec; use crate::pipeline; use crate::server; use crate::ws_client; @@ -79,7 +80,7 @@ fn activate(app: &Application) { 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 || { for msg in ws_rx { match msg { @@ -88,6 +89,8 @@ fn activate(app: &Application) { let bundle = server::fetch_bundle(&server_for_reload, &key_for_reload); let _ = tx_for_reload.send(WorkerMsg::RenderBundle(bundle)); } + ServerMsg::Standby => { cec::standby(); } + ServerMsg::Wake => { cec::wake(); } } } }); diff --git a/kiosk/src/ws_client.rs b/kiosk/src/ws_client.rs index 627c50f..4dabf93 100644 --- a/kiosk/src/ws_client.rs +++ b/kiosk/src/ws_client.rs @@ -40,6 +40,12 @@ pub fn run(server_url: &str, kiosk_key: &str, tx: Sender) { } else if text.contains("\"type\":\"reload-bundle\"") { info!("ws: reload-bundle received"); 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 { info!("ws: msg: {text}"); } diff --git a/server/src/plugins/service-admin-http/index.ts b/server/src/plugins/service-admin-http/index.ts index 0e98025..4247dda 100644 --- a/server/src/plugins/service-admin-http/index.ts +++ b/server/src/plugins/service-admin-http/index.ts @@ -129,6 +129,25 @@ export class Plugin extends BSBService, typeof Event registerAdminRoutes(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("/readyz", () => { try { diff --git a/server/src/plugins/service-admin-http/routes-admin.ts b/server/src/plugins/service-admin-http/routes-admin.ts index 09dbfe1..a465657 100644 --- a/server/src/plugins/service-admin-http/routes-admin.ts +++ b/server/src/plugins/service-admin-http/routes-admin.ts @@ -748,4 +748,17 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { deps.repo.deleteKiosk(id); 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}` } }); + }); } diff --git a/server/src/web-templates/admin-pages.tsx b/server/src/web-templates/admin-pages.tsx index 3664d7b..e4bd48b 100644 --- a/server/src/web-templates/admin-pages.tsx +++ b/server/src/web-templates/admin-pages.tsx @@ -750,6 +750,15 @@ export function KioskEditPage(props: KioskEditProps) {
Paired: {k.paired_at ? formatTime(k.paired_at) : "—"}
Last seen: {k.last_seen_at ? formatTime(k.last_seen_at) : "Never"}
+
+
Display Power (CEC)
+
+ +
+
+ +
+
{/* Associated displays */}