mirror of
https://github.com/BetterCorp/BetterFrame.git
synced 2026-05-26 15:46:35 +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)
|
||||
```
|
||||
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -29,20 +29,25 @@ Access first-run setup at:
|
|||
http://<pi-ip>/setup
|
||||
```
|
||||
|
||||
Node-RED is reachable only through:
|
||||
Node-RED editor is reachable only through:
|
||||
|
||||
```text
|
||||
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>`
|
||||
- Kiosk-authenticated ingress: `http://<pi-ip>/in/kiosk/<node-red-path>`
|
||||
- BetterFrame web/API: `/`, `/setup`, `/admin/*`, `/auth/*`, `/static/*`,
|
||||
`/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
|
||||
`http://<pi-ip>/in/public/test1` for public traffic, or
|
||||
`http://<pi-ip>/in/kiosk/test1` for kiosk-authenticated traffic.
|
||||
For example, a Node-RED `http in` node at `/test1` is public at
|
||||
`http://<pi-ip>/test1` and also available at
|
||||
`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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -51,6 +51,7 @@ services:
|
|||
- TZ=UTC
|
||||
volumes:
|
||||
- nodered-data:/data
|
||||
- ./nodered-settings.js:/data/settings.js:ro
|
||||
expose:
|
||||
- "1880"
|
||||
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::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,
|
||||
|
|
|
|||
|
|
@ -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/<topic> on Node-RED. Node-RED flows can attach
|
||||
// http-in nodes at /in/<topic> 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",
|
||||
|
|
|
|||
Loading…
Reference in a new issue