fix(proxy): split Node-RED route surfaces

Route backend, kiosk ingest, kiosk dashboards, and public Node-RED HTTP-in separately. Keep Node-RED editor under admin auth and attach kiosk auth when kiosk loads protected dashboard URLs.
This commit is contained in:
Mitchell R 2026-05-11 10:44:45 +02:00
parent 346ddfa3a4
commit 820e0a5945
No known key found for this signature in database
7 changed files with 98 additions and 22 deletions

View file

@ -47,6 +47,11 @@
↕ BSB event bus (events-default in-memory; swap to RabbitMQ later if needed) ↕ BSB event bus (events-default in-memory; swap to RabbitMQ later if needed)
``` ```
Route correction: `/nrdp/*` is the admin-auth Node-RED editor, `/dash/*` is
kiosk-auth Node-RED dashboard content, `/in/kiosk/*` is kiosk-auth ingest
(ONVIF/GPIO/etc.), and otherwise-unmatched root paths are public Node-RED
HTTP-in endpoints for user webhooks/actions.
## stack & languages ## stack & languages
- **TS / Node.js ≥23** — server. BSB v9 framework. h3 v2 for HTTP. node:sqlite built-in. argon2 + otpauth for crypto/totp. jsx-htmx for SSR. - **TS / Node.js ≥23** — server. BSB v9 framework. h3 v2 for HTTP. node:sqlite built-in. argon2 + otpauth for crypto/totp. jsx-htmx for SSR.
- **TS** — Node-RED custom nodes (`bf-*`). - **TS** — Node-RED custom nodes (`bf-*`).
@ -64,14 +69,17 @@
7. **multi-display ready** but v1 = index 0. all api carries display_id. 7. **multi-display ready** but v1 = index 0. all api carries display_id.
8. **node-red owns ALL rules**. dropped the py engine plan. `event_log` table kept. 8. **node-red owns ALL rules**. dropped the py engine plan. `event_log` table kept.
9. **shortlinks live in node-red**, not our db. 9. **shortlinks live in node-red**, not our db.
10. **3 auth tiers** at proxy via `auth_request`: 10. **Proxy route surfaces**:
- public `/in/public/*` `/s/*` — rate-limited - public `/in/public/*` `/s/*` — rate-limited
- kiosk-key `/api/kiosk/*` `/in/kiosk/*` `/dash/*` (kiosks) - kiosk-key `/api/kiosk/*` `/in/kiosk/*` `/dash/*` (kiosks)
- admin session+TOTP `/admin/*` `/api/admin/*` `/nrdp/*` `/dash/*` (humans) - admin session+TOTP `/admin/*` `/api/admin/*` `/nrdp/*` (humans)
Node-RED external HTTP-in has exactly two ingress bases: `/in/public/*` BetterFrame web/API stays on backend routes; kiosk-specific ingest/control
for user webhooks/actions and `/in/kiosk/*` for kiosk-authenticated data. uses `/api/kiosk/*`, `/ws/kiosk`, `/in/kiosk/*`, and `/dash/*`; dashboards
Angie strips that base before proxying, so a Node-RED route `/test1` is are kiosk-only; otherwise-unmatched root paths are public Node-RED HTTP-in
called as `/in/public/test1` or `/in/kiosk/test1`. URLs for user webhooks/actions. `/api/*` and `/ws/*` must not fall through
to Node-RED. Angie strips `/in/public` and `/in/kiosk` before proxying, so
a Node-RED route `/test1` is callable as `/test1`, `/in/public/test1`, or
`/in/kiosk/test1` depending on the desired auth surface.
11. **labels** = routing primitive. cams+layouts+kiosks carry labels. 2 binding kinds: 11. **labels** = routing primitive. cams+layouts+kiosks carry labels. 2 binding kinds:
- `consume`: any kiosk w/label may render - `consume`: any kiosk w/label may render
- `operate`: exactly ONE kiosk authoritative (composite PK incl role) - `operate`: exactly ONE kiosk authoritative (composite PK incl role)

View file

