Skip to main content

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}