diff --git a/deploy/README.md b/deploy/README.md index 338cd3c..b6295a3 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -5,14 +5,15 @@ ### Server ```bash -# Install Node.js 23 +# Install Node.js 23 + Node-RED curl -fsSL https://deb.nodesource.com/setup_23.x | sudo bash - sudo apt install -y nodejs build-essential +sudo npm install -g --unsafe-perm node-red # 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 +sudo mkdir -p /opt/betterframe /var/log/betterframe /etc/betterframe /var/lib/betterframe/nodered +sudo chown -R betterframe:betterframe /var/lib/betterframe /var/log/betterframe # Deploy code sudo git clone https://github.com/BetterCorp/BetterFrame.git /opt/betterframe @@ -21,12 +22,18 @@ sudo -u betterframe npm install sudo -u betterframe npm run build sudo cp sec-config.yaml /opt/betterframe/server/sec-config.yaml -# Install systemd unit +# Install systemd units sudo cp deploy/systemd/betterframe-server.service /etc/systemd/system/ +sudo cp deploy/systemd/betterframe-nodered.service /etc/systemd/system/ sudo systemctl daemon-reload -sudo systemctl enable --now betterframe-server +sudo systemctl enable --now betterframe-server betterframe-nodered ``` +The native config binds BetterFrame service ports and Node-RED to `127.0.0.1`. +Do not expose ports `18080`, `18081`, `18082`, or `1880` directly on the LAN. +Use Angie/nginx as the public entry point so `/nrdp/`, `/in/kiosk/`, and the +admin routes get the auth protections in `deploy/angie/betterframe.conf`. + ### Kiosk ```bash @@ -63,6 +70,9 @@ sudo systemctl reload angie The Angie config gates `/nrdp/*` with the admin session/API-key auth-check endpoint and `/in/kiosk/*` with the kiosk Bearer-key auth-check endpoint. +Access: `http:///setup` for first-run. Kiosks should use the proxy URL +(`http://` or `http://betterframe.local`), not direct backend ports. + ## Docker ```bash @@ -70,8 +80,9 @@ docker compose -f deploy/docker/docker-compose.yml up -d ``` Kiosk still runs natively on the Pi (needs Wayland/HDMI), not in Docker. -The Compose stack uses `deploy/angie/betterframe.docker.conf` because service -names, not `127.0.0.1`, are the correct upstreams inside the Docker network. +The Compose stack uses `deploy/angie/betterframe.docker.conf` and +`deploy/docker/sec-config.yaml` because service names, not `127.0.0.1`, are the +correct upstreams inside the Docker network. Access: `http:///setup` for first-run. diff --git a/deploy/angie/betterframe.conf b/deploy/angie/betterframe.conf index a26c29c..53fe6c0 100644 --- a/deploy/angie/betterframe.conf +++ b/deploy/angie/betterframe.conf @@ -2,7 +2,7 @@ # # 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. +# bind to 127.0.0.1; Angie/nginx is the only public HTTP edge. # Upstreams (BSB services) upstream betterframe_admin { server 127.0.0.1:18080; keepalive 16; } diff --git a/deploy/docker/docker-compose.yml b/deploy/docker/docker-compose.yml index 32e47ac..2272102 100644 --- a/deploy/docker/docker-compose.yml +++ b/deploy/docker/docker-compose.yml @@ -21,7 +21,7 @@ services: restart: unless-stopped volumes: - betterframe-data:/var/lib/betterframe - - ../../sec-config.yaml:/app/server/sec-config.yaml:ro + - ./sec-config.yaml:/app/server/sec-config.yaml:ro expose: - "18080" - "18081" diff --git a/deploy/docker/sec-config.yaml b/deploy/docker/sec-config.yaml new file mode 100644 index 0000000..2e4290c --- /dev/null +++ b/deploy/docker/sec-config.yaml @@ -0,0 +1,62 @@ +# BSB runtime configuration for the Docker compose stack. +# Backend services bind all interfaces inside the private compose network; +# Angie/nginx is the only published host port. + +default: + observable: + observable-default: + plugin: observable-default + enabled: true + config: {} + events: + events-default: + plugin: events-default + enabled: true + services: + service-store: + plugin: service-store + enabled: true + config: + sqlitePath: /var/lib/betterframe/betterframe.db + + service-admin-http: + plugin: service-admin-http + enabled: true + config: + host: 0.0.0.0 + port: 18080 + dataDir: /var/lib/betterframe + sessionIdleSeconds: 43200 + sessionMaxSeconds: 2592000 + loginLockoutThreshold: 8 + loginLockoutSeconds: 900 + argon2Memory: 65536 + argon2TimeCost: 3 + argon2Parallelism: 2 + cookieName: betterframe_session + totpIssuer: BetterFrame + + service-api-http: + plugin: service-api-http + enabled: true + config: + host: 0.0.0.0 + port: 18081 + codeTtlSeconds: 600 + dataDir: /var/lib/betterframe + argon2Memory: 65536 + argon2TimeCost: 3 + argon2Parallelism: 2 + noderedUrl: http://nodered:1880 + + service-coordinator-ws: + plugin: service-coordinator-ws + enabled: true + config: + host: 0.0.0.0 + port: 18082 + noderedUrl: http://nodered:1880 + dataDir: /var/lib/betterframe + argon2Memory: 65536 + argon2TimeCost: 3 + argon2Parallelism: 2 diff --git a/deploy/nodered/settings.js b/deploy/nodered/settings.js new file mode 100644 index 0000000..3a21367 --- /dev/null +++ b/deploy/nodered/settings.js @@ -0,0 +1,11 @@ +const settings = { + uiHost: "127.0.0.1", + uiPort: Number(process.env.PORT || 1880), + functionGlobalContext: {}, +}; + +if (process.env.NODE_RED_CREDENTIAL_SECRET) { + settings.credentialSecret = process.env.NODE_RED_CREDENTIAL_SECRET; +} + +module.exports = settings; diff --git a/deploy/systemd/betterframe-kiosk.service b/deploy/systemd/betterframe-kiosk.service index f71d60c..b70206d 100644 --- a/deploy/systemd/betterframe-kiosk.service +++ b/deploy/systemd/betterframe-kiosk.service @@ -10,6 +10,7 @@ Type=simple WorkingDirectory=%h/.betterframe-kiosk Environment=XDG_RUNTIME_DIR=/run/user/%U Environment=WAYLAND_DISPLAY=wayland-0 +Environment=NO_AT_BRIDGE=1 Environment=GST_DEBUG=1 ExecStart=/opt/betterframe/kiosk/betterframe-kiosk Restart=on-failure diff --git a/deploy/systemd/betterframe-nodered.service b/deploy/systemd/betterframe-nodered.service new file mode 100644 index 0000000..7895425 --- /dev/null +++ b/deploy/systemd/betterframe-nodered.service @@ -0,0 +1,27 @@ +[Unit] +Description=BetterFrame Node-RED +Documentation=https://github.com/BetterCorp/BetterFrame +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +User=betterframe +Group=betterframe +WorkingDirectory=/var/lib/betterframe/nodered +Environment=NODE_ENV=production +Environment=PORT=1880 +ExecStart=/usr/bin/env node-red --userDir /var/lib/betterframe/nodered --settings /opt/betterframe/deploy/nodered/settings.js +Restart=on-failure +RestartSec=5 +StandardOutput=journal +StandardError=journal + +NoNewPrivileges=true +ProtectSystem=strict +ProtectHome=true +PrivateTmp=true +ReadWritePaths=/var/lib/betterframe/nodered + +[Install] +WantedBy=multi-user.target diff --git a/kiosk/prototype.sh b/kiosk/prototype.sh index 702ccc0..352149c 100644 --- a/kiosk/prototype.sh +++ b/kiosk/prototype.sh @@ -34,8 +34,8 @@ discover_server() { fi local candidates=( - "http://localhost:18081" - "http://betterframe.local:18081" + "http://localhost" + "http://betterframe.local" "https://frame.betterportal.cloud" ) diff --git a/kiosk/src/server.rs b/kiosk/src/server.rs index 501c081..9e24776 100644 --- a/kiosk/src/server.rs +++ b/kiosk/src/server.rs @@ -32,8 +32,8 @@ pub fn discover_server(override_url: Option<&str>) -> String { } let candidates = [ - "http://localhost:18081", - "http://betterframe.local:18081", + "http://localhost", + "http://betterframe.local", "https://frame.betterportal.cloud", ]; diff --git a/kiosk/src/ui.rs b/kiosk/src/ui.rs index 7aa4f1b..84a4cbf 100644 --- a/kiosk/src/ui.rs +++ b/kiosk/src/ui.rs @@ -175,22 +175,23 @@ fn show_pairing_code(window: &ApplicationWindow, code: &str) { } fn render_bundle(window: &ApplicationWindow, bundle: KioskBundle) { - let layout = bundle.layouts.iter() - .find(|l| l.is_default) - .or_else(|| bundle.layouts.first()); + let layout = match bundle.display.default_layout_id { + Some(default_layout_id) => bundle.layouts.iter() + .find(|l| l.id == default_layout_id) + .or_else(|| bundle.layouts.iter().find(|l| l.is_default)), + None => None, + }; let Some(layout) = layout else { - warn!("no layouts in bundle"); - WARM_CAMERAS.with(|w| { - for (_, (pipe, _)) in w.borrow().iter() { pipeline::stop(pipe); } - w.borrow_mut().clear(); - }); + warn!("display has no default layout"); + clear_warm_cameras(); show_logo(window); return; }; if layout.cells.is_empty() { warn!("layout has no cells"); + clear_warm_cameras(); show_logo(window); return; } @@ -296,6 +297,13 @@ fn render_bundle(window: &ApplicationWindow, bundle: KioskBundle) { window.set_child(Some(&grid)); } +fn clear_warm_cameras() { + WARM_CAMERAS.with(|w| { + for (_, (pipe, _)) in w.borrow().iter() { pipeline::stop(pipe); } + w.borrow_mut().clear(); + }); +} + /// Returns the paintable for a camera, creating a warm pipeline if missing. fn ensure_warm( cam_id: u32, diff --git a/kiosk/src/ws_client.rs b/kiosk/src/ws_client.rs index 4dabf93..308cce1 100644 --- a/kiosk/src/ws_client.rs +++ b/kiosk/src/ws_client.rs @@ -84,7 +84,7 @@ fn build_ws_url(http_url: &str, token: &str) -> String { format!("ws://{http_url}") }; - // coordinator-ws runs on a different port (18082 vs api-http on 18081) + // Direct dev URLs may point at api-http; normal installs go through Angie. let base_port = base.rsplit(':').next().unwrap_or(""); let base = if base_port == "18081" { base.replace(":18081", ":18082") diff --git a/sec-config.yaml b/sec-config.yaml index fa8b354..1277526 100644 --- a/sec-config.yaml +++ b/sec-config.yaml @@ -30,7 +30,7 @@ default: plugin: service-admin-http enabled: true config: - host: 0.0.0.0 + host: 127.0.0.1 port: 18080 # Secrets (was service-secrets) dataDir: /var/lib/betterframe @@ -50,7 +50,7 @@ default: plugin: service-api-http enabled: true config: - host: 0.0.0.0 + host: 127.0.0.1 port: 18081 codeTtlSeconds: 600 # 10m pairing code TTL dataDir: /var/lib/betterframe @@ -64,7 +64,7 @@ default: plugin: service-coordinator-ws enabled: true config: - host: 0.0.0.0 + host: 127.0.0.1 port: 18082 noderedUrl: http://127.0.0.1:1880 dataDir: /var/lib/betterframe diff --git a/server/src/plugins/service-api-http/index.ts b/server/src/plugins/service-api-http/index.ts index 3e3e47b..b3160a8 100644 --- a/server/src/plugins/service-api-http/index.ts +++ b/server/src/plugins/service-api-http/index.ts @@ -29,7 +29,7 @@ import type { SecretsApi } from "../../shared/secrets.js"; const ConfigSchema = av.object( { - host: av.string().default("0.0.0.0"), + host: av.string().default("127.0.0.1"), port: av.int().min(1).max(65535).default(18081), codeTtlSeconds: av.int().min(60).max(3600).default(600), // Secrets + auth config (shared with admin-http for now) diff --git a/server/src/plugins/service-coordinator-ws/index.ts b/server/src/plugins/service-coordinator-ws/index.ts index 24fa542..4e3e7a1 100644 --- a/server/src/plugins/service-coordinator-ws/index.ts +++ b/server/src/plugins/service-coordinator-ws/index.ts @@ -31,7 +31,7 @@ import { setCoordinator } from "../../shared/coordinator-registry.js"; const ConfigSchema = av.object( { - host: av.string().default("0.0.0.0"), + host: av.string().default("127.0.0.1"), port: av.int().min(1).max(65535).default(18082), noderedUrl: av.string().minLength(1).default("http://127.0.0.1:1880"), dataDir: av.string().minLength(1).default("/var/lib/betterframe"),