@ -29,20 +29,25 @@ Access first-run setup at:
http://<pi-ip>/setup http://<pi-ip>/setup
``` ```
Node-RED is reachable only through: Node-RED editor is reachable only through:
```text ```text
http://<pi-ip>/nrdp/ http://<pi-ip>/nrdp/
``` ```
Node-RED HTTP-in routes have two public base URLs: The proxy has four route surfaces:
- Public webhook/user actions: `http://<pi-ip>/in/public/<node-red-path>` - BetterFrame web/API: `/`, `/setup`, `/admin/*`, `/auth/*`, `/static/*`,
- Kiosk-authenticated ingress: `http://<pi-ip>/in/kiosk/<node-red-path>` `/api/admin/*`, `/api/kiosk/*`, `/api/pair/*`, `/ws/kiosk`
- Kiosk-only Node-RED ingress: `/in/kiosk/<node-red-path>`
- Kiosk-only Node-RED dashboards: `/dash/*`
- Public Node-RED HTTP-in URLs: any otherwise-unmatched root path, plus
`/in/public/<node-red-path>`
For example, a Node-RED `http in` node at `/test1` is called as For example, a Node-RED `http in` node at `/test1` is public at
`http://<pi-ip>/in/public/test1` for public traffic, or `http://<pi-ip>/test1` and also available at
`http://<pi-ip>/in/kiosk/test1` for kiosk-authenticated traffic. `http://<pi-ip>/in/public/test1`. Kiosk-authenticated traffic to that same
Node-RED path uses `http://<pi-ip>/in/kiosk/test1`.
Do not publish `18080`, `18081`, `18082`, or `1880` on the host. Do not publish `18080`, `18081`, `18082`, or `1880` on the host.

View file

