reddb_server/server/ui_bridge.rs
1//! Local `red ui` bridge — the tracer-bullet spine of the red-ui
2//! integration (issue #1042, PRD #1041; ADR 0047 bridge, ADR 0049
3//! RedWire-over-WS transport, ADR 0036 WS edge).
4//!
5//! `red ui --server file://<path>` opens a graphical UI in the browser
6//! against a local `.rdb`. This module is the process that makes that
7//! work: a single loopback (`127.0.0.1`) HTTP server that
8//!
9//! * serves the UI bundle (a `--ui-dir` directory, or the checked-in
10//! minimal fixture page when none is given), and
11//! * mounts `/redwire` — a RedWire-over-WebSocket endpoint over the
12//! **embedded engine** opened from the file. The WS data channel is
13//! bridged into the same async-transport ↔ sync-engine seam the
14//! internet WS edge uses ([`super::ws_edge::run_ws_session`], ADR
15//! 0036). This serves RedWire over the embedded engine — it is *not*
16//! a proxy of the HTTP surface.
17//!
18//! Security (ADR 0036, adapted for loopback):
19//! * **Origin allowlist, default-deny.** WebSocket is not covered by
20//! CORS, so the upgrade validates the `Origin` header against an
21//! explicit, exact-match allowlist ([`loopback_ws_origin_allowed`]).
22//! The list is seeded with the bridge's own served origins
23//! (`http://127.0.0.1:<port>` and `http://localhost:<port>`), so the
24//! served page can connect and a cross-site page cannot.
25//! * **WSS-only is relaxed.** The internet edge requires TLS
26//! ([`super::ws_edge::ws_upgrade_decision`]); the loopback bridge
27//! accepts plain `ws://` because it is bound to `127.0.0.1` and never
28//! leaves the host. This is the one rule that differs from the
29//! internet edge, and it is deliberate.
30//!
31//! The bridge is session-scoped: [`UiBridge::shutdown`] tears the server
32//! down cleanly (graceful shutdown of the listener + the serve task), so
33//! closing the UI / interrupting the command leaves no orphaned process.
34
35use std::net::SocketAddr;
36use std::path::PathBuf;
37use std::sync::Arc;
38
39use axum::extract::ws::{WebSocket, WebSocketUpgrade};
40use axum::extract::State;
41use axum::http::{header, HeaderMap, StatusCode, Uri};
42use axum::response::{IntoResponse, Response};
43use axum::routing::get;
44use tokio::sync::oneshot;
45
46use super::ws_edge::{
47 inject_bearer_handshake, pump_ws_stream, run_injected_ws_session, run_ws_session,
48 REDWIRE_WS_PATH, REDWIRE_WS_SUBPROTOCOL,
49};
50use super::RedDBServer;
51
52/// The checked-in minimal UI fixture served when no `--ui-dir` is given.
53/// A real bundle is downloaded at runtime in a later slice (PRD #1041);
54/// for now this page is enough to open a RedWire-over-WS session and run
55/// a query against the embedded engine.
56const FIXTURE_INDEX: &str = include_str!("ui_bridge_fixture/index.html");
57
58/// Configuration for [`spawn_ui_bridge`].
59#[derive(Debug, Clone, Default)]
60pub struct UiBridgeConfig {
61 /// Directory to serve the UI bundle from. `None` serves the embedded
62 /// [`FIXTURE_INDEX`] at `/` (and `/index.html`).
63 pub ui_dir: Option<PathBuf>,
64 /// Loopback port to bind. `0` (the default) picks an ephemeral port —
65 /// the resolved address is read back from [`UiBridge::local_addr`].
66 pub port: u16,
67 /// Bearer token held by the bridge and injected into the RedWire
68 /// handshake. The served page never receives the token.
69 pub injected_token: Option<String>,
70 /// Credential-free auth mode hint injected into served HTML.
71 pub auth_mode: super::ui_auth::UiAuthMode,
72}
73
74/// A remote RedWire endpoint the bridge fronts for a `red://` / `reds://`
75/// target (issue #1044, ADR 0047 bridge, ADR 0049 transport). The local
76/// loopback WS endpoint pumps its data channel straight into a fresh
77/// TCP (or TLS) connection to this host — the UI is unaware that the
78/// engine lives in another process / container.
79#[derive(Debug, Clone)]
80pub struct RemoteRedwireTarget {
81 /// Host to dial (the `red://`/`reds://` authority).
82 pub host: String,
83 /// Port to dial (defaults to `DEFAULT_PORT_RED` via the URI parser).
84 pub port: u16,
85 /// Negotiate TLS to the target (`reds://`). The handshake is
86 /// transparent to the UI.
87 pub tls: bool,
88 /// Optional CA bundle (PEM) to trust for the TLS handshake, on top of
89 /// the webpki system roots. Needed for a self-signed / private-CA
90 /// `reds://` target (a dev container); `None` trusts system roots only.
91 pub ca_pem: Option<Vec<u8>>,
92}
93
94/// What a running bridge fronts: the embedded engine opened from a
95/// `file://` target, or a remote RedWire endpoint (`red://` / `reds://`).
96/// Both are reached through the *same* loopback WS endpoint and the same
97/// byte-pump seam — only the far end of the pump differs.
98enum BridgeBackend {
99 /// `file://` — RedWire runs over the embedded engine in-process.
100 /// Boxed because `RedDBServer` is by far the largest variant payload
101 /// (≥280 bytes vs ≤56 for `Remote`); keeping it inline trips
102 /// `clippy::large_enum_variant` and bloats every `BridgeBackend`.
103 Embedded(Box<RedDBServer>),
104 /// `red://` / `reds://` — RedWire bytes are relayed to a remote
105 /// RedWire-over-TCP/TLS instance.
106 Remote(RemoteRedwireTarget),
107 /// `red+wss://` / `red+ws://` — no loopback relay; the browser
108 /// connects directly to the target. The `/redwire` route is not
109 /// mounted for this backend.
110 Direct,
111}
112
113/// How `red ui` should connect the browser to a target URI.
114///
115/// - [`UiTarget::File`] and [`UiTarget::Remote`] are *bridge-required*:
116/// a loopback WS relay is started and the UI only talks to that.
117/// - [`UiTarget::Direct`] is *bridge-free* (ADR 0047 direct-when-reachable):
118/// the browser connects to `ws_url` directly — only a static HTTP server
119/// to serve the UI bundle is started, with no WS relay process.
120#[derive(Debug, Clone, PartialEq, Eq)]
121pub enum UiTarget {
122 /// A local file / embedded-engine target. The caller canonicalises
123 /// the `file://` path and opens the engine itself.
124 File,
125 /// A remote RedWire-over-TCP (`red://`) or -TLS (`reds://`) target.
126 Remote(RemoteRedwireTargetSpec),
127 /// A browser-reachable WS endpoint (`red+wss://` or `red+ws://`).
128 /// The browser connects to `ws_url` directly; no loopback relay is
129 /// started. The UI bundle is still served from a local HTTP server.
130 Direct { ws_url: String },
131}
132
133/// Host / port / TLS triple parsed from a `red://` / `reds://` URI —
134/// the connection-independent part of [`RemoteRedwireTarget`] (no CA
135/// bytes), so it can derive `PartialEq` for classification tests.
136#[derive(Debug, Clone, PartialEq, Eq)]
137pub struct RemoteRedwireTargetSpec {
138 pub host: String,
139 pub port: u16,
140 pub tls: bool,
141}
142
143/// Classify a `red ui` target URI into the bridge backend it requires.
144///
145/// `file://` and bare filesystem paths resolve to [`UiTarget::File`];
146/// `red://host[:port]` and `reds://host[:port]` resolve to
147/// [`UiTarget::Remote`] with the parser's default port
148/// ([`reddb_wire::DEFAULT_PORT_RED`], 5050) when none is given and `tls`
149/// set for `reds://`. Any other scheme (or a cluster URI) is an error —
150/// `red ui` bridges exactly one endpoint.
151pub fn classify_ui_target(uri: &str) -> Result<UiTarget, String> {
152 // A bare path with no scheme is a file target (matches the existing
153 // `red ui ./data.rdb` shorthand).
154 if !uri.contains("://") {
155 return Ok(UiTarget::File);
156 }
157 match reddb_wire::parse(uri) {
158 Ok(reddb_wire::ConnectionTarget::File { .. }) => Ok(UiTarget::File),
159 Ok(reddb_wire::ConnectionTarget::RedWire { host, port, tls }) => {
160 Ok(UiTarget::Remote(RemoteRedwireTargetSpec {
161 host,
162 port,
163 tls,
164 }))
165 }
166 Ok(reddb_wire::ConnectionTarget::WsNative { host, port, tls }) => {
167 // ADR 0047: browser-reachable WS target — no loopback relay.
168 let scheme = if tls { "wss" } else { "ws" };
169 let ws_url = format!("{scheme}://{host}:{port}/redwire");
170 Ok(UiTarget::Direct { ws_url })
171 }
172 Ok(_) | Err(_) => Err(format!(
173 "unsupported target for red ui; supported schemes: \
174 file://, red://, reds://, red+ws://, red+wss://; got: {uri}"
175 )),
176 }
177}
178
179/// State threaded into the bridge's axum handlers. Cheap to clone (the
180/// backend is shared via `Arc`; the origin list and bundle dir likewise).
181#[derive(Clone)]
182struct BridgeState {
183 backend: Arc<BridgeBackend>,
184 allowed_origins: Arc<Vec<String>>,
185 ui_dir: Option<Arc<PathBuf>>,
186 injected_token: Option<Arc<String>>,
187 auth_mode: super::ui_auth::UiAuthMode,
188 /// Set for `Direct` targets. When `Some`, the WS URL is injected as
189 /// `window.REDDB_WS_URL` into HTML responses so the UI page can
190 /// connect directly rather than deriving from `location.host`.
191 direct_ws_url: Option<Arc<String>>,
192}
193
194/// A running loopback UI bridge. Holds the bound address plus the handles
195/// needed to shut the serve task down cleanly. Dropping it without
196/// calling [`Self::shutdown`] aborts the serve task on drop of the join
197/// handle's runtime; prefer `shutdown().await` for an orderly teardown.
198pub struct UiBridge {
199 local_addr: SocketAddr,
200 shutdown_tx: Option<oneshot::Sender<()>>,
201 join: tokio::task::JoinHandle<()>,
202 /// For `Direct` targets, the WS URL the browser connects to (the
203 /// remote endpoint). `None` for bridge targets — `ws_url()` derives
204 /// the loopback URL from `local_addr` in that case.
205 direct_ws_url: Option<String>,
206}
207
208impl UiBridge {
209 /// The loopback address the bridge is serving on.
210 pub fn local_addr(&self) -> SocketAddr {
211 self.local_addr
212 }
213
214 /// URL of the served UI bundle root — open this in a browser.
215 pub fn ui_url(&self) -> String {
216 format!("http://{}/", self.local_addr)
217 }
218
219 /// The WebSocket URL the served page connects to.
220 ///
221 /// For bridge targets (`file://`, `red://`, `reds://`) this is the
222 /// loopback endpoint on the same server. For direct targets
223 /// (`red+wss://`, `red+ws://`) this is the remote endpoint the browser
224 /// connects to without a relay.
225 pub fn ws_url(&self) -> String {
226 self.direct_ws_url
227 .clone()
228 .unwrap_or_else(|| format!("ws://{}{}", self.local_addr, REDWIRE_WS_PATH))
229 }
230
231 /// Signal graceful shutdown and wait for the serve task to wind down.
232 /// Session-scoped: the bridge process exits cleanly with no orphaned
233 /// listener once the UI is closed / the command is interrupted.
234 pub async fn shutdown(mut self) {
235 if let Some(tx) = self.shutdown_tx.take() {
236 let _ = tx.send(());
237 }
238 let _ = self.join.await;
239 }
240}
241
242/// Exact-match, default-deny Origin gate for the loopback `/redwire`
243/// upgrade (ADR 0036, adapted for loopback). An absent `Origin` is
244/// rejected (a browser always sends one); a present origin must appear
245/// verbatim in `allowed`. Kept pure so the policy is unit-tested without
246/// a live socket. The WSS-only rule of the internet edge does *not* apply
247/// here — the bridge is `127.0.0.1`-bound, so plain `ws://` is accepted.
248pub fn loopback_ws_origin_allowed(origin: Option<&str>, allowed: &[String]) -> bool {
249 match origin {
250 None => false,
251 Some(o) => allowed.iter().any(|a| a == o),
252 }
253}
254
255/// The served origins a page loaded from this bridge will present on its
256/// WebSocket upgrade — both the `127.0.0.1` literal and `localhost` form
257/// of the bound port. Seeded into the allowlist so the bundle can connect
258/// while a cross-site origin cannot.
259fn seed_loopback_origins(port: u16) -> Vec<String> {
260 vec![
261 format!("http://127.0.0.1:{port}"),
262 format!("http://localhost:{port}"),
263 ]
264}
265
266/// Bind a loopback HTTP server that serves the UI bundle and mounts the
267/// RedWire-over-WS endpoint over `server`'s embedded engine, then spawn
268/// its serve loop. Returns once the listener is bound; the returned
269/// [`UiBridge`] carries the resolved address and a clean-shutdown handle.
270///
271/// Must be called from within a tokio runtime (it binds a tokio listener
272/// and spawns the serve task).
273pub async fn spawn_ui_bridge(
274 server: RedDBServer,
275 config: UiBridgeConfig,
276) -> std::io::Result<UiBridge> {
277 spawn_ui_bridge_backend(BridgeBackend::Embedded(Box::new(server)), config).await
278}
279
280/// Like [`spawn_ui_bridge`] but fronting a *remote* RedWire endpoint
281/// (`red://` / `reds://`, issue #1044) rather than the embedded engine.
282/// The served UI still talks only to the loopback WS endpoint; each WS
283/// session opens a fresh TCP/TLS connection to `target`, and the byte
284/// stream is pumped through transparently.
285pub async fn spawn_ui_bridge_remote(
286 target: RemoteRedwireTarget,
287 config: UiBridgeConfig,
288) -> std::io::Result<UiBridge> {
289 spawn_ui_bridge_backend(BridgeBackend::Remote(target), config).await
290}
291
292async fn spawn_ui_bridge_backend(
293 backend: BridgeBackend,
294 config: UiBridgeConfig,
295) -> std::io::Result<UiBridge> {
296 let listener = tokio::net::TcpListener::bind(("127.0.0.1", config.port)).await?;
297 let local_addr = listener.local_addr()?;
298
299 let state = BridgeState {
300 backend: Arc::new(backend),
301 allowed_origins: Arc::new(seed_loopback_origins(local_addr.port())),
302 ui_dir: config.ui_dir.map(Arc::new),
303 injected_token: config.injected_token.map(Arc::new),
304 auth_mode: config.auth_mode,
305 direct_ws_url: None,
306 };
307
308 let router = axum::Router::new()
309 .route(REDWIRE_WS_PATH, get(loopback_redwire_upgrade))
310 .fallback(serve_ui)
311 .with_state(state);
312
313 let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>();
314 let join = tokio::spawn(async move {
315 let _ = axum::serve(listener, router)
316 .with_graceful_shutdown(async move {
317 let _ = shutdown_rx.await;
318 })
319 .await;
320 });
321
322 Ok(UiBridge {
323 local_addr,
324 shutdown_tx: Some(shutdown_tx),
325 join,
326 direct_ws_url: None,
327 })
328}
329
330/// Serve the UI bundle for a **direct** `red+wss://` / `red+ws://` target
331/// (ADR 0047). No loopback WS relay is started — the browser connects to
332/// `ws_url` directly. A `window.REDDB_WS_URL` config is injected into HTML
333/// responses so the UI page knows the target without user input.
334pub async fn spawn_direct_ui_server(
335 ws_url: String,
336 config: UiBridgeConfig,
337) -> std::io::Result<UiBridge> {
338 let listener = tokio::net::TcpListener::bind(("127.0.0.1", config.port)).await?;
339 let local_addr = listener.local_addr()?;
340
341 let state = BridgeState {
342 backend: Arc::new(BridgeBackend::Direct),
343 allowed_origins: Arc::new(vec![]),
344 ui_dir: config.ui_dir.map(Arc::new),
345 injected_token: config.injected_token.map(Arc::new),
346 auth_mode: config.auth_mode,
347 direct_ws_url: Some(Arc::new(ws_url.clone())),
348 };
349
350 // No /redwire route — the browser owns its own WS connection.
351 let router = axum::Router::new().fallback(serve_ui).with_state(state);
352
353 let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>();
354 let join = tokio::spawn(async move {
355 let _ = axum::serve(listener, router)
356 .with_graceful_shutdown(async move {
357 let _ = shutdown_rx.await;
358 })
359 .await;
360 });
361
362 Ok(UiBridge {
363 local_addr,
364 shutdown_tx: Some(shutdown_tx),
365 join,
366 direct_ws_url: Some(ws_url),
367 })
368}
369
370/// axum handler for `GET /redwire`. Enforces the loopback Origin gate,
371/// then upgrades to a binary WebSocket and runs a RedWire session over it
372/// against the embedded engine (the same seam as the internet WS edge).
373async fn loopback_redwire_upgrade(
374 State(state): State<BridgeState>,
375 headers: HeaderMap,
376 ws: WebSocketUpgrade,
377) -> Response {
378 let origin = headers
379 .get(header::ORIGIN)
380 .and_then(|value| value.to_str().ok());
381
382 if !loopback_ws_origin_allowed(origin, &state.allowed_origins) {
383 return (
384 StatusCode::FORBIDDEN,
385 "origin not allowed for loopback redwire websocket",
386 )
387 .into_response();
388 }
389
390 let backend = Arc::clone(&state.backend);
391 let injected_token = state.injected_token.clone();
392 ws.protocols([REDWIRE_WS_SUBPROTOCOL])
393 .on_upgrade(move |socket| async move {
394 match &*backend {
395 // `file://` — RedWire over the in-process embedded engine.
396 BridgeBackend::Embedded(server) => {
397 if let Some(token) = injected_token.as_deref().map(String::as_str) {
398 run_injected_ws_session(socket, (**server).clone(), token).await;
399 } else {
400 run_ws_session(socket, (**server).clone()).await;
401 }
402 }
403 // `red://` / `reds://` — relay to a remote RedWire instance.
404 BridgeBackend::Remote(target) => {
405 run_remote_ws_session(
406 socket,
407 target,
408 injected_token.as_deref().map(String::as_str),
409 )
410 .await;
411 }
412 // `red+wss://` / `red+ws://` — the `/redwire` route is not
413 // mounted for direct targets, so this arm is unreachable.
414 BridgeBackend::Direct => {
415 close_ws(socket).await;
416 }
417 }
418 })
419}
420
421/// Bridge the binary WebSocket to a remote RedWire-over-TCP/TLS endpoint.
422///
423/// Opens a fresh connection to `target` per WS session and pumps the byte
424/// stream straight through ([`pump_ws_stream`]). The browser sends the
425/// exact native preamble (`0xFE` magic + minor + `Hello`), so the remote
426/// listener's standalone session handles it unchanged — the bridge is a
427/// pure byte relay and never parses RedWire frames. On a connection
428/// failure the WS is closed so the UI surfaces the error rather than
429/// hanging.
430async fn run_remote_ws_session(
431 socket: WebSocket,
432 target: &RemoteRedwireTarget,
433 injected_token: Option<&str>,
434) {
435 let addr = (target.host.as_str(), target.port);
436 let tcp = match tokio::net::TcpStream::connect(addr).await {
437 Ok(tcp) => tcp,
438 Err(err) => {
439 tracing::warn!(
440 host = %target.host,
441 port = target.port,
442 err = %err,
443 "ui bridge: connect to remote redwire target failed"
444 );
445 close_ws(socket).await;
446 return;
447 }
448 };
449
450 if !target.tls {
451 if let Some(token) = injected_token {
452 inject_bearer_handshake(socket, tcp, token).await;
453 } else {
454 pump_ws_stream(socket, tcp).await;
455 }
456 return;
457 }
458
459 match wrap_remote_tls(tcp, target).await {
460 Ok(tls) => {
461 if let Some(token) = injected_token {
462 inject_bearer_handshake(socket, tls, token).await;
463 } else {
464 pump_ws_stream(socket, tls).await;
465 }
466 }
467 Err(err) => {
468 tracing::warn!(
469 host = %target.host,
470 port = target.port,
471 err = %err,
472 "ui bridge: TLS handshake to remote redwire target failed"
473 );
474 close_ws(socket).await;
475 }
476 }
477}
478
479/// Send a best-effort close frame on a WS the bridge is abandoning.
480async fn close_ws(mut socket: WebSocket) {
481 let _ = socket.send(axum::extract::ws::Message::Close(None)).await;
482}
483
484/// Negotiate client-side TLS to a `reds://` target. Trusts the webpki
485/// system roots, plus any caller-supplied CA bundle (PEM) — enough for a
486/// public-cert target out of the box and a self-signed / private-CA dev
487/// container when a `--tls-ca` bundle is passed. Server-only TLS (no
488/// client cert): RedWire auth is negotiated inside the handshake, exactly
489/// as on the native socket transports.
490async fn wrap_remote_tls(
491 tcp: tokio::net::TcpStream,
492 target: &RemoteRedwireTarget,
493) -> std::io::Result<tokio_rustls::client::TlsStream<tokio::net::TcpStream>> {
494 use rustls::pki_types::ServerName;
495 use rustls::{ClientConfig, RootCertStore};
496
497 let _ = rustls::crypto::ring::default_provider().install_default();
498
499 let mut roots = RootCertStore::empty();
500 // Seed the webpki system roots so a public-cert `reds://` works
501 // without an explicit CA.
502 roots.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
503 // Add any caller-supplied CA (a self-signed dev container / private CA).
504 if let Some(pem) = &target.ca_pem {
505 let mut reader = std::io::BufReader::new(&pem[..]);
506 for cert in rustls_pemfile::certs(&mut reader) {
507 let cert = cert.map_err(std::io::Error::other)?;
508 roots
509 .add(cert)
510 .map_err(|e| std::io::Error::other(format!("add CA cert: {e}")))?;
511 }
512 }
513
514 let config = ClientConfig::builder()
515 .with_root_certificates(roots)
516 .with_no_client_auth();
517
518 let connector = tokio_rustls::TlsConnector::from(Arc::new(config));
519 let server_name = ServerName::try_from(target.host.clone())
520 .map_err(|e| std::io::Error::other(format!("invalid TLS server name: {e}")))?;
521 connector.connect(server_name, tcp).await
522}
523
524/// Static-file fallback: serve the UI bundle. With a `--ui-dir`, files are
525/// read from that directory (`/` → `index.html`), guarded against path
526/// traversal; without one, the embedded fixture answers `/` and
527/// `/index.html` and everything else is 404.
528///
529/// For direct targets (`BridgeBackend::Direct`), the WS URL config
530/// (`window.REDDB_WS_URL`) is injected before `</head>` in HTML responses
531/// so the UI page can connect to the remote endpoint directly.
532async fn serve_ui(State(state): State<BridgeState>, uri: Uri) -> Response {
533 let raw = uri.path();
534 let rel = raw.trim_start_matches('/');
535 let rel = if rel.is_empty() { "index.html" } else { rel };
536
537 let (content_type, mut body) = match &state.ui_dir {
538 None => {
539 if rel == "index.html" {
540 (
541 "text/html; charset=utf-8",
542 FIXTURE_INDEX.as_bytes().to_vec(),
543 )
544 } else {
545 return not_found();
546 }
547 }
548 Some(dir) => {
549 // Reject traversal: no `..` or `.` segments, no absolute
550 // re-rooting. Only plain, forward components are served.
551 if rel
552 .split('/')
553 .any(|seg| seg == ".." || seg == "." || seg.is_empty())
554 {
555 return not_found();
556 }
557 let full = dir.join(rel);
558 // Read off the async runtime — `tokio::fs` is not enabled, and
559 // these are small local bundle assets on loopback anyway.
560 match tokio::task::spawn_blocking(move || std::fs::read(&full)).await {
561 Ok(Ok(bytes)) => (content_type_for(rel), bytes),
562 _ => return not_found(),
563 }
564 }
565 };
566
567 // For direct targets, inject `window.REDDB_WS_URL` before </head> so
568 // the UI page knows the remote WS endpoint without a loopback relay.
569 if content_type.starts_with("text/html") {
570 if let Some(ws_url) = &state.direct_ws_url {
571 body = inject_ws_url_config(body, ws_url);
572 }
573 body = super::ui_auth::inject_auth_mode_config(body, state.auth_mode);
574 }
575
576 (StatusCode::OK, [(header::CONTENT_TYPE, content_type)], body).into_response()
577}
578
579/// Inject `<script>window.REDDB_WS_URL="<ws_url>";</script>` just before
580/// `</head>` in an HTML document. The ws_url is constructed from a
581/// validated URI (scheme + host + port) and contains no `"` or `\`, so
582/// simple string interpolation is safe. Returns the original bytes
583/// unchanged when `</head>` is not found.
584fn inject_ws_url_config(html: Vec<u8>, ws_url: &str) -> Vec<u8> {
585 let snippet = format!("<script>window.REDDB_WS_URL=\"{ws_url}\";</script>");
586 let marker = b"</head>";
587 match html.windows(marker.len()).position(|w| w == marker) {
588 Some(pos) => {
589 let mut out = Vec::with_capacity(html.len() + snippet.len());
590 out.extend_from_slice(&html[..pos]);
591 out.extend_from_slice(snippet.as_bytes());
592 out.extend_from_slice(&html[pos..]);
593 out
594 }
595 None => html,
596 }
597}
598
599/// Guess a content type from a file extension. Minimal map covering the
600/// asset kinds a UI bundle ships; anything unknown is served as opaque
601/// bytes. Shared with the server-side static surface ([`super::ui_static`],
602/// `red server --ui`) so the two bundle-serving paths agree on MIME types.
603pub(crate) fn content_type_for(path: &str) -> &'static str {
604 let ext = path.rsplit('.').next().unwrap_or("");
605 match ext {
606 "html" | "htm" => "text/html; charset=utf-8",
607 "js" | "mjs" => "text/javascript; charset=utf-8",
608 "css" => "text/css; charset=utf-8",
609 "json" => "application/json; charset=utf-8",
610 "svg" => "image/svg+xml",
611 "png" => "image/png",
612 "jpg" | "jpeg" => "image/jpeg",
613 "gif" => "image/gif",
614 "ico" => "image/x-icon",
615 "wasm" => "application/wasm",
616 "map" => "application/json; charset=utf-8",
617 "txt" => "text/plain; charset=utf-8",
618 _ => "application/octet-stream",
619 }
620}
621
622fn content_type_response(path: &str, body: Vec<u8>) -> Response {
623 (
624 StatusCode::OK,
625 [(header::CONTENT_TYPE, content_type_for(path))],
626 body,
627 )
628 .into_response()
629}
630
631fn html_response(body: Vec<u8>) -> Response {
632 (
633 StatusCode::OK,
634 [(header::CONTENT_TYPE, "text/html; charset=utf-8")],
635 body,
636 )
637 .into_response()
638}
639
640fn not_found() -> Response {
641 (StatusCode::NOT_FOUND, "not found").into_response()
642}
643
644#[cfg(test)]
645mod tests {
646 use super::*;
647
648 fn origins() -> Vec<String> {
649 seed_loopback_origins(7777)
650 }
651
652 #[test]
653 fn served_origin_is_allowed() {
654 assert!(loopback_ws_origin_allowed(
655 Some("http://127.0.0.1:7777"),
656 &origins()
657 ));
658 assert!(loopback_ws_origin_allowed(
659 Some("http://localhost:7777"),
660 &origins()
661 ));
662 }
663
664 #[test]
665 fn missing_origin_is_rejected() {
666 assert!(!loopback_ws_origin_allowed(None, &origins()));
667 }
668
669 #[test]
670 fn cross_site_origin_is_rejected() {
671 assert!(!loopback_ws_origin_allowed(
672 Some("http://evil.example.com"),
673 &origins()
674 ));
675 // A different port is a different origin — exact match only.
676 assert!(!loopback_ws_origin_allowed(
677 Some("http://127.0.0.1:9999"),
678 &origins()
679 ));
680 }
681
682 #[test]
683 fn empty_allowlist_denies_every_origin() {
684 assert!(!loopback_ws_origin_allowed(
685 Some("http://127.0.0.1:7777"),
686 &[]
687 ));
688 }
689
690 // ----------------------------------------------------------------
691 // Scheme classification (issue #1044). `red://` / `reds://` are
692 // bridge-required remote targets and resolve to the parser's default
693 // port; `file://` and bare paths stay local.
694 // ----------------------------------------------------------------
695
696 #[test]
697 fn red_scheme_classifies_as_remote_plaintext_default_port() {
698 // No port → the shared parser's DEFAULT_PORT_RED (5050), no TLS.
699 assert_eq!(
700 classify_ui_target("red://db.internal").unwrap(),
701 UiTarget::Remote(RemoteRedwireTargetSpec {
702 host: "db.internal".to_string(),
703 port: reddb_wire::DEFAULT_PORT_RED,
704 tls: false,
705 })
706 );
707 assert_eq!(reddb_wire::DEFAULT_PORT_RED, 5050);
708 }
709
710 #[test]
711 fn reds_scheme_classifies_as_remote_tls_default_port() {
712 assert_eq!(
713 classify_ui_target("reds://db.internal").unwrap(),
714 UiTarget::Remote(RemoteRedwireTargetSpec {
715 host: "db.internal".to_string(),
716 port: reddb_wire::DEFAULT_PORT_RED,
717 tls: true,
718 })
719 );
720 }
721
722 #[test]
723 fn red_scheme_honours_explicit_port() {
724 assert_eq!(
725 classify_ui_target("red://127.0.0.1:6000").unwrap(),
726 UiTarget::Remote(RemoteRedwireTargetSpec {
727 host: "127.0.0.1".to_string(),
728 port: 6000,
729 tls: false,
730 })
731 );
732 assert_eq!(
733 classify_ui_target("reds://host:7001").unwrap(),
734 UiTarget::Remote(RemoteRedwireTargetSpec {
735 host: "host".to_string(),
736 port: 7001,
737 tls: true,
738 })
739 );
740 }
741
742 #[test]
743 fn file_and_bare_path_classify_as_local() {
744 assert_eq!(
745 classify_ui_target("file:///var/lib/db.rdb").unwrap(),
746 UiTarget::File
747 );
748 assert_eq!(classify_ui_target("./data.rdb").unwrap(), UiTarget::File);
749 assert_eq!(classify_ui_target("data.rdb").unwrap(), UiTarget::File);
750 }
751
752 #[test]
753 fn unsupported_scheme_is_rejected() {
754 // gRPC / http / a cluster URI are not single RedWire endpoints.
755 assert!(classify_ui_target("grpc://host:5055").is_err());
756 assert!(classify_ui_target("http://host").is_err());
757 assert!(classify_ui_target("red://a,b").is_err());
758 }
759
760 // ----------------------------------------------------------------
761 // Direct targets (issue #1045, ADR 0047 direct-when-reachable).
762 // `red+wss://` and `red+ws://` are browser-reachable WS endpoints —
763 // no loopback relay is needed.
764 // ----------------------------------------------------------------
765
766 #[test]
767 fn red_plus_wss_classifies_as_direct_default_port() {
768 assert_eq!(
769 classify_ui_target("red+wss://mydb.db.reddb.io").unwrap(),
770 UiTarget::Direct {
771 ws_url: "wss://mydb.db.reddb.io:443/redwire".to_string(),
772 }
773 );
774 }
775
776 #[test]
777 fn red_plus_wss_with_explicit_port_classifies_as_direct() {
778 assert_eq!(
779 classify_ui_target("red+wss://host:5055").unwrap(),
780 UiTarget::Direct {
781 ws_url: "wss://host:5055/redwire".to_string(),
782 }
783 );
784 }
785
786 #[test]
787 fn red_plus_ws_classifies_as_direct_plaintext() {
788 assert_eq!(
789 classify_ui_target("red+ws://host:8080").unwrap(),
790 UiTarget::Direct {
791 ws_url: "ws://host:8080/redwire".to_string(),
792 }
793 );
794 }
795
796 #[test]
797 fn unsupported_scheme_error_names_supported_set() {
798 let err = classify_ui_target("mongodb://host").unwrap_err();
799 for scheme in ["file://", "red://", "reds://", "red+ws://", "red+wss://"] {
800 assert!(
801 err.contains(scheme),
802 "error must mention {scheme}: got: {err}"
803 );
804 }
805 }
806
807 // ----------------------------------------------------------------
808 // inject_ws_url_config — config injection into HTML.
809 // ----------------------------------------------------------------
810
811 #[test]
812 fn inject_ws_url_inserts_before_head_close() {
813 let html = b"<html><head></head><body></body></html>".to_vec();
814 let out = inject_ws_url_config(html, "wss://host:443/redwire");
815 let s = String::from_utf8(out).unwrap();
816 assert!(
817 s.contains("<script>window.REDDB_WS_URL=\"wss://host:443/redwire\";</script></head>"),
818 "snippet must appear before </head>: {s}"
819 );
820 }
821
822 #[test]
823 fn inject_ws_url_noop_when_no_head_close() {
824 let html = b"<html><body>no head close</body></html>".to_vec();
825 let orig = html.clone();
826 let out = inject_ws_url_config(html, "wss://host/redwire");
827 assert_eq!(out, orig, "html without </head> must be returned unchanged");
828 }
829
830 #[test]
831 fn content_types_cover_bundle_assets() {
832 assert_eq!(content_type_for("index.html"), "text/html; charset=utf-8");
833 assert_eq!(content_type_for("app.js"), "text/javascript; charset=utf-8");
834 assert_eq!(content_type_for("style.css"), "text/css; charset=utf-8");
835 assert_eq!(content_type_for("data.bin"), "application/octet-stream");
836 }
837}