fix(deploy): require proxied local services

Bind native backend services and Node-RED to loopback so Angie remains the public auth boundary. Keep Docker on an internal compose network and stop kiosk fallback to a layout when display default is none.
This commit is contained in:
Mitchell R 2026-05-11 09:51:00 +02:00
parent 026325ccd0
commit 96d7cc45ba
No known key found for this signature in database
14 changed files with 147 additions and 27 deletions

View file

@ -5,14 +5,15 @@
### Server ### Server
```bash ```bash
# Install Node.js 23 # Install Node.js 23 + Node-RED
curl -fsSL https://deb.nodesource.com/setup_23.x | sudo bash - curl -fsSL https://deb.nodesource.com/setup_23.x | sudo bash -
sudo apt install -y nodejs build-essential sudo apt install -y nodejs build-essential
sudo npm install -g --unsafe-perm node-red
# Create user + dirs # Create user + dirs
sudo useradd -r -m -d /var/lib/betterframe betterframe sudo useradd -r -m -d /var/lib/betterframe betterframe
sudo mkdir -p /opt/betterframe /var/log/betterframe /etc/betterframe sudo mkdir -p /opt/betterframe /var/log/betterframe /etc/betterframe /var/lib/betterframe/nodered
sudo chown betterframe:betterframe /var/lib/betterframe /var/log/betterframe sudo chown -R betterframe:betterframe /var/lib/betterframe /var/log/betterframe
# Deploy code # Deploy code
sudo git clone https://github.com/BetterCorp/BetterFrame.git /opt/betterframe 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 -u betterframe npm run build
sudo cp sec-config.yaml /opt/betterframe/server/sec-config.yaml 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-server.service /etc/systemd/system/
sudo cp deploy/systemd/betterframe-nodered.service /etc/systemd/system/
sudo systemctl daemon-reload 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 ### Kiosk
```bash ```bash
@ -63,6 +70,9 @@ sudo systemctl reload angie
The Angie config gates `/nrdp/*` with the admin session/API-key auth-check 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. endpoint and `/in/kiosk/*` with the kiosk Bearer-key auth-check endpoint.
Access: `http://<pi-ip>/setup` for first-run. Kiosks should use the proxy URL
(`http://<pi-ip>` or `http://betterframe.local`), not direct backend ports.
## Docker ## Docker
```bash ```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. Kiosk still runs natively on the Pi (needs Wayland/HDMI), not in Docker.
The Compose stack uses `deploy/angie/betterframe.docker.conf` because service The Compose stack uses `deploy/angie/betterframe.docker.conf` and
names, not `127.0.0.1`, are the correct upstreams inside the Docker network. `deploy/docker/sec-config.yaml` because service names, not `127.0.0.1`, are the
correct upstreams inside the Docker network.
Access: `http://<pi-ip>/setup` for first-run. Access: `http://<pi-ip>/setup` for first-run.

View file

@ -2,7 +2,7 @@
# #
# Place in /etc/angie/conf.d/betterframe.conf or /etc/nginx/conf.d/betterframe.conf # 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 # 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) # Upstreams (BSB services)
upstream betterframe_admin { server 127.0.0.1:18080; keepalive 16; } upstream betterframe_admin { server 127.0.0.1:18080; keepalive 16; }

View file

@ -21,7 +21,7 @@ services:
restart: unless-stopped restart: unless-stopped
volumes: volumes:
- betterframe-data:/var/lib/betterframe - betterframe-data:/var/lib/betterframe
- ../../sec-config.yaml:/app/server/sec-config.yaml:ro - ./sec-config.yaml:/app/server/sec-config.yaml:ro
expose: expose:
- "18080" - "18080"
- "18081" - "18081"

View file

@ -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

View file

@ -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;

View file

@ -10,6 +10,7 @@ Type=simple
WorkingDirectory=%h/.betterframe-kiosk WorkingDirectory=%h/.betterframe-kiosk
Environment=XDG_RUNTIME_DIR=/run/user/%U Environment=XDG_RUNTIME_DIR=/run/user/%U
Environment=WAYLAND_DISPLAY=wayland-0 Environment=WAYLAND_DISPLAY=wayland-0
Environment=NO_AT_BRIDGE=1
Environment=GST_DEBUG=1 Environment=GST_DEBUG=1
ExecStart=/opt/betterframe/kiosk/betterframe-kiosk ExecStart=/opt/betterframe/kiosk/betterframe-kiosk
Restart=on-failure Restart=on-failure

View file

@ -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

View file