@ -65,7 +65,6 @@ server {
location /nrdp/ { location /nrdp/ {
auth_request /api/admin/_check; auth_request /api/admin/_check;
rewrite ^/nrdp/(.*) /$1 break;
proxy_pass http://betterframe_nodered; proxy_pass http://betterframe_nodered;
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_http_version 1.1; proxy_http_version 1.1;
@ -73,6 +72,19 @@ server {
proxy_set_header Connection "upgrade"; proxy_set_header Connection "upgrade";
} }
location = /nrdp { return 301 /nrdp/; }
location /dash/ {
auth_request /api/kiosk/_check;
auth_request_set $bf_kiosk_id $upstream_http_x_betterframe_kiosk_id;
proxy_pass http://betterframe_nodered;
proxy_set_header Host $host;
proxy_set_header X-BetterFrame-Kiosk-Id $bf_kiosk_id;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
location /in/public/ { location /in/public/ {
limit_req zone=bf_public burst=20 nodelay; limit_req zone=bf_public burst=20 nodelay;
rewrite ^/in/public/(.*) /$1 break; rewrite ^/in/public/(.*) /$1 break;
@ -81,8 +93,10 @@ server {
location /in/kiosk/ { location /in/kiosk/ {
auth_request /api/kiosk/_check; auth_request /api/kiosk/_check;
auth_request_set $bf_kiosk_id $upstream_http_x_betterframe_kiosk_id;
rewrite ^/in/kiosk/(.*) /$1 break; rewrite ^/in/kiosk/(.*) /$1 break;
proxy_pass http://betterframe_nodered; proxy_pass http://betterframe_nodered;
proxy_set_header X-BetterFrame-Kiosk-Id $bf_kiosk_id;
} }
location = /api/admin/_check { location = /api/admin/_check {
@ -104,6 +118,9 @@ server {
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
} }
location /api/ { return 404; }
location /ws/ { return 404; }
location ~ ^/(healthz|readyz|version)$ { location ~ ^/(healthz|readyz|version)$ {
proxy_pass http://betterframe_admin; proxy_pass http://betterframe_admin;
} }
@ -111,4 +128,10 @@ server {
location = / { location = / {
proxy_pass http://betterframe_admin; proxy_pass http://betterframe_admin;
} }
location / {
proxy_pass http://betterframe_nodered;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
} }

View file

@ -51,6 +51,7 @@ services:
- TZ=UTC - TZ=UTC
volumes: volumes:
- nodered-data:/data - nodered-data:/data
- ./nodered-settings.js:/data/settings.js:ro
expose: expose:
- "1880" - "1880"
networks: networks:

View file

@ -0,0 +1,7 @@
module.exports = {
uiHost: "0.0.0.0",
uiPort: Number(process.env.PORT || 1880),
httpAdminRoot: "/nrdp",
httpNodeRoot: "/",
functionGlobalContext: {},
};

View file

@ -1,5 +1,6 @@
use std::cell::RefCell; use std::cell::RefCell;
use std::sync::mpsc; use std::sync::mpsc;
use url::Url;
thread_local! { thread_local! {
/// camera_id → (pipeline, paintable). Pipelines stay warm across layout /// camera_id → (pipeline, paintable). Pipelines stay warm across layout
@ -70,7 +71,7 @@ fn activate(app: &Application) {
let bundle = server::fetch_bundle(&server, &key); let bundle = server::fetch_bundle(&server, &key);
info!("bundle: {} cameras, {} layouts", bundle.cameras.len(), bundle.layouts.len()); info!("bundle: {} cameras, {} layouts", bundle.cameras.len(), bundle.layouts.len());
let _ = tx.send(WorkerMsg::RenderBundle(bundle)); let _ = tx.send(WorkerMsg::RenderBundle(bundle, server.clone(), key.clone()));
// Spawn WS client in a separate thread for live updates // Spawn WS client in a separate thread for live updates
let server_ws = server.clone(); let server_ws = server.clone();
@ -91,7 +92,11 @@ fn activate(app: &Application) {
ServerMsg::ReloadBundle => { ServerMsg::ReloadBundle => {
info!("reloading bundle"); info!("reloading bundle");
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,
server_for_reload.clone(),
key_for_reload.clone(),
));
} }
ServerMsg::Standby => cec::standby(), ServerMsg::Standby => cec::standby(),
ServerMsg::Wake => cec::wake(), ServerMsg::Wake => cec::wake(),
@ -113,7 +118,7 @@ fn activate(app: &Application) {
while let Ok(msg) = rx.try_recv() { while let Ok(msg) = rx.try_recv() {
match msg { match msg {
WorkerMsg::ShowPairingCode(code) => show_pairing_code(&window_clone, &code), WorkerMsg::ShowPairingCode(code) => show_pairing_code(&window_clone, &code),
WorkerMsg::RenderBundle(bundle) => render_bundle(&window_clone, bundle), WorkerMsg::RenderBundle(bundle, server, key) => render_bundle(&window_clone, bundle, &server, &key),
} }
} }
gtk::glib::ControlFlow::Continue gtk::glib::ControlFlow::Continue
@ -122,7 +127,7 @@ fn activate(app: &Application) {
enum WorkerMsg { enum WorkerMsg {
ShowPairingCode(String), ShowPairingCode(String),
RenderBundle(KioskBundle), RenderBundle(KioskBundle, String, String),
} }
/// Query connected HDMI displays from sysfs. Returns (name, width, height). /// Query connected HDMI displays from sysfs. Returns (name, width, height).
@ -174,7 +179,7 @@ fn show_pairing_code(window: &ApplicationWindow, code: &str) {
window.set_child(Some(&vbox)); window.set_child(Some(&vbox));
} }
fn render_bundle(window: &ApplicationWindow, bundle: KioskBundle) { fn render_bundle(window: &ApplicationWindow, bundle: KioskBundle, server_url: &str, kiosk_key: &str) {
let layout = match bundle.display.default_layout_id { let layout = match bundle.display.default_layout_id {
Some(default_layout_id) => bundle.layouts.iter() Some(default_layout_id) => bundle.layouts.iter()
.find(|l| l.id == default_layout_id) .find(|l| l.id == default_layout_id)
@ -275,7 +280,7 @@ fn render_bundle(window: &ApplicationWindow, bundle: KioskBundle) {
none_cell() none_cell()
} else { } else {
let webview = webkit6::WebView::new(); let webview = webkit6::WebView::new();
webkit6::prelude::WebViewExt::load_uri(&webview, url); load_webview_url(&webview, url, server_url, kiosk_key);
webview.set_vexpand(true); webview.set_vexpand(true);
webview.set_hexpand(true); webview.set_hexpand(true);
webview.upcast() webview.upcast()
@ -304,6 +309,33 @@ fn clear_warm_cameras() {
}); });
} }
fn load_webview_url(webview: &webkit6::WebView, url: &str, server_url: &str, kiosk_key: &str) {
if should_attach_kiosk_auth(url, server_url) {
let request = webkit6::URIRequest::new(url);
if let Some(headers) = request.http_headers() {
headers.append("Authorization", &format!("Bearer {kiosk_key}"));
}
webkit6::prelude::WebViewExt::load_request(webview, &request);
return;
}
webkit6::prelude::WebViewExt::load_uri(webview, url);
}
fn should_attach_kiosk_auth(url: &str, server_url: &str) -> bool {
let Ok(target) = Url::parse(url) else { return false };
let Ok(server) = Url::parse(server_url) else { return false };
if target.scheme() != server.scheme()
|| target.host_str() != server.host_str()
|| target.port_or_known_default() != server.port_or_known_default()
{
return false;
}
let path = target.path();
path.starts_with("/dash/") || path.starts_with("/in/kiosk/")
}
/// 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

@ -29,8 +29,8 @@ export function initNoderedBridge(config: NoderedConfig, log: NoderedLog): Noder
const ctrl = new AbortController(); const ctrl = new AbortController();
const t = setTimeout(() => ctrl.abort(), timeoutMs); const t = setTimeout(() => ctrl.abort(), timeoutMs);
// POST to /in/<topic> on Node-RED. Node-RED flows can attach // Internal server-to-Node-RED delivery for events the backend already
// http-in nodes at /in/<topic> to consume. // authenticated, such as kiosk ONVIF/GPIO ingest.
const url = `${base}/in/${encodeURIComponent(topic)}`; const url = `${base}/in/${encodeURIComponent(topic)}`;
fetch(url, { fetch(url, {
method: "POST", method: "POST",