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(
316 listener,
317 router.into_make_service_with_connect_info::<std::net::SocketAddr>(),
318 )
319 .with_graceful_shutdown(async move {
320 let _ = shutdown_rx.await;
321 })
322 .await;
323 });
324
325 Ok(UiBridge {
326 local_addr,
327 shutdown_tx: Some(shutdown_tx),
328 join,
329 direct_ws_url: None,
330 })
331}
332
333/// Serve the UI bundle for a **direct** `red+wss://` / `red+ws://` target
334/// (ADR 0047). No loopback WS relay is started — the browser connects to
335/// `ws_url` directly. A `window.REDDB_WS_URL` config is injected into HTML
336/// responses so the UI page knows the target without user input.
337pub async fn spawn_direct_ui_server(
338 ws_url: String,
339 config: UiBridgeConfig,
340) -> std::io::Result<UiBridge> {
341 let listener = tokio::net::TcpListener::bind(("127.0.0.1", config.port)).await?;
342 let local_addr = listener.local_addr()?;
343
344 let state = BridgeState {
345 backend: Arc::new(BridgeBackend::Direct),
346 allowed_origins: Arc::new(vec![]),
347 ui_dir: config.ui_dir.map(Arc::new),
348 injected_token: config.injected_token.map(Arc::new),
349 auth_mode: config.auth_mode,
350 direct_ws_url: Some(Arc::new(ws_url.clone())),
351 };
352
353 // No /redwire route — the browser owns its own WS connection.
354 let router = axum::Router::new().fallback(serve_ui).with_state(state);
355
356 let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>();
357 let join = tokio::spawn(async move {
358 let _ = axum::serve(
359 listener,
360 router.into_make_service_with_connect_info::<std::net::SocketAddr>(),
361 )
362 .with_graceful_shutdown(async move {
363 let _ = shutdown_rx.await;
364 })
365 .await;
366 });
367
368 Ok(UiBridge {
369 local_addr,
370 shutdown_tx: Some(shutdown_tx),
371 join,
372 direct_ws_url: Some(ws_url),
373 })
374}
375
376/// axum handler for `GET /redwire`. Enforces the loopback Origin gate,
377/// then upgrades to a binary WebSocket and runs a RedWire session over it
378/// against the embedded engine (the same seam as the internet WS edge).
379async fn loopback_redwire_upgrade(
380 State(state): State<BridgeState>,
381 axum::extract::ConnectInfo(peer): axum::extract::ConnectInfo<std::net::SocketAddr>,
382 headers: HeaderMap,
383 ws: WebSocketUpgrade,
384) -> Response {
385 // `Origin` is asserted by the browser; the peer address is asserted
386 // by the kernel. The bridge exists for a page on this host, so a
387 // connection from anywhere else is refused before the header is
388 // even read — a misbound or forwarded listener cannot widen it.
389 if !peer.ip().is_loopback() {
390 return (
391 StatusCode::FORBIDDEN,
392 "loopback redwire websocket accepts connections from this host only",
393 )
394 .into_response();
395 }
396
397 let origin = headers
398 .get(header::ORIGIN)
399 .and_then(|value| value.to_str().ok());
400
401 if !loopback_ws_origin_allowed(origin, &state.allowed_origins) {
402 return (
403 StatusCode::FORBIDDEN,
404 "origin not allowed for loopback redwire websocket",
405 )
406 .into_response();
407 }
408
409 let backend = Arc::clone(&state.backend);
410 let injected_token = state.injected_token.clone();
411 ws.protocols([REDWIRE_WS_SUBPROTOCOL])
412 .on_upgrade(move |socket| async move {
413 match &*backend {
414 // `file://` — RedWire over the in-process embedded engine.
415 BridgeBackend::Embedded(server) => {
416 if let Some(token) = injected_token.as_deref().map(String::as_str) {
417 run_injected_ws_session(socket, (**server).clone(), token).await;
418 } else {
419 run_ws_session(socket, (**server).clone()).await;
420 }
421 }
422 // `red://` / `reds://` — relay to a remote RedWire instance.
423 BridgeBackend::Remote(target) => {
424 run_remote_ws_session(
425 socket,
426 target,
427 injected_token.as_deref().map(String::as_str),
428 )
429 .await;
430 }
431 // `red+wss://` / `red+ws://` — the `/redwire` route is not
432 // mounted for direct targets, so this arm is unreachable.
433 BridgeBackend::Direct => {
434 close_ws(socket).await;
435 }
436 }
437 })
438}
439
440/// Bridge the binary WebSocket to a remote RedWire-over-TCP/TLS endpoint.
441///
442/// Opens a fresh connection to `target` per WS session and pumps the byte
443/// stream straight through ([`pump_ws_stream`]). The browser sends the
444/// exact native preamble (`0xFE` magic + minor + `Hello`), so the remote
445/// listener's standalone session handles it unchanged — the bridge is a
446/// pure byte relay and never parses RedWire frames. On a connection
447/// failure the WS is closed so the UI surfaces the error rather than
448/// hanging.
449async fn run_remote_ws_session(
450 socket: WebSocket,
451 target: &RemoteRedwireTarget,
452 injected_token: Option<&str>,
453) {
454 let addr = (target.host.as_str(), target.port);
455 let tcp = match tokio::net::TcpStream::connect(addr).await {
456 Ok(tcp) => tcp,
457 Err(err) => {
458 tracing::warn!(
459 host = %target.host,
460 port = target.port,
461 err = %err,
462 "ui bridge: connect to remote redwire target failed"
463 );
464 close_ws(socket).await;
465 return;
466 }
467 };
468
469 if !target.tls {
470 if let Some(token) = injected_token {
471 inject_bearer_handshake(socket, tcp, token).await;
472 } else {
473 pump_ws_stream(socket, tcp).await;
474 }
475 return;
476 }
477
478 match wrap_remote_tls(tcp, target).await {
479 Ok(tls) => {
480 if let Some(token) = injected_token {
481 inject_bearer_handshake(socket, tls, token).await;
482 } else {
483 pump_ws_stream(socket, tls).await;
484 }
485 }
486 Err(err) => {
487 tracing::warn!(
488 host = %target.host,
489 port = target.port,
490 err = %err,
491 "ui bridge: TLS handshake to remote redwire target failed"
492 );
493 close_ws(socket).await;
494 }
495 }
496}
497
498/// Send a best-effort close frame on a WS the bridge is abandoning.
499async fn close_ws(mut socket: WebSocket) {
500 let _ = socket.send(axum::extract::ws::Message::Close(None)).await;
501}
502
503/// Negotiate client-side TLS to a `reds://` target. Trusts the webpki
504/// system roots, plus any caller-supplied CA bundle (PEM) — enough for a
505/// public-cert target out of the box and a self-signed / private-CA dev
506/// container when a `--tls-ca` bundle is passed. Server-only TLS (no
507/// client cert): RedWire auth is negotiated inside the handshake, exactly
508/// as on the native socket transports.
509async fn wrap_remote_tls(
510 tcp: tokio::net::TcpStream,
511 target: &RemoteRedwireTarget,
512) -> std::io::Result<tokio_rustls::client::TlsStream<tokio::net::TcpStream>> {
513 use rustls::pki_types::ServerName;
514 use rustls::{ClientConfig, RootCertStore};
515
516 let _ = rustls::crypto::ring::default_provider().install_default();
517
518 let mut roots = RootCertStore::empty();
519 // Seed the webpki system roots so a public-cert `reds://` works
520 // without an explicit CA.
521 roots.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
522 // Add any caller-supplied CA (a self-signed dev container / private CA).
523 if let Some(pem) = &target.ca_pem {
524 let mut reader = std::io::BufReader::new(&pem[..]);
525 for cert in rustls_pemfile::certs(&mut reader) {
526 let cert = cert.map_err(std::io::Error::other)?;
527 roots
528 .add(cert)
529 .map_err(|e| std::io::Error::other(format!("add CA cert: {e}")))?;
530 }
531 }
532
533 let config = ClientConfig::builder()
534 .with_root_certificates(roots)
535 .with_no_client_auth();
536
537 let connector = tokio_rustls::TlsConnector::from(Arc::new(config));
538 let server_name = ServerName::try_from(target.host.clone())
539 .map_err(|e| std::io::Error::other(format!("invalid TLS server name: {e}")))?;
540 connector.connect(server_name, tcp).await
541}
542
543/// Static-file fallback: serve the UI bundle. With a `--ui-dir`, files are
544/// read from that directory (`/` → `index.html`), guarded against path
545/// traversal; without one, the embedded fixture answers `/` and
546/// `/index.html` and everything else is 404.
547///
548/// For direct targets (`BridgeBackend::Direct`), the WS URL config
549/// (`window.REDDB_WS_URL`) is injected before `</head>` in HTML responses
550/// so the UI page can connect to the remote endpoint directly.
551async fn serve_ui(State(state): State<BridgeState>, uri: Uri) -> Response {
552 let raw = uri.path();
553 let rel = raw.trim_start_matches('/');
554 let rel = if rel.is_empty() { "index.html" } else { rel };
555
556 let (content_type, mut body) = match &state.ui_dir {
557 None => {
558 if rel == "index.html" {
559 (
560 "text/html; charset=utf-8",
561 FIXTURE_INDEX.as_bytes().to_vec(),
562 )
563 } else {
564 return not_found();
565 }
566 }
567 Some(dir) => {
568 // Reject traversal: no `..` or `.` segments, no absolute
569 // re-rooting. Only plain, forward components are served.
570 if rel
571 .split('/')
572 .any(|seg| seg == ".." || seg == "." || seg.is_empty())
573 {
574 return not_found();
575 }
576 let full = dir.join(rel);
577 // Read off the async runtime — `tokio::fs` is not enabled, and
578 // these are small local bundle assets on loopback anyway.
579 match tokio::task::spawn_blocking(move || std::fs::read(&full)).await {
580 Ok(Ok(bytes)) => (content_type_for(rel), bytes),
581 _ => return not_found(),
582 }
583 }
584 };
585
586 // For direct targets, inject `window.REDDB_WS_URL` before </head> so
587 // the UI page knows the remote WS endpoint without a loopback relay.
588 if content_type.starts_with("text/html") {
589 if let Some(ws_url) = &state.direct_ws_url {
590 body = inject_ws_url_config(body, ws_url);
591 }
592 body = super::ui_auth::inject_auth_mode_config(body, state.auth_mode);
593 }
594
595 (StatusCode::OK, [(header::CONTENT_TYPE, content_type)], body).into_response()
596}
597
598/// Inject `<script>window.REDDB_WS_URL="<ws_url>";</script>` just before
599/// `</head>` in an HTML document. The ws_url is constructed from a
600/// validated URI (scheme + host + port) and contains no `"` or `\`, so
601/// simple string interpolation is safe. Returns the original bytes
602/// unchanged when `</head>` is not found.
603fn inject_ws_url_config(html: Vec<u8>, ws_url: &str) -> Vec<u8> {
604 let snippet = format!("<script>window.REDDB_WS_URL=\"{ws_url}\";</script>");
605 let marker = b"</head>";
606 match html.windows(marker.len()).position(|w| w == marker) {
607 Some(pos) => {
608 let mut out = Vec::with_capacity(html.len() + snippet.len());
609 out.extend_from_slice(&html[..pos]);
610 out.extend_from_slice(snippet.as_bytes());
611 out.extend_from_slice(&html[pos..]);
612 out
613 }
614 None => html,
615 }
616}
617
618/// Guess a content type from a file extension. Minimal map covering the
619/// asset kinds a UI bundle ships; anything unknown is served as opaque
620/// bytes. Shared with the server-side static surface ([`super::ui_static`],
621/// `red server --ui`) so the two bundle-serving paths agree on MIME types.
622pub(crate) fn content_type_for(path: &str) -> &'static str {
623 let ext = path.rsplit('.').next().unwrap_or("");
624 match ext {
625 "html" | "htm" => "text/html; charset=utf-8",
626 "js" | "mjs" => "text/javascript; charset=utf-8",
627 "css" => "text/css; charset=utf-8",
628 "json" => "application/json; charset=utf-8",
629 "svg" => "image/svg+xml",
630 "png" => "image/png",
631 "jpg" | "jpeg" => "image/jpeg",
632 "gif" => "image/gif",
633 "ico" => "image/x-icon",
634 "wasm" => "application/wasm",
635 "map" => "application/json; charset=utf-8",
636 "txt" => "text/plain; charset=utf-8",
637 _ => "application/octet-stream",
638 }
639}
640
641fn content_type_response(path: &str, body: Vec<u8>) -> Response {
642 (
643 StatusCode::OK,
644 [(header::CONTENT_TYPE, content_type_for(path))],
645 body,
646 )
647 .into_response()
648}
649
650fn html_response(body: Vec<u8>) -> Response {
651 (
652 StatusCode::OK,
653 [(header::CONTENT_TYPE, "text/html; charset=utf-8")],
654 body,
655 )
656 .into_response()
657}
658
659fn not_found() -> Response {
660 (StatusCode::NOT_FOUND, "not found").into_response()
661}
662
663#[cfg(test)]
664mod tests {
665 use super::*;
666
667 fn origins() -> Vec<String> {
668 seed_loopback_origins(7777)
669 }
670
671 #[test]
672 fn served_origin_is_allowed() {
673 assert!(loopback_ws_origin_allowed(
674 Some("http://127.0.0.1:7777"),
675 &origins()
676 ));
677 assert!(loopback_ws_origin_allowed(
678 Some("http://localhost:7777"),
679 &origins()
680 ));
681 }
682
683 #[test]
684 fn missing_origin_is_rejected() {
685 assert!(!loopback_ws_origin_allowed(None, &origins()));
686 }
687
688 #[test]
689 fn cross_site_origin_is_rejected() {
690 assert!(!loopback_ws_origin_allowed(
691 Some("http://evil.example.com"),
692 &origins()
693 ));
694 // A different port is a different origin — exact match only.
695 assert!(!loopback_ws_origin_allowed(
696 Some("http://127.0.0.1:9999"),
697 &origins()
698 ));
699 }
700
701 #[test]
702 fn empty_allowlist_denies_every_origin() {
703 assert!(!loopback_ws_origin_allowed(
704 Some("http://127.0.0.1:7777"),
705 &[]
706 ));
707 }
708
709 // ----------------------------------------------------------------
710 // Scheme classification (issue #1044). `red://` / `reds://` are
711 // bridge-required remote targets and resolve to the parser's default
712 // port; `file://` and bare paths stay local.
713 // ----------------------------------------------------------------
714
715 #[test]
716 fn red_scheme_classifies_as_remote_plaintext_default_port() {
717 // No port → the shared parser's DEFAULT_PORT_RED (5050), no TLS.
718 assert_eq!(
719 classify_ui_target("red://db.internal").unwrap(),
720 UiTarget::Remote(RemoteRedwireTargetSpec {
721 host: "db.internal".to_string(),
722 port: reddb_wire::DEFAULT_PORT_RED,
723 tls: false,
724 })
725 );
726 assert_eq!(reddb_wire::DEFAULT_PORT_RED, 5050);
727 }
728
729 #[test]
730 fn reds_scheme_classifies_as_remote_tls_default_port() {
731 assert_eq!(
732 classify_ui_target("reds://db.internal").unwrap(),
733 UiTarget::Remote(RemoteRedwireTargetSpec {
734 host: "db.internal".to_string(),
735 port: reddb_wire::DEFAULT_PORT_RED,
736 tls: true,
737 })
738 );
739 }
740
741 #[test]
742 fn red_scheme_honours_explicit_port() {
743 assert_eq!(
744 classify_ui_target("red://127.0.0.1:6000").unwrap(),
745 UiTarget::Remote(RemoteRedwireTargetSpec {
746 host: "127.0.0.1".to_string(),
747 port: 6000,
748 tls: false,
749 })
750 );
751 assert_eq!(
752 classify_ui_target("reds://host:7001").unwrap(),
753 UiTarget::Remote(RemoteRedwireTargetSpec {
754 host: "host".to_string(),
755 port: 7001,
756 tls: true,
757 })
758 );
759 }
760
761 #[test]
762 fn file_and_bare_path_classify_as_local() {
763 assert_eq!(
764 classify_ui_target("file:///var/lib/db.rdb").unwrap(),
765 UiTarget::File
766 );
767 assert_eq!(classify_ui_target("./data.rdb").unwrap(), UiTarget::File);
768 assert_eq!(classify_ui_target("data.rdb").unwrap(), UiTarget::File);
769 }
770
771 #[test]
772 fn unsupported_scheme_is_rejected() {
773 // gRPC / http / a cluster URI are not single RedWire endpoints.
774 assert!(classify_ui_target("grpc://host:55055").is_err());
775 assert!(classify_ui_target("http://host").is_err());
776 assert!(classify_ui_target("red://a,b").is_err());
777 }
778
779 // ----------------------------------------------------------------
780 // Direct targets (issue #1045, ADR 0047 direct-when-reachable).
781 // `red+wss://` and `red+ws://` are browser-reachable WS endpoints —
782 // no loopback relay is needed.
783 // ----------------------------------------------------------------
784
785 #[test]
786 fn red_plus_wss_classifies_as_direct_default_port() {
787 assert_eq!(
788 classify_ui_target("red+wss://mydb.db.reddb.io").unwrap(),
789 UiTarget::Direct {
790 ws_url: "wss://mydb.db.reddb.io:443/redwire".to_string(),
791 }
792 );
793 }
794
795 #[test]
796 fn red_plus_wss_with_explicit_port_classifies_as_direct() {
797 assert_eq!(
798 classify_ui_target("red+wss://host:55055").unwrap(),
799 UiTarget::Direct {
800 ws_url: "wss://host:55055/redwire".to_string(),
801 }
802 );
803 }
804
805 #[test]
806 fn red_plus_ws_classifies_as_direct_plaintext() {
807 assert_eq!(
808 classify_ui_target("red+ws://host:5000").unwrap(),
809 UiTarget::Direct {
810 ws_url: "ws://host:5000/redwire".to_string(),
811 }
812 );
813 }
814
815 #[test]
816 fn unsupported_scheme_error_names_supported_set() {
817 let err = classify_ui_target("mongodb://host").unwrap_err();
818 for scheme in ["file://", "red://", "reds://", "red+ws://", "red+wss://"] {
819 assert!(
820 err.contains(scheme),
821 "error must mention {scheme}: got: {err}"
822 );
823 }
824 }
825
826 // ----------------------------------------------------------------
827 // inject_ws_url_config — config injection into HTML.
828 // ----------------------------------------------------------------
829
830 #[test]
831 fn inject_ws_url_inserts_before_head_close() {
832 let html = b"<html><head></head><body></body></html>".to_vec();
833 let out = inject_ws_url_config(html, "wss://host:443/redwire");
834 let s = String::from_utf8(out).unwrap();
835 assert!(
836 s.contains("<script>window.REDDB_WS_URL=\"wss://host:443/redwire\";</script></head>"),
837 "snippet must appear before </head>: {s}"
838 );
839 }
840
841 #[test]
842 fn inject_ws_url_noop_when_no_head_close() {
843 let html = b"<html><body>no head close</body></html>".to_vec();
844 let orig = html.clone();
845 let out = inject_ws_url_config(html, "wss://host/redwire");
846 assert_eq!(out, orig, "html without </head> must be returned unchanged");
847 }
848
849 #[test]
850 fn content_types_cover_bundle_assets() {
851 assert_eq!(content_type_for("index.html"), "text/html; charset=utf-8");
852 assert_eq!(content_type_for("app.js"), "text/javascript; charset=utf-8");
853 assert_eq!(content_type_for("style.css"), "text/css; charset=utf-8");
854 assert_eq!(content_type_for("data.bin"), "application/octet-stream");
855 }
856}