@ -34,8 +34,8 @@ discover_server() {
fi fi
local candidates=( local candidates=(
"http://localhost:18081" "http://localhost"
"http://betterframe.local:18081" "http://betterframe.local"
"https://frame.betterportal.cloud" "https://frame.betterportal.cloud"
) )

View file

@ -32,8 +32,8 @@ pub fn discover_server(override_url: Option<&str>) -> String {
} }
let candidates = [ let candidates = [
"http://localhost:18081", "http://localhost",
"http://betterframe.local:18081", "http://betterframe.local",
"https://frame.betterportal.cloud", "https://frame.betterportal.cloud",
]; ];

View file

@ -175,22 +175,23 @@ fn show_pairing_code(window: &ApplicationWindow, code: &str) {
} }
fn render_bundle(window: &ApplicationWindow, bundle: KioskBundle) { fn render_bundle(window: &ApplicationWindow, bundle: KioskBundle) {
let layout = bundle.layouts.iter() let layout = match bundle.display.default_layout_id {
.find(|l| l.is_default) Some(default_layout_id) => bundle.layouts.iter()
.or_else(|| bundle.layouts.first()); .find(|l| l.id == default_layout_id)
.or_else(|| bundle.layouts.iter().find(|l| l.is_default)),
None => None,
};
let Some(layout) = layout else { let Some(layout) = layout else {
warn!("no layouts in bundle"); warn!("display has no default layout");
WARM_CAMERAS.with(|w| { clear_warm_cameras();
for (_, (pipe, _)) in w.borrow().iter() { pipeline::stop(pipe); }
w.borrow_mut().clear();
});
show_logo(window); show_logo(window);
return; return;
}; };
if layout.cells.is_empty() { if layout.cells.is_empty() {
warn!("layout has no cells"); warn!("layout has no cells");
clear_warm_cameras();
show_logo(window); show_logo(window);
return; return;
} }
@ -296,6 +297,13 @@ fn render_bundle(window: &ApplicationWindow, bundle: KioskBundle) {
window.set_child(Some(&grid)); 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. /// Returns the paintable for a camera, creating a warm pipeline if missing.
fn ensure_warm( fn ensure_warm(
cam_id: u32, cam_id: u32,

View file

@ -84,7 +84,7 @@ fn build_ws_url(http_url: &str, token: &str) -> String {
format!("ws://{http_url}") 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_port = base.rsplit(':').next().unwrap_or("");
let base = if base_port == "18081" { let base = if base_port == "18081" {
base.replace(":18081", ":18082") base.replace(":18081", ":18082")

View file

@ -30,7 +30,7 @@ default:
plugin: service-admin-http plugin: service-admin-http
enabled: true enabled: true
config: config:
host: 0.0.0.0 host: 127.0.0.1
port: 18080 port: 18080
# Secrets (was service-secrets) # Secrets (was service-secrets)
dataDir: /var/lib/betterframe dataDir: /var/lib/betterframe
@ -50,7 +50,7 @@ default:
plugin: service-api-http plugin: service-api-http
enabled: true enabled: true
config: config:
host: 0.0.0.0 host: 127.0.0.1
port: 18081 port: 18081
codeTtlSeconds: 600 # 10m pairing code TTL codeTtlSeconds: 600 # 10m pairing code TTL
dataDir: /var/lib/betterframe dataDir: /var/lib/betterframe
@ -64,7 +64,7 @@ default:
plugin: service-coordinator-ws plugin: service-coordinator-ws
enabled: true enabled: true
config: config:
host: 0.0.0.0 host: 127.0.0.1
port: 18082 port: 18082
noderedUrl: http://127.0.0.1:1880 noderedUrl: http://127.0.0.1:1880
dataDir: /var/lib/betterframe dataDir: /var/lib/betterframe

View file

@ -29,7 +29,7 @@ import type { SecretsApi } from "../../shared/secrets.js";
const ConfigSchema = av.object( 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), port: av.int().min(1).max(65535).default(18081),
codeTtlSeconds: av.int().min(60).max(3600).default(600), codeTtlSeconds: av.int().min(60).max(3600).default(600),
// Secrets + auth config (shared with admin-http for now) // Secrets + auth config (shared with admin-http for now)

View file

@ -31,7 +31,7 @@ import { setCoordinator } from "../../shared/coordinator-registry.js";
const ConfigSchema = av.object( 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), port: av.int().min(1).max(65535).default(18082),
noderedUrl: av.string().minLength(1).default("http://127.0.0.1:1880"), noderedUrl: av.string().minLength(1).default("http://127.0.0.1:1880"),
dataDir: av.string().minLength(1).default("/var/lib/betterframe"), dataDir: av.string().minLength(1).default("/var/lib/betterframe"),