From 820e0a5945c30460a72bfc99ea0144e0ade86166 Mon Sep 17 00:00:00 2001 From: Mitchell R Date: Mon, 11 May 2026 10:44:45 +0200 Subject: [PATCH] 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. --- CLAUDE.md | 20 +++++++++---- deploy/README.md | 19 +++++++----- deploy/angie/betterframe.docker.conf | 25 +++++++++++++++- deploy/docker/docker-compose.yml | 1 + deploy/docker/nodered-settings.js | 7 +++++ kiosk/src/ui.rs | 44 ++++++++++++++++++++++++---- server/src/shared/nodered-bridge.ts | 4 +-- 7 files changed, 98 insertions(+), 22 deletions(-) create mode 100644 deploy/docker/nodered-settings.js diff --git a/CLAUDE.md b/CLAUDE.md index 2c19517..e807185 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -47,6 +47,11 @@ ↕ 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 - **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-*`). @@ -64,14 +69,17 @@ 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. 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 - kiosk-key `/api/kiosk/*` `/in/kiosk/*` `/dash/*` (kiosks) - - admin session+TOTP `/admin/*` `/api/admin/*` `/nrdp/*` `/dash/*` (humans) - Node-RED external HTTP-in has exactly two ingress bases: `/in/public/*` - for user webhooks/actions and `/in/kiosk/*` for kiosk-authenticated data. - Angie strips that base before proxying, so a Node-RED route `/test1` is - called as `/in/public/test1` or `/in/kiosk/test1`. + - admin session+TOTP `/admin/*` `/api/admin/*` `/nrdp/*` (humans) + BetterFrame web/API stays on backend routes; kiosk-specific ingest/control + uses `/api/kiosk/*`, `/ws/kiosk`, `/in/kiosk/*`, and `/dash/*`; dashboards + are kiosk-only; otherwise-unmatched root paths are public Node-RED HTTP-in + 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: - `consume`: any kiosk w/label may render - `operate`: exactly ONE kiosk authoritative (composite PK incl role) diff --git a/deploy/README.md b/deploy/README.md index 803c429..e670885 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -29,20 +29,25 @@ Access first-run setup at: http:///setup ``` -Node-RED is reachable only through: +Node-RED editor is reachable only through: ```text http:///nrdp/ ``` -Node-RED HTTP-in routes have two public base URLs: +The proxy has four route surfaces: -- Public webhook/user actions: `http:///in/public/` -- Kiosk-authenticated ingress: `http:///in/kiosk/` +- BetterFrame web/API: `/`, `/setup`, `/admin/*`, `/auth/*`, `/static/*`, + `/api/admin/*`, `/api/kiosk/*`, `/api/pair/*`, `/ws/kiosk` +- Kiosk-only Node-RED ingress: `/in/kiosk/` +- Kiosk-only Node-RED dashboards: `/dash/*` +- Public Node-RED HTTP-in URLs: any otherwise-unmatched root path, plus + `/in/public/` -For example, a Node-RED `http in` node at `/test1` is called as -`http:///in/public/test1` for public traffic, or -`http:///in/kiosk/test1` for kiosk-authenticated traffic. +For example, a Node-RED `http in` node at `/test1` is public at +`http:///test1` and also available at +`http:///in/public/test1`. Kiosk-authenticated traffic to that same +Node-RED path uses `http:///in/kiosk/test1`. Do not publish `18080`, `18081`, `18082`, or `1880` on the host. diff --git a/deploy/angie/betterframe.docker.conf b/deploy/angie/betterframe.docker.conf index 4c204b3..d0d8d2d 100644 --- a/deploy/angie/betterframe.docker.conf +++ b/deploy/angie/betterframe.docker.conf @@ -65,7 +65,6 @@ server { location /nrdp/ { auth_request /api/admin/_check; - rewrite ^/nrdp/(.*) /$1 break; proxy_pass http://betterframe_nodered; proxy_set_header Host $host; proxy_http_version 1.1; @@ -73,6 +72,19 @@ server { 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/ { limit_req zone=bf_public burst=20 nodelay; rewrite ^/in/public/(.*) /$1 break; @@ -81,8 +93,10 @@ server { location /in/kiosk/ { auth_request /api/kiosk/_check; + auth_request_set $bf_kiosk_id $upstream_http_x_betterframe_kiosk_id; rewrite ^/in/kiosk/(.*) /$1 break; proxy_pass http://betterframe_nodered; + proxy_set_header X-BetterFrame-Kiosk-Id $bf_kiosk_id; } location = /api/admin/_check { @@ -104,6 +118,9 @@ server { proxy_set_header X-Real-IP $remote_addr; } + location /api/ { return 404; } + location /ws/ { return 404; } + location ~ ^/(healthz|readyz|version)$ { proxy_pass http://betterframe_admin; } @@ -111,4 +128,10 @@ server { location = / { proxy_pass http://betterframe_admin; } + + location / { + proxy_pass http://betterframe_nodered; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + } } diff --git a/deploy/docker/docker-compose.yml b/deploy/docker/docker-compose.yml index 04a659b..01b11f5 100644 --- a/deploy/docker/docker-compose.yml +++ b/deploy/docker/docker-compose.yml @@ -51,6 +51,7 @@ services: - TZ=UTC volumes: - nodered-data:/data + - ./nodered-settings.js:/data/settings.js:ro expose: - "1880" networks: diff --git a/deploy/docker/nodered-settings.js b/deploy/docker/nodered-settings.js new file mode 100644 index 0000000..16375cf --- /dev/null +++ b/deploy/docker/nodered-settings.js @@ -0,0 +1,7 @@ +module.exports = { + uiHost: "0.0.0.0", + uiPort: Number(process.env.PORT || 1880), + httpAdminRoot: "/nrdp", + httpNodeRoot: "/", + functionGlobalContext: {}, +}; diff --git a/kiosk/src/ui.rs b/kiosk/src/ui.rs index 84a4cbf..8afc32f 100644 --- a/kiosk/src/ui.rs +++ b/kiosk/src/ui.rs @@ -1,5 +1,6 @@ use std::cell::RefCell; use std::sync::mpsc; +use url::Url; thread_local! { /// camera_id → (pipeline, paintable). Pipelines stay warm across layout @@ -70,7 +71,7 @@ fn activate(app: &Application) { let bundle = server::fetch_bundle(&server, &key); 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 let server_ws = server.clone(); @@ -91,7 +92,11 @@ fn activate(app: &Application) { ServerMsg::ReloadBundle => { info!("reloading bundle"); 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::Wake => cec::wake(), @@ -113,7 +118,7 @@ fn activate(app: &Application) { while let Ok(msg) = rx.try_recv() { match msg { 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 @@ -122,7 +127,7 @@ fn activate(app: &Application) { enum WorkerMsg { ShowPairingCode(String), - RenderBundle(KioskBundle), + RenderBundle(KioskBundle, String, String), } /// 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)); } -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 { Some(default_layout_id) => bundle.layouts.iter() .find(|l| l.id == default_layout_id) @@ -275,7 +280,7 @@ fn render_bundle(window: &ApplicationWindow, bundle: KioskBundle) { none_cell() } else { 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_hexpand(true); 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. fn ensure_warm( cam_id: u32, diff --git a/server/src/shared/nodered-bridge.ts b/server/src/shared/nodered-bridge.ts index 3bc5e69..2149448 100644 --- a/server/src/shared/nodered-bridge.ts +++ b/server/src/shared/nodered-bridge.ts @@ -29,8 +29,8 @@ export function initNoderedBridge(config: NoderedConfig, log: NoderedLog): Noder const ctrl = new AbortController(); const t = setTimeout(() => ctrl.abort(), timeoutMs); - // POST to /in/ on Node-RED. Node-RED flows can attach - // http-in nodes at /in/ to consume. + // Internal server-to-Node-RED delivery for events the backend already + // authenticated, such as kiosk ONVIF/GPIO ingest. const url = `${base}/in/${encodeURIComponent(topic)}`; fetch(url, { method: "POST",