mirror of
https://github.com/BetterCorp/BetterFrame.git
synced 2026-05-26 16:56:33 +00:00
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:
parent
346ddfa3a4
commit
820e0a5945
7 changed files with 98 additions and 22 deletions
20
CLAUDE.md
20
CLAUDE.md
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
7
deploy/docker/nodered-settings.js
Normal file
7
deploy/docker/nodered-settings.js
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
module.exports = {
|
||||||
|
uiHost: "0.0.0.0",
|
||||||
|
uiPort: Number(process.env.PORT || 1880),
|
||||||
|
httpAdminRoot: "/nrdp",
|
||||||
|
httpNodeRoot: "/",
|
||||||
|
functionGlobalContext: {},
|
||||||
|
};
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue