Skip to main content

omni_dev/browser/
bridge.rs

1//! The long-lived bridge server.
2//!
3//! A WebSocket plane the browser connects to and an HTTP control plane the
4//! operator drives, joined by an `id`-keyed correlator.
5//!
6//! A request flows: control plane (authenticated) → assign `id` + register a
7//! `oneshot` waiter → serialise a [`Command`] frame → WebSocket → browser
8//! `fetch()` → [`BrowserReply`] frame → correlator resolves the waiter by `id`
9//! → control plane returns the HTTP response.
10
11use std::collections::{BTreeMap, HashMap};
12use std::net::{Ipv4Addr, Ipv6Addr, SocketAddr};
13use std::sync::atomic::{AtomicU64, Ordering};
14use std::sync::{Arc, Mutex as StdMutex};
15use std::time::{Duration, Instant};
16
17use anyhow::{Context, Result};
18use axum::{
19    body::{Body, Bytes},
20    extract::{DefaultBodyLimit, Request, State},
21    http::{header, StatusCode},
22    middleware::{self, Next},
23    response::{IntoResponse, Response},
24    routing::{get, post},
25    Json, Router,
26};
27use base64::engine::general_purpose::STANDARD as BASE64;
28use base64::Engine as _;
29use futures::{SinkExt, StreamExt};
30use tokio::net::{TcpListener, TcpSocket, TcpStream};
31use tokio::sync::{mpsc, oneshot, OwnedSemaphorePermit, Semaphore};
32use tokio::task::JoinSet;
33use tokio_tungstenite::tungstenite::Message;
34use tokio_util::sync::CancellationToken;
35
36use crate::browser::auth;
37use crate::browser::protocol::{
38    BrowserFrame, BrowserReply, CancelCommand, Command, ControlRequest, ReplyOutcome,
39    ResponseEnvelope, StatusResponse, StreamItem, StreamLine, TabInfo,
40};
41use crate::browser::snippet;
42use crate::request_log;
43
44/// Default WebSocket-plane port.
45pub const DEFAULT_WS_PORT: u16 = 9999;
46/// Default HTTP control-plane port.
47pub const DEFAULT_CONTROL_PORT: u16 = 9998;
48/// Default per-request timeout, in seconds.
49pub const DEFAULT_TIMEOUT_SECS: u64 = 30;
50/// Default maximum browser response body size (8 MiB).
51pub const DEFAULT_MAX_BODY_BYTES: usize = 8 * 1024 * 1024;
52/// Default maximum concurrent in-flight requests.
53pub const DEFAULT_MAX_CONCURRENT: usize = 64;
54
55/// Resolved runtime configuration for a bridge instance.
56#[derive(Debug, Clone)]
57pub struct BridgeConfig {
58    /// WebSocket-plane port (`0` binds an OS-assigned free port).
59    pub ws_port: u16,
60    /// HTTP control-plane port (`0` binds an OS-assigned free port).
61    pub control_port: u16,
62    /// Per-request timeout before the control plane returns `504`.
63    pub request_timeout: Duration,
64    /// Optional cross-origin allowlist for both the WS upgrade and outbound URLs.
65    pub allow_origin: Option<String>,
66    /// Maximum browser response body size accepted, in bytes.
67    pub max_body_bytes: usize,
68    /// Maximum number of concurrent in-flight requests.
69    pub max_concurrent: usize,
70}
71
72impl Default for BridgeConfig {
73    /// The documented default ports and limits, shared by the `serve` CLI and
74    /// the daemon-hosted bridge so the two never drift.
75    fn default() -> Self {
76        Self {
77            ws_port: DEFAULT_WS_PORT,
78            control_port: DEFAULT_CONTROL_PORT,
79            request_timeout: Duration::from_secs(DEFAULT_TIMEOUT_SECS),
80            allow_origin: None,
81            max_body_bytes: DEFAULT_MAX_BODY_BYTES,
82            max_concurrent: DEFAULT_MAX_CONCURRENT,
83        }
84    }
85}
86
87/// A registered waiter for a given id: either a buffered one-shot (resolved by a
88/// single reply) or a stream (fed many [`StreamItem`]s until `End`/`Error`).
89enum Waiter {
90    /// Buffered request: one reply resolves it.
91    Buffered(oneshot::Sender<BrowserReply>),
92    /// Streamed request: head + chunk + terminator items are forwarded here.
93    Stream(mpsc::UnboundedSender<StreamItem>),
94}
95
96/// `id → waiter` registry plus the monotonic id counter.
97#[derive(Clone)]
98struct Correlator {
99    pending: Arc<StdMutex<HashMap<u64, Waiter>>>,
100    next_id: Arc<AtomicU64>,
101}
102
103impl Correlator {
104    fn new() -> Self {
105        Self {
106            pending: Arc::new(StdMutex::new(HashMap::new())),
107            next_id: Arc::new(AtomicU64::new(1)),
108        }
109    }
110
111    /// Allocates an id and registers a buffered waiter for its single reply.
112    fn register(&self) -> (u64, oneshot::Receiver<BrowserReply>) {
113        let id = self.next_id.fetch_add(1, Ordering::Relaxed);
114        let (tx, rx) = oneshot::channel();
115        self.lock().insert(id, Waiter::Buffered(tx));
116        (id, rx)
117    }
118
119    /// Allocates an id and registers a stream waiter that receives every
120    /// [`StreamItem`] of the response until `End`/`Error`.
121    fn register_stream(&self) -> (u64, mpsc::UnboundedReceiver<StreamItem>) {
122        let id = self.next_id.fetch_add(1, Ordering::Relaxed);
123        let (tx, rx) = mpsc::unbounded_channel();
124        self.lock().insert(id, Waiter::Stream(tx));
125        (id, rx)
126    }
127
128    /// Drops a waiter without resolving it (timeout / send failure cleanup).
129    fn remove(&self, id: u64) {
130        self.lock().remove(&id);
131    }
132
133    /// Routes an inbound browser frame to its waiter.
134    ///
135    /// Buffered waiters resolve and are removed; stream waiters receive one
136    /// [`StreamItem`] and are removed only on a terminal item (or when their
137    /// consumer has gone). Returns `Some(id)` when the browser should be told to
138    /// cancel that stream (its control-plane consumer disconnected).
139    fn deliver(&self, frame: BrowserFrame) -> Option<u64> {
140        let id = frame.id;
141        let mut guard = self.lock();
142        match guard.get(&id) {
143            Some(Waiter::Buffered(_)) => {
144                if let Some(Waiter::Buffered(tx)) = guard.remove(&id) {
145                    let _ = tx.send(frame.into_reply());
146                }
147                None
148            }
149            Some(Waiter::Stream(_)) => {
150                let item = frame.stream_item();
151                let terminal = matches!(item, StreamItem::End | StreamItem::Error(_));
152                let send_failed = match guard.get(&id) {
153                    Some(Waiter::Stream(tx)) => tx.send(item).is_err(),
154                    _ => false,
155                };
156                if terminal || send_failed {
157                    guard.remove(&id);
158                }
159                send_failed.then_some(id)
160            }
161            None => None,
162        }
163    }
164
165    fn pending_count(&self) -> usize {
166        self.lock().len()
167    }
168
169    fn lock(&self) -> std::sync::MutexGuard<'_, HashMap<u64, Waiter>> {
170        self.pending
171            .lock()
172            .unwrap_or_else(std::sync::PoisonError::into_inner)
173    }
174}
175
176/// One authenticated browser connection in the registry.
177struct WsConn {
178    /// Outbound message channel to this connection's writer task.
179    sender: mpsc::UnboundedSender<Message>,
180    /// The connecting tab's `Origin`, if it sent one.
181    origin: Option<String>,
182}
183
184/// Shared server state, cloned into every handler and task.
185#[derive(Clone)]
186struct AppState {
187    token: Arc<String>,
188    config: Arc<BridgeConfig>,
189    correlator: Correlator,
190    /// Connected tabs keyed by connection id (the public routing selector). A
191    /// new authenticated connection never displaces an existing one — each lives
192    /// under its own key — so the non-eviction guarantee holds per-connection.
193    ///
194    /// A `std::sync::Mutex` (not tokio's): the guard is never held across an
195    /// `.await`, so a synchronous lock keeps [`AppState::status_snapshot`] and
196    /// [`BridgeServer::disconnect_tab`] non-async — matching [`Correlator`].
197    tabs: Arc<StdMutex<HashMap<u64, WsConn>>>,
198    in_flight: Arc<Semaphore>,
199    conn_counter: Arc<AtomicU64>,
200}
201
202impl AppState {
203    /// Locks the tab registry, recovering from a poisoned mutex (mirrors
204    /// [`Correlator::lock`]).
205    fn lock_tabs(&self) -> std::sync::MutexGuard<'_, HashMap<u64, WsConn>> {
206        self.tabs
207            .lock()
208            .unwrap_or_else(std::sync::PoisonError::into_inner)
209    }
210
211    /// Builds a [`StatusResponse`] snapshot of the connected tabs and pending
212    /// request count. Shared by the `GET /__bridge/status` handler and
213    /// [`BridgeServer::status`] so neither makes an internal HTTP self-call.
214    fn status_snapshot(&self) -> StatusResponse {
215        let mut tabs: Vec<TabInfo> = {
216            let guard = self.lock_tabs();
217            guard
218                .iter()
219                .map(|(id, conn)| TabInfo {
220                    id: *id,
221                    origin: conn.origin.clone(),
222                })
223                .collect()
224        };
225        tabs.sort_by_key(|t| t.id);
226        // `browser_origin` is v1 back-compat: meaningful only when exactly one
227        // tab is connected; ambiguous (so `None`) for zero or several.
228        let browser_origin = match tabs.as_slice() {
229            [only] => only.origin.clone(),
230            _ => None,
231        };
232        StatusResponse {
233            connected: !tabs.is_empty(),
234            browser_origin,
235            tabs,
236            pending: self.correlator.pending_count(),
237        }
238    }
239}
240
241/// A running bridge instance: both loopback-TCP planes bound and serving, with
242/// a [`CancellationToken`] for graceful shutdown.
243///
244/// Created by [`BridgeServer::start`], which returns once the planes are bound
245/// (fail-closed) and the accept loops are spawned. A standalone `serve` calls
246/// [`wait`](Self::wait) (run until Ctrl-C); a supervisor (the daemon) instead
247/// drives [`status`](Self::status) / [`disconnect_tab`](Self::disconnect_tab) /
248/// [`shutdown`](Self::shutdown) directly.
249pub struct BridgeServer {
250    state: AppState,
251    control_port: u16,
252    ws_port: u16,
253    shutdown: CancellationToken,
254    tasks: JoinSet<()>,
255}
256
257/// Binds a loopback TCP listener with `SO_REUSEADDR` so the bridge can rebind
258/// its fixed ports immediately after a restart even when a just-closed
259/// connection still holds the address in `TIME_WAIT` (#990). `SO_REUSEADDR`
260/// does *not* permit a second live listener on the same address, so the
261/// fail-closed-on-a-live-squatter guarantee (see
262/// `start_fails_closed_on_taken_control_port`) is unchanged.
263fn bind_loopback_reuse(addr: SocketAddr) -> std::io::Result<TcpListener> {
264    let socket = if addr.is_ipv4() {
265        TcpSocket::new_v4()?
266    } else {
267        TcpSocket::new_v6()?
268    };
269    socket.set_reuseaddr(true)?;
270    socket.bind(addr)?;
271    socket.listen(1024) // matches tokio's default `TcpListener::bind` backlog
272}
273
274impl BridgeServer {
275    /// Binds both planes (fail-closed), spawns the accept loops, and returns
276    /// immediately. `token` is the already-resolved session token (never
277    /// sourced from argv).
278    pub async fn start(mut config: BridgeConfig, token: String) -> Result<Self> {
279        let control_listener =
280            bind_loopback_reuse(SocketAddr::from((Ipv4Addr::LOCALHOST, config.control_port)))
281                .with_context(|| {
282                    format!(
283                        "Failed to bind control plane to 127.0.0.1:{} (already in use?)",
284                        config.control_port
285                    )
286                })?;
287        let ws_listener =
288            bind_loopback_reuse(SocketAddr::from((Ipv4Addr::LOCALHOST, config.ws_port)))
289                .with_context(|| {
290                    format!(
291                        "Failed to bind WebSocket plane to 127.0.0.1:{} (already in use?)",
292                        config.ws_port
293                    )
294                })?;
295
296        // Read back the OS-assigned ports so port-0 (random) is reflected
297        // everywhere: the snippet, the printed instructions, and the Host check.
298        config.control_port = control_listener.local_addr()?.port();
299        config.ws_port = ws_listener.local_addr()?.port();
300        let control_port = config.control_port;
301        let ws_port = config.ws_port;
302        let max_body_bytes = config.max_body_bytes;
303        let max_concurrent = config.max_concurrent;
304
305        // Also accept the WebSocket plane on IPv6 loopback (`::1`) at the same
306        // port, best-effort. A browser connecting to `ws://localhost` may resolve
307        // it to `::1` rather than `127.0.0.1`; binding both means the pasted
308        // snippet connects either way. Still loopback-only — ADR-0036 unchanged.
309        let ws_listener_v6 =
310            bind_loopback_reuse(SocketAddr::from((Ipv6Addr::LOCALHOST, ws_port))).ok();
311
312        let state = AppState {
313            token: Arc::new(token),
314            config: Arc::new(config),
315            correlator: Correlator::new(),
316            tabs: Arc::new(StdMutex::new(HashMap::new())),
317            in_flight: Arc::new(Semaphore::new(max_concurrent)),
318            conn_counter: Arc::new(AtomicU64::new(1)),
319        };
320
321        let shutdown = CancellationToken::new();
322        let mut tasks = JoinSet::new();
323
324        // WebSocket accept loops (cancellable), one per bound loopback address.
325        spawn_ws_accept(&mut tasks, ws_listener, state.clone(), shutdown.clone());
326        if let Some(listener) = ws_listener_v6 {
327            spawn_ws_accept(&mut tasks, listener, state.clone(), shutdown.clone());
328        } else {
329            tracing::debug!("IPv6 loopback (::1) WebSocket bind unavailable; IPv4 only");
330        }
331
332        // Control plane (axum) with graceful shutdown: drains in-flight requests
333        // before the serve future resolves.
334        let control_state = state.clone();
335        let control_shutdown = shutdown.clone();
336        tasks.spawn(async move {
337            let app = control_router(control_state, max_body_bytes);
338            if let Err(e) = axum::serve(control_listener, app)
339                .with_graceful_shutdown(control_shutdown.cancelled_owned())
340                .await
341            {
342                tracing::warn!("Control-plane server error: {e}");
343            }
344        });
345
346        Ok(Self {
347            state,
348            control_port,
349            ws_port,
350            shutdown,
351            tasks,
352        })
353    }
354
355    /// The bound control-plane port (resolved if `0` was requested).
356    pub fn control_port(&self) -> u16 {
357        self.control_port
358    }
359
360    /// The bound WebSocket-plane port (resolved if `0` was requested).
361    pub fn ws_port(&self) -> u16 {
362        self.ws_port
363    }
364
365    /// A synchronous snapshot of connection status (the same payload the
366    /// `GET /__bridge/status` endpoint returns).
367    pub fn status(&self) -> StatusResponse {
368        self.state.status_snapshot()
369    }
370
371    /// Disconnects the tab with the given connection id: sends a WebSocket
372    /// Close and drops it from the registry. Errors if no such tab is connected.
373    pub fn disconnect_tab(&self, id: u64) -> Result<()> {
374        let mut tabs = self.state.lock_tabs();
375        match tabs.remove(&id) {
376            Some(conn) => {
377                let _ = conn.sender.send(Message::Close(None));
378                Ok(())
379            }
380            None => anyhow::bail!("no connected tab with id {id}"),
381        }
382    }
383
384    /// Runs until Ctrl-C, then shuts down gracefully. Used by standalone
385    /// `serve` (a supervisor calls [`shutdown`](Self::shutdown) directly).
386    pub async fn wait(self) -> Result<()> {
387        let _ = tokio::signal::ctrl_c().await;
388        self.shutdown().await;
389        Ok(())
390    }
391
392    /// Cancels the planes and drains spawned tasks (bounded by a timeout, after
393    /// which any stragglers are aborted when the [`JoinSet`] drops).
394    pub async fn shutdown(self) {
395        let Self {
396            mut tasks,
397            shutdown,
398            ..
399        } = self;
400        shutdown.cancel();
401        let drain = async { while tasks.join_next().await.is_some() {} };
402        if tokio::time::timeout(Duration::from_secs(10), drain)
403            .await
404            .is_err()
405        {
406            tracing::warn!("bridge shutdown timed out; remaining tasks will be aborted");
407        }
408    }
409}
410
411/// Binds both planes (fail-closed) and serves until the process is stopped.
412///
413/// Thin wrapper over [`BridgeServer::start`] + [`BridgeServer::wait`] that also
414/// prints the startup banner — preserved so `omni-dev browser bridge serve` is
415/// behaviourally unchanged.
416///
417/// `token` is the already-resolved session token (never sourced from argv).
418pub async fn run(config: BridgeConfig, token: String) -> Result<()> {
419    let server = BridgeServer::start(config, token).await?;
420    print_startup(server.state.config.as_ref(), &server.state.token);
421    server.wait().await
422}
423
424/// Prints the bound ports, session token, and paste-ready snippet to stdout.
425fn print_startup(config: &BridgeConfig, token: &str) {
426    let snippet = snippet::render(config.ws_port, token);
427    println!("omni-dev browser bridge serve");
428    println!("  control plane : http://127.0.0.1:{}", config.control_port);
429    println!("  websocket     : ws://127.0.0.1:{}", config.ws_port);
430    println!("  session token : {token}");
431    if let Some(origin) = &config.allow_origin {
432        println!("  allow-origin  : {origin}");
433    }
434    println!();
435    println!("Paste this into the DevTools console of the authenticated tab:");
436    println!();
437    println!("{snippet}");
438    println!();
439    println!("Then drive requests, e.g.:");
440    println!(
441        "  omni-dev browser bridge request --control-port {} --url /path",
442        config.control_port
443    );
444}
445
446// ── WebSocket plane ──────────────────────────────────────────────────
447
448/// Spawns a cancellable accept loop for one WebSocket-plane listener; each
449/// accepted connection is handled on its own task. Used once per bound loopback
450/// address (IPv4 and, when available, IPv6) feeding the shared [`AppState`].
451fn spawn_ws_accept(
452    tasks: &mut JoinSet<()>,
453    listener: TcpListener,
454    state: AppState,
455    shutdown: CancellationToken,
456) {
457    tasks.spawn(async move {
458        loop {
459            tokio::select! {
460                () = shutdown.cancelled() => break,
461                accepted = listener.accept() => match accepted {
462                    Ok((stream, _peer)) => {
463                        tokio::spawn(handle_ws_conn(stream, state.clone()));
464                    }
465                    Err(e) => tracing::warn!("WebSocket accept error: {e}"),
466                },
467            }
468        }
469    });
470}
471
472/// Handles one inbound TCP connection on the WebSocket plane: authenticates the
473/// upgrade, registers the connection, and pumps replies into the correlator.
474//
475// `clippy::result_large_err`: the handshake callback's `Result<Response,
476// ErrorResponse>` return type is dictated by `tungstenite::accept_hdr_async`;
477// `ErrorResponse` is a large `http::Response`, but the signature is not ours to
478// change.
479#[allow(clippy::result_large_err)]
480async fn handle_ws_conn(stream: TcpStream, state: AppState) {
481    use tokio_tungstenite::tungstenite::handshake::server::{ErrorResponse, Request, Response};
482
483    let token = state.token.clone();
484    let allow_origin = state.config.allow_origin.clone();
485    let captured_origin: Arc<StdMutex<Option<String>>> = Arc::new(StdMutex::new(None));
486    let co = captured_origin.clone();
487
488    let callback =
489        move |req: &Request, mut response: Response| -> Result<Response, ErrorResponse> {
490            let origin = req
491                .headers()
492                .get("origin")
493                .and_then(|v| v.to_str().ok())
494                .map(str::to_string);
495
496            if !auth::ws_origin_allowed(origin.as_deref(), allow_origin.as_deref()) {
497                tracing::warn!("Rejected WebSocket upgrade: origin not allowed");
498                return Err(ws_error(StatusCode::FORBIDDEN, "origin not allowed"));
499            }
500
501            let protocols: Vec<String> = req
502                .headers()
503                .get_all("sec-websocket-protocol")
504                .iter()
505                .filter_map(|v| v.to_str().ok())
506                .flat_map(|s| s.split(',').map(|p| p.trim().to_string()))
507                .collect();
508
509            let Some(matched) =
510                auth::ws_subprotocol_token(protocols.iter().map(String::as_str), &token)
511            else {
512                tracing::warn!("Rejected WebSocket upgrade: missing or invalid token");
513                return Err(ws_error(
514                    StatusCode::UNAUTHORIZED,
515                    "missing or invalid token",
516                ));
517            };
518            if let Ok(value) = matched.parse() {
519                response
520                    .headers_mut()
521                    .insert("sec-websocket-protocol", value);
522            }
523            *co.lock().unwrap_or_else(std::sync::PoisonError::into_inner) = origin;
524            Ok(response)
525        };
526
527    let ws_stream = match tokio_tungstenite::accept_hdr_async(stream, callback).await {
528        Ok(s) => s,
529        Err(e) => {
530            tracing::debug!("WebSocket handshake failed: {e}");
531            return;
532        }
533    };
534
535    let origin = captured_origin
536        .lock()
537        .unwrap_or_else(std::sync::PoisonError::into_inner)
538        .take();
539    let conn_id = state.conn_counter.fetch_add(1, Ordering::Relaxed);
540    tracing::info!(
541        "Browser connected (conn {conn_id}{})",
542        origin
543            .as_deref()
544            .map(|o| format!(", origin {o}"))
545            .unwrap_or_default()
546    );
547
548    let (tx, mut rx) = mpsc::unbounded_channel::<Message>();
549    state
550        .lock_tabs()
551        .insert(conn_id, WsConn { sender: tx, origin });
552
553    let (mut sink, mut read) = ws_stream.split();
554    let writer = tokio::spawn(async move {
555        while let Some(msg) = rx.recv().await {
556            if sink.send(msg).await.is_err() {
557                break;
558            }
559        }
560    });
561
562    while let Some(Ok(msg)) = read.next().await {
563        match msg {
564            Message::Text(txt) => match serde_json::from_str::<BrowserFrame>(&txt) {
565                Ok(frame) => {
566                    // If a streamed response's control-plane consumer has gone,
567                    // tell *this* browser (the one fetching it) to cancel its
568                    // reader so it stops fetching.
569                    if let Some(cancel_id) = state.correlator.deliver(frame) {
570                        send_cancel(&state, conn_id, cancel_id).await;
571                    }
572                }
573                Err(e) => tracing::debug!("Unparseable browser frame: {e}"),
574            },
575            Message::Close(_) => break,
576            _ => {}
577        }
578    }
579
580    writer.abort();
581    // Drop only this connection's registry entry (keyed by its unique id, so a
582    // reconnect under a new id is never clobbered).
583    if state.lock_tabs().remove(&conn_id).is_some() {
584        tracing::info!("Browser disconnected (conn {conn_id})");
585    }
586}
587
588fn ws_error(
589    code: StatusCode,
590    msg: &str,
591) -> tokio_tungstenite::tungstenite::handshake::server::ErrorResponse {
592    let mut resp = tokio_tungstenite::tungstenite::http::Response::new(Some(msg.to_string()));
593    *resp.status_mut() = code;
594    resp
595}
596
597// ── HTTP control plane ───────────────────────────────────────────────
598
599fn control_router(state: AppState, max_body_bytes: usize) -> Router {
600    Router::new()
601        .route("/__bridge/status", get(status_handler))
602        .route("/__bridge/request", post(request_handler))
603        .fallback(proxy_handler)
604        .layer(DefaultBodyLimit::max(max_body_bytes))
605        .layer(middleware::from_fn_with_state(state.clone(), guard))
606        .with_state(state)
607}
608
609/// Enforces the control-plane trust boundary on every request: bearer token,
610/// `X-Omni-Bridge: 1`, `Host` allowlist, and rejection of browser-originated
611/// requests. Emits no CORS headers and refuses `OPTIONS`.
612async fn guard(State(state): State<AppState>, request: Request, next: Next) -> Response {
613    // Never answer OPTIONS (would be a CORS preflight); legitimate CLI clients
614    // do not send it.
615    if request.method() == axum::http::Method::OPTIONS {
616        return (StatusCode::METHOD_NOT_ALLOWED, "OPTIONS not allowed").into_response();
617    }
618
619    let headers = request.headers();
620    let get = |name: &str| headers.get(name).and_then(|v| v.to_str().ok());
621
622    let host = get(header::HOST.as_str()).unwrap_or("");
623    if !auth::host_allowed(host, state.config.control_port) {
624        tracing::warn!("Rejected control-plane request: disallowed Host");
625        return (StatusCode::BAD_REQUEST, "host not allowed").into_response();
626    }
627
628    if auth::is_browser_originated(get("origin"), get("sec-fetch-site")) {
629        tracing::warn!("Rejected control-plane request: browser-originated");
630        return (
631            StatusCode::FORBIDDEN,
632            "browser-originated requests are denied",
633        )
634            .into_response();
635    }
636
637    if !auth::has_bridge_header(get(auth::BRIDGE_HEADER)) {
638        return (StatusCode::FORBIDDEN, "missing X-Omni-Bridge: 1").into_response();
639    }
640
641    if !auth::bearer_matches(get(header::AUTHORIZATION.as_str()), &state.token) {
642        return (StatusCode::UNAUTHORIZED, "invalid or missing bearer token").into_response();
643    }
644
645    next.run(request).await
646}
647
648async fn status_handler(State(state): State<AppState>) -> Json<StatusResponse> {
649    Json(state.status_snapshot())
650}
651
652/// `POST /__bridge/request` — full-fidelity control endpoint. A `stream: true`
653/// body returns an NDJSON stream (head line, `{seq,chunk}` lines, `{done}`);
654/// otherwise a single JSON response envelope.
655async fn request_handler(
656    State(state): State<AppState>,
657    headers: axum::http::HeaderMap,
658    body: Bytes,
659) -> Response {
660    let mut req: ControlRequest = match serde_json::from_slice(&body) {
661        Ok(r) => r,
662        Err(e) => {
663            return (StatusCode::BAD_REQUEST, format!("invalid JSON body: {e}")).into_response()
664        }
665    };
666    // The header, when present, overrides a `target` body field.
667    if let Some(target) = target_header(&headers) {
668        req.target = Some(target);
669    }
670    if req.stream {
671        return match start_stream(&state, req).await {
672            Ok((status, headers, driver)) => ndjson_stream_response(status, headers, driver),
673            Err((code, msg)) => (code, msg).into_response(),
674        };
675    }
676    match dispatch(&state, req).await {
677        Ok(env) => Json(env).into_response(),
678        Err((code, msg)) => (code, msg).into_response(),
679    }
680}
681
682/// Transparent proxy for any path not under `/__bridge/`.
683async fn proxy_handler(State(state): State<AppState>, request: Request) -> Response {
684    let (parts, body) = request.into_parts();
685
686    let path = parts.uri.path();
687    if auth::normalize_request_path(path).is_none() {
688        return (StatusCode::BAD_REQUEST, "unsafe request path").into_response();
689    }
690    // `?__stream=1` opts the proxied request into a streamed (chunked) response;
691    // the marker is stripped so it never reaches the upstream URL.
692    let (stream, forwarded_query) = extract_stream_flag(parts.uri.query());
693    let url = match forwarded_query.as_deref() {
694        Some(q) => format!("{path}?{q}"),
695        None => path.to_string(),
696    };
697
698    let headers = forwardable_headers(&parts.headers);
699
700    let Ok(body_bytes) = axum::body::to_bytes(body, state.config.max_body_bytes).await else {
701        return (StatusCode::PAYLOAD_TOO_LARGE, "request body too large").into_response();
702    };
703    let body = if body_bytes.is_empty() {
704        None
705    } else {
706        Some(String::from_utf8_lossy(&body_bytes).into_owned())
707    };
708
709    let req = ControlRequest {
710        url,
711        method: parts.method.to_string(),
712        headers,
713        body,
714        stream,
715        target: target_header(&parts.headers),
716        // The transparent proxy has no per-request override channel; cross-origin
717        // proxying is governed solely by the `serve --allow-origin` global.
718        allow_origin: None,
719        // The transparent proxy has no per-request credentials control; it keeps
720        // the snippet default (`include`), matching pre-credentials behavior.
721        credentials: None,
722    };
723
724    if stream {
725        return match start_stream(&state, req).await {
726            Ok((status, headers, driver)) => raw_stream_response(status, headers, driver),
727            Err((code, msg)) => (code, msg).into_response(),
728        };
729    }
730
731    match dispatch(&state, req).await {
732        Ok(env) => envelope_to_response(env),
733        Err((code, msg)) => (code, msg).into_response(),
734    }
735}
736
737/// Splits a `__stream` marker out of a query string.
738///
739/// Returns whether streaming was requested and the query with the marker
740/// removed (`None` when nothing remains). `__stream=0` / `__stream=false`
741/// explicitly disable it; any other presence enables it.
742fn extract_stream_flag(query: Option<&str>) -> (bool, Option<String>) {
743    let Some(query) = query else {
744        return (false, None);
745    };
746    let mut stream = false;
747    let kept: Vec<&str> = query
748        .split('&')
749        .filter(|kv| {
750            let (key, value) = match kv.split_once('=') {
751                Some((k, v)) => (k, Some(v)),
752                None => (*kv, None),
753            };
754            if key == "__stream" {
755                stream = !matches!(value, Some("0" | "false"));
756                false
757            } else {
758                true
759            }
760        })
761        .collect();
762    let rebuilt = (!kept.is_empty()).then(|| kept.join("&"));
763    (stream, rebuilt)
764}
765
766/// Copies request headers safe to forward to the browser, dropping the
767/// bridge-control and hop-by-hop headers a CLI client adds.
768fn forwardable_headers(headers: &axum::http::HeaderMap) -> BTreeMap<String, String> {
769    const DROP: &[&str] = &[
770        "host",
771        "authorization",
772        auth::BRIDGE_HEADER,
773        auth::BRIDGE_TARGET_HEADER,
774        "content-length",
775        "connection",
776        "accept-encoding",
777        "origin",
778        "sec-fetch-site",
779        "sec-fetch-mode",
780        "sec-fetch-dest",
781    ];
782    headers
783        .iter()
784        .filter_map(|(k, v)| {
785            let name = k.as_str();
786            if DROP.contains(&name) {
787                return None;
788            }
789            v.to_str()
790                .ok()
791                .map(|val| (name.to_string(), val.to_string()))
792        })
793        .collect()
794}
795
796/// Extracts the `X-Omni-Bridge-Target` selector from request headers, if present
797/// and non-empty.
798fn target_header(headers: &axum::http::HeaderMap) -> Option<String> {
799    headers
800        .get(auth::BRIDGE_TARGET_HEADER)
801        .and_then(|v| v.to_str().ok())
802        .map(str::trim)
803        .filter(|s| !s.is_empty())
804        .map(str::to_string)
805}
806
807/// Resolves which connected tab a request targets, returning its connection id
808/// and a clone of its outbound sender.
809///
810/// An explicit `target` is a connection id (canonical) or an `Origin` that
811/// uniquely matches one tab. With no target, routing succeeds only when exactly
812/// one tab is connected — otherwise the request is ambiguous and rejected.
813fn resolve_target(
814    tabs: &HashMap<u64, WsConn>,
815    target: Option<&str>,
816) -> Result<(u64, mpsc::UnboundedSender<Message>), (StatusCode, String)> {
817    if tabs.is_empty() {
818        return Err((
819            StatusCode::SERVICE_UNAVAILABLE,
820            "no browser connected".to_string(),
821        ));
822    }
823    let Some(sel) = target else {
824        // No target: route only when exactly one tab is connected.
825        let mut it = tabs.iter();
826        return match (it.next(), it.next()) {
827            (Some((id, conn)), None) => Ok((*id, conn.sender.clone())),
828            _ => Err((
829                StatusCode::CONFLICT,
830                format!(
831                    "multiple tabs connected; select one with the X-Omni-Bridge-Target \
832                     header or a `target` field ({})",
833                    tab_list(tabs)
834                ),
835            )),
836        };
837    };
838
839    // A bare integer selects by connection id (canonical, unambiguous).
840    if let Ok(id) = sel.parse::<u64>() {
841        return match tabs.get(&id) {
842            Some(conn) => Ok((id, conn.sender.clone())),
843            None => Err((
844                StatusCode::NOT_FOUND,
845                format!(
846                    "no connected tab with id {id}; connected: {}",
847                    tab_list(tabs)
848                ),
849            )),
850        };
851    }
852
853    // Otherwise match the selector against tab origins.
854    let mut hits = tabs
855        .iter()
856        .filter(|(_, c)| c.origin.as_deref() == Some(sel));
857    match (hits.next(), hits.next()) {
858        (Some((id, conn)), None) => Ok((*id, conn.sender.clone())),
859        (None, _) => Err((
860            StatusCode::NOT_FOUND,
861            format!(
862                "no connected tab with origin {sel}; connected: {}",
863                tab_list(tabs)
864            ),
865        )),
866        (Some(_), Some(_)) => Err((
867            StatusCode::CONFLICT,
868            format!(
869                "origin {sel} matches multiple tabs; target by connection id ({})",
870                tab_list(tabs)
871            ),
872        )),
873    }
874}
875
876/// Renders the connected tabs as `id N: origin, …` (id-sorted) for error
877/// messages. Carries no authenticated data beyond the origin already in status.
878fn tab_list(tabs: &HashMap<u64, WsConn>) -> String {
879    let mut items: Vec<(u64, Option<&str>)> = tabs
880        .iter()
881        .map(|(id, c)| (*id, c.origin.as_deref()))
882        .collect();
883    items.sort_by_key(|(id, _)| *id);
884    items
885        .iter()
886        .map(|(id, origin)| match origin {
887            Some(o) => format!("id {id}: {o}"),
888            None => format!("id {id}"),
889        })
890        .collect::<Vec<_>>()
891        .join(", ")
892}
893
894/// Resolves the outbound-origin allowlist for a single request.
895///
896/// A per-request `request --allow-origin` override (`req.allow_origin`) takes
897/// precedence over the `serve --allow-origin` global; otherwise the global is
898/// used. This value reaches **only** [`auth::validate_outbound_url`] — never the
899/// connection-time `ws_origin_allowed` gate — so a request may target a
900/// cross-origin URL without the page's own tab being rejected at upgrade.
901///
902/// A WARN is logged whenever the per-request override is exercised, since it
903/// widens this request's outbound scope beyond what `serve` was started with.
904fn resolve_allow_origin<'a>(req: &'a ControlRequest, state: &'a AppState) -> Option<&'a str> {
905    match req.allow_origin.as_deref() {
906        Some(origin) => {
907            tracing::warn!(
908                "Per-request --allow-origin override in effect; outbound scope for this request \
909                 widened to {origin}"
910            );
911            Some(origin)
912        }
913        None => state.config.allow_origin.as_deref(),
914    }
915}
916
917/// The shared request path: scope-check, register a waiter, send the command,
918/// and await the browser's reply (or time out).
919/// Appends a best-effort `service = browser-bridge` HTTP record for one proxied
920/// request, flagging `via_daemon` when the bridge is hosted by the daemon.
921fn record_bridge_http(
922    method: &str,
923    url: &str,
924    started: Instant,
925    status: Option<u16>,
926    error: Option<&str>,
927) {
928    let via_daemon = matches!(
929        request_log::current_context().source,
930        request_log::Source::Daemon
931    );
932    request_log::record_http_with(
933        "browser-bridge",
934        method,
935        url,
936        started,
937        status,
938        error,
939        request_log::HttpExtra {
940            via_daemon,
941            ..Default::default()
942        },
943    );
944}
945
946async fn dispatch(
947    state: &AppState,
948    req: ControlRequest,
949) -> Result<ResponseEnvelope, (StatusCode, String)> {
950    let started = Instant::now();
951    auth::validate_outbound_url(&req.url, resolve_allow_origin(&req, state)).map_err(|_| {
952        (
953            StatusCode::FORBIDDEN,
954            "outbound URL is cross-origin; pass --allow-origin to permit it".to_string(),
955        )
956    })?;
957
958    for (name, value) in &req.headers {
959        if !auth::header_is_safe(name, value) {
960            return Err((
961                StatusCode::BAD_REQUEST,
962                "invalid header name or value".to_string(),
963            ));
964        }
965    }
966
967    let _permit = state.in_flight.clone().try_acquire_owned().map_err(|_| {
968        (
969            StatusCode::TOO_MANY_REQUESTS,
970            "too many in-flight requests".to_string(),
971        )
972    })?;
973
974    // Clone the method/URL for the request log before they move into `Command`.
975    let log_method = req.method.clone();
976    let log_url = req.url.clone();
977    let (id, rx) = state.correlator.register();
978    let command = Command {
979        id,
980        url: req.url,
981        method: req.method,
982        headers: req.headers,
983        body: req.body,
984        stream: false,
985        credentials: req.credentials,
986    };
987    let frame = match serde_json::to_string(&command) {
988        Ok(f) => f,
989        Err(e) => {
990            state.correlator.remove(id);
991            return Err((
992                StatusCode::INTERNAL_SERVER_ERROR,
993                format!("serialise error: {e}"),
994            ));
995        }
996    };
997
998    {
999        let tabs = state.lock_tabs();
1000        let (_conn_id, sender) = match resolve_target(&tabs, req.target.as_deref()) {
1001            Ok(t) => t,
1002            Err(e) => {
1003                state.correlator.remove(id);
1004                return Err(e);
1005            }
1006        };
1007        if sender.send(Message::Text(frame.into())).is_err() {
1008            state.correlator.remove(id);
1009            return Err((
1010                StatusCode::SERVICE_UNAVAILABLE,
1011                "no browser connected".to_string(),
1012            ));
1013        }
1014    }
1015
1016    match tokio::time::timeout(state.config.request_timeout, rx).await {
1017        Ok(Ok(reply)) => match reply.outcome() {
1018            ReplyOutcome::Success {
1019                status,
1020                headers,
1021                body,
1022                encoding,
1023            } => {
1024                record_bridge_http(&log_method, &log_url, started, Some(status), None);
1025                // Size is accounted against the *decoded* body. For base64 that
1026                // means decoding here to learn the true byte length; the envelope
1027                // still carries the base64 string (the caller / proxy decodes).
1028                let decoded_len = match encoding.as_deref() {
1029                    None => body.len(),
1030                    Some("base64") => match BASE64.decode(body.as_bytes()) {
1031                        Ok(bytes) => bytes.len(),
1032                        Err(_) => {
1033                            return Err((
1034                                StatusCode::BAD_GATEWAY,
1035                                "browser sent an invalid base64 body".to_string(),
1036                            ))
1037                        }
1038                    },
1039                    Some(other) => {
1040                        return Err((
1041                            StatusCode::BAD_GATEWAY,
1042                            format!("browser sent an unsupported body encoding: {other}"),
1043                        ))
1044                    }
1045                };
1046                if decoded_len > state.config.max_body_bytes {
1047                    return Err((
1048                        StatusCode::BAD_GATEWAY,
1049                        format!(
1050                            "browser response body is {decoded_len} bytes, exceeding the \
1051                             --max-body-bytes limit of {} bytes; page the request to fetch \
1052                             less per call (e.g. narrow the time range or lower a `limit`/page \
1053                             size) or raise --max-body-bytes",
1054                            state.config.max_body_bytes
1055                        ),
1056                    ));
1057                }
1058                Ok(ResponseEnvelope {
1059                    id,
1060                    status,
1061                    headers,
1062                    body,
1063                    encoding,
1064                })
1065            }
1066            ReplyOutcome::Error(msg) => {
1067                record_bridge_http(&log_method, &log_url, started, None, Some(&msg));
1068                Err((
1069                    StatusCode::BAD_GATEWAY,
1070                    format!("browser fetch failed: {msg}"),
1071                ))
1072            }
1073        },
1074        Ok(Err(_)) => {
1075            record_bridge_http(
1076                &log_method,
1077                &log_url,
1078                started,
1079                None,
1080                Some("browser connection closed before replying"),
1081            );
1082            Err((
1083                StatusCode::BAD_GATEWAY,
1084                "browser connection closed before replying".to_string(),
1085            ))
1086        }
1087        Err(_) => {
1088            state.correlator.remove(id);
1089            record_bridge_http(
1090                &log_method,
1091                &log_url,
1092                started,
1093                None,
1094                Some("browser did not reply in time"),
1095            );
1096            Err((
1097                StatusCode::GATEWAY_TIMEOUT,
1098                "browser did not reply in time".to_string(),
1099            ))
1100        }
1101    }
1102}
1103
1104/// Sends a best-effort cancellation frame to the tab handling stream `id` and
1105/// drops the pending stream, so a stream whose consumer is gone (or which
1106/// tripped a limit) stops the in-page reader rather than fetching to completion.
1107/// A no-op if that tab has since disconnected.
1108async fn send_cancel(state: &AppState, conn_id: u64, id: u64) {
1109    state.correlator.remove(id);
1110    let Ok(frame) = serde_json::to_string(&CancelCommand::new(id)) else {
1111        return;
1112    };
1113    let tabs = state.lock_tabs();
1114    if let Some(conn) = tabs.get(&conn_id) {
1115        let _ = conn.sender.send(Message::Text(frame.into()));
1116    }
1117}
1118
1119/// The shared streaming request path: scope-check, register a stream waiter, send
1120/// the `stream: true` command, and await the head frame (status + headers) under
1121/// the inter-chunk idle timeout. Returns the head plus a [`StreamDriver`] that
1122/// pulls the remaining body chunks; the concurrency permit is held by the driver
1123/// for the stream's lifetime.
1124async fn start_stream(
1125    state: &AppState,
1126    req: ControlRequest,
1127) -> Result<(u16, BTreeMap<String, String>, StreamDriver), (StatusCode, String)> {
1128    let started = Instant::now();
1129    auth::validate_outbound_url(&req.url, resolve_allow_origin(&req, state)).map_err(|_| {
1130        (
1131            StatusCode::FORBIDDEN,
1132            "outbound URL is cross-origin; pass --allow-origin to permit it".to_string(),
1133        )
1134    })?;
1135
1136    for (name, value) in &req.headers {
1137        if !auth::header_is_safe(name, value) {
1138            return Err((
1139                StatusCode::BAD_REQUEST,
1140                "invalid header name or value".to_string(),
1141            ));
1142        }
1143    }
1144
1145    let permit = state.in_flight.clone().try_acquire_owned().map_err(|_| {
1146        (
1147            StatusCode::TOO_MANY_REQUESTS,
1148            "too many in-flight requests".to_string(),
1149        )
1150    })?;
1151
1152    // Clone the method/URL for the request log before they move into `Command`.
1153    let log_method = req.method.clone();
1154    let log_url = req.url.clone();
1155    let (id, mut rx) = state.correlator.register_stream();
1156    let command = Command {
1157        id,
1158        url: req.url,
1159        method: req.method,
1160        headers: req.headers,
1161        body: req.body,
1162        stream: true,
1163        credentials: req.credentials,
1164    };
1165    let frame = match serde_json::to_string(&command) {
1166        Ok(f) => f,
1167        Err(e) => {
1168            state.correlator.remove(id);
1169            return Err((
1170                StatusCode::INTERNAL_SERVER_ERROR,
1171                format!("serialise error: {e}"),
1172            ));
1173        }
1174    };
1175
1176    let conn_id = {
1177        let tabs = state.lock_tabs();
1178        let (conn_id, sender) = match resolve_target(&tabs, req.target.as_deref()) {
1179            Ok(t) => t,
1180            Err(e) => {
1181                state.correlator.remove(id);
1182                return Err(e);
1183            }
1184        };
1185        if sender.send(Message::Text(frame.into())).is_err() {
1186            state.correlator.remove(id);
1187            return Err((
1188                StatusCode::SERVICE_UNAVAILABLE,
1189                "no browser connected".to_string(),
1190            ));
1191        }
1192        conn_id
1193    };
1194
1195    let idle = state.config.request_timeout;
1196    let (status, headers) = match tokio::time::timeout(idle, rx.recv()).await {
1197        Ok(Some(StreamItem::Head { status, headers })) => {
1198            record_bridge_http(&log_method, &log_url, started, Some(status), None);
1199            (status, headers)
1200        }
1201        Ok(Some(StreamItem::Error(msg))) => {
1202            state.correlator.remove(id);
1203            record_bridge_http(&log_method, &log_url, started, None, Some(&msg));
1204            return Err((
1205                StatusCode::BAD_GATEWAY,
1206                format!("browser fetch failed: {msg}"),
1207            ));
1208        }
1209        Ok(Some(_)) => {
1210            state.correlator.remove(id);
1211            record_bridge_http(
1212                &log_method,
1213                &log_url,
1214                started,
1215                None,
1216                Some("browser streamed a body chunk before the response head"),
1217            );
1218            return Err((
1219                StatusCode::BAD_GATEWAY,
1220                "browser streamed a body chunk before the response head".to_string(),
1221            ));
1222        }
1223        Ok(None) => {
1224            record_bridge_http(
1225                &log_method,
1226                &log_url,
1227                started,
1228                None,
1229                Some("browser connection closed before replying"),
1230            );
1231            return Err((
1232                StatusCode::BAD_GATEWAY,
1233                "browser connection closed before replying".to_string(),
1234            ));
1235        }
1236        Err(_) => {
1237            send_cancel(state, conn_id, id).await;
1238            record_bridge_http(
1239                &log_method,
1240                &log_url,
1241                started,
1242                None,
1243                Some("browser did not start streaming in time"),
1244            );
1245            return Err((
1246                StatusCode::GATEWAY_TIMEOUT,
1247                "browser did not start streaming in time".to_string(),
1248            ));
1249        }
1250    };
1251
1252    let driver = StreamDriver {
1253        state: state.clone(),
1254        id,
1255        conn_id,
1256        rx,
1257        idle,
1258        max_body: state.config.max_body_bytes,
1259        sent: 0,
1260        _permit: permit,
1261        done: false,
1262    };
1263    Ok((status, headers, driver))
1264}
1265
1266/// Drives a registered stream's body chunks: applies the inter-chunk idle
1267/// timeout, decodes each base64 chunk, enforces the cumulative `--max-body-bytes`
1268/// ceiling, and cancels the browser stream on early/abnormal termination. Holds
1269/// the concurrency permit until dropped.
1270struct StreamDriver {
1271    state: AppState,
1272    id: u64,
1273    /// Connection id of the tab serving this stream; cancels route back to it.
1274    conn_id: u64,
1275    rx: mpsc::UnboundedReceiver<StreamItem>,
1276    idle: Duration,
1277    max_body: usize,
1278    sent: usize,
1279    _permit: OwnedSemaphorePermit,
1280    done: bool,
1281}
1282
1283/// One step of a [`StreamDriver`]: decoded chunk bytes, or end-of-stream.
1284enum NextChunk {
1285    /// A decoded body chunk and its sequence number.
1286    Data {
1287        /// Chunk sequence number reported by the browser.
1288        seq: u64,
1289        /// Decoded chunk bytes.
1290        bytes: Vec<u8>,
1291    },
1292    /// The stream is finished (normal end, error, idle timeout, or cap hit).
1293    End,
1294}
1295
1296impl StreamDriver {
1297    /// Pulls the next decoded chunk, ending the stream on a terminal item, an
1298    /// invalid chunk, an idle timeout, or the cumulative byte cap.
1299    async fn next_chunk(&mut self) -> NextChunk {
1300        if self.done {
1301            return NextChunk::End;
1302        }
1303        loop {
1304            match tokio::time::timeout(self.idle, self.rx.recv()).await {
1305                Ok(Some(StreamItem::Chunk { seq, data })) => {
1306                    let Ok(bytes) = BASE64.decode(data.as_bytes()) else {
1307                        return self.abort().await;
1308                    };
1309                    self.sent = self.sent.saturating_add(bytes.len());
1310                    if self.sent > self.max_body {
1311                        return self.abort().await;
1312                    }
1313                    return NextChunk::Data { seq, bytes };
1314                }
1315                // A stray head after the first is a protocol slip; ignore it.
1316                Ok(Some(StreamItem::Head { .. })) => {}
1317                Ok(Some(StreamItem::End | StreamItem::Error(_)) | None) => {
1318                    return self.finish();
1319                }
1320                // Inter-chunk idle timeout: stop the browser and end the stream.
1321                Err(_) => return self.abort().await,
1322            }
1323        }
1324    }
1325
1326    /// Ends the stream and removes the pending entry (terminal item / consumer
1327    /// gone — the browser is already done, so no cancel is sent).
1328    fn finish(&mut self) -> NextChunk {
1329        self.done = true;
1330        self.state.correlator.remove(self.id);
1331        NextChunk::End
1332    }
1333
1334    /// Ends the stream early and tells the browser to cancel its reader (idle
1335    /// timeout, cap exceeded, or an undecodable chunk).
1336    async fn abort(&mut self) -> NextChunk {
1337        self.done = true;
1338        send_cancel(&self.state, self.conn_id, self.id).await;
1339        NextChunk::End
1340    }
1341}
1342
1343/// Serialises a [`StreamLine`] as one NDJSON line (trailing newline).
1344fn to_ndjson_line(line: &StreamLine) -> String {
1345    let mut s = serde_json::to_string(line).unwrap_or_else(|_| "{}".to_string());
1346    s.push('\n');
1347    s
1348}
1349
1350/// Builds the transparent-proxy response for a streamed body: status and
1351/// `content-type` from the head frame, decoded chunk bytes streamed as a chunked
1352/// HTTP body.
1353fn raw_stream_response(
1354    status: u16,
1355    headers: BTreeMap<String, String>,
1356    driver: StreamDriver,
1357) -> Response {
1358    let code = StatusCode::from_u16(status).unwrap_or(StatusCode::BAD_GATEWAY);
1359    let mut builder = Response::builder().status(code);
1360    if let Some(ct) = headers.get("content-type") {
1361        builder = builder.header(header::CONTENT_TYPE, ct);
1362    }
1363    let stream = futures::stream::unfold(driver, |mut driver| async move {
1364        match driver.next_chunk().await {
1365            NextChunk::Data { bytes, .. } => Some((
1366                Ok::<_, std::convert::Infallible>(Bytes::from(bytes)),
1367                driver,
1368            )),
1369            NextChunk::End => None,
1370        }
1371    });
1372    builder
1373        .body(Body::from_stream(stream))
1374        .unwrap_or_else(|_| StatusCode::BAD_GATEWAY.into_response())
1375}
1376
1377/// Builds the `POST /__bridge/request` response for a streamed body: an NDJSON
1378/// body of a head line, `{seq,chunk}` lines, and a terminating `{done}` line.
1379fn ndjson_stream_response(
1380    status: u16,
1381    headers: BTreeMap<String, String>,
1382    driver: StreamDriver,
1383) -> Response {
1384    let head_line = to_ndjson_line(&StreamLine::Head { status, headers });
1385    // State: (pending head line, driver, done-line-emitted).
1386    let init = (Some(head_line), driver, false);
1387    let stream = futures::stream::unfold(init, |(head, mut driver, done_emitted)| async move {
1388        if let Some(line) = head {
1389            return Some((
1390                Ok::<_, std::convert::Infallible>(Bytes::from(line)),
1391                (None, driver, done_emitted),
1392            ));
1393        }
1394        if done_emitted {
1395            return None;
1396        }
1397        match driver.next_chunk().await {
1398            NextChunk::Data { seq, bytes } => {
1399                let line = to_ndjson_line(&StreamLine::Chunk {
1400                    seq,
1401                    chunk: BASE64.encode(&bytes),
1402                });
1403                Some((Ok(Bytes::from(line)), (None, driver, false)))
1404            }
1405            NextChunk::End => {
1406                let line = to_ndjson_line(&StreamLine::Done { done: true });
1407                Some((Ok(Bytes::from(line)), (None, driver, true)))
1408            }
1409        }
1410    });
1411    Response::builder()
1412        .status(StatusCode::OK)
1413        .header(header::CONTENT_TYPE, "application/x-ndjson")
1414        .body(Body::from_stream(stream))
1415        .unwrap_or_else(|_| StatusCode::BAD_GATEWAY.into_response())
1416}
1417
1418/// Renders a browser response envelope as the transparent-proxy HTTP response.
1419///
1420/// A base64-tagged body is decoded back to raw bytes so a `curl` client gets the
1421/// original bytes (image, gzip blob, …); the base64 is validated in `dispatch`,
1422/// but a decode failure here still fails closed with `502`.
1423fn envelope_to_response(env: ResponseEnvelope) -> Response {
1424    let status = StatusCode::from_u16(env.status).unwrap_or(StatusCode::BAD_GATEWAY);
1425    let mut builder = Response::builder().status(status);
1426    if let Some(ct) = env.headers.get("content-type") {
1427        builder = builder.header(header::CONTENT_TYPE, ct);
1428    }
1429    let body = match env.encoding.as_deref() {
1430        Some("base64") => match BASE64.decode(env.body.as_bytes()) {
1431            Ok(bytes) => Body::from(bytes),
1432            Err(_) => return StatusCode::BAD_GATEWAY.into_response(),
1433        },
1434        _ => Body::from(env.body),
1435    };
1436    builder
1437        .body(body)
1438        .unwrap_or_else(|_| StatusCode::BAD_GATEWAY.into_response())
1439}
1440
1441#[cfg(test)]
1442#[allow(clippy::unwrap_used, clippy::expect_used)]
1443mod tests {
1444    use super::*;
1445
1446    fn buffered_frame(id: u64) -> BrowserFrame {
1447        BrowserFrame {
1448            id,
1449            status: Some(200),
1450            headers: None,
1451            body: Some("ok".into()),
1452            encoding: None,
1453            error: None,
1454            stream: None,
1455            chunk: None,
1456            seq: None,
1457            done: None,
1458        }
1459    }
1460
1461    /// Builds a `tabs` map entry with a detached sender (the receiver is dropped;
1462    /// routing tests only assert *which* connection is chosen, not delivery).
1463    fn tab(origin: Option<&str>) -> WsConn {
1464        let (sender, _rx) = mpsc::unbounded_channel();
1465        WsConn {
1466            sender,
1467            origin: origin.map(str::to_string),
1468        }
1469    }
1470
1471    #[test]
1472    fn resolve_target_no_tabs_is_503() {
1473        let tabs = HashMap::new();
1474        let err = resolve_target(&tabs, None).unwrap_err();
1475        assert_eq!(err.0, StatusCode::SERVICE_UNAVAILABLE);
1476    }
1477
1478    #[test]
1479    fn resolve_target_single_tab_routes_without_target() {
1480        let mut tabs = HashMap::new();
1481        tabs.insert(1, tab(Some("https://a.test")));
1482        let (id, _s) = resolve_target(&tabs, None).unwrap();
1483        assert_eq!(id, 1);
1484    }
1485
1486    #[test]
1487    fn resolve_target_multiple_tabs_no_target_is_409() {
1488        let mut tabs = HashMap::new();
1489        tabs.insert(1, tab(Some("https://a.test")));
1490        tabs.insert(2, tab(Some("https://b.test")));
1491        let err = resolve_target(&tabs, None).unwrap_err();
1492        assert_eq!(err.0, StatusCode::CONFLICT);
1493        // The message lists both connected tabs to disambiguate.
1494        assert!(err.1.contains("id 1") && err.1.contains("id 2"));
1495    }
1496
1497    #[test]
1498    fn resolve_target_by_connection_id() {
1499        let mut tabs = HashMap::new();
1500        tabs.insert(1, tab(Some("https://a.test")));
1501        tabs.insert(2, tab(Some("https://b.test")));
1502        let (id, _s) = resolve_target(&tabs, Some("2")).unwrap();
1503        assert_eq!(id, 2);
1504        // Unknown id is a 404.
1505        assert_eq!(
1506            resolve_target(&tabs, Some("9")).unwrap_err().0,
1507            StatusCode::NOT_FOUND
1508        );
1509    }
1510
1511    #[test]
1512    fn resolve_target_by_unique_origin() {
1513        let mut tabs = HashMap::new();
1514        tabs.insert(1, tab(Some("https://a.test")));
1515        tabs.insert(2, tab(Some("https://b.test")));
1516        let (id, _s) = resolve_target(&tabs, Some("https://b.test")).unwrap();
1517        assert_eq!(id, 2);
1518        // Unknown origin is a 404.
1519        assert_eq!(
1520            resolve_target(&tabs, Some("https://nope.test"))
1521                .unwrap_err()
1522                .0,
1523            StatusCode::NOT_FOUND
1524        );
1525    }
1526
1527    #[test]
1528    fn resolve_target_ambiguous_origin_is_409() {
1529        let mut tabs = HashMap::new();
1530        tabs.insert(1, tab(Some("https://a.test")));
1531        tabs.insert(2, tab(Some("https://a.test")));
1532        let err = resolve_target(&tabs, Some("https://a.test")).unwrap_err();
1533        assert_eq!(err.0, StatusCode::CONFLICT);
1534        // Two tabs share the origin → caller is told to target by id.
1535        assert!(err.1.contains("connection id"));
1536    }
1537
1538    #[test]
1539    fn target_header_trims_and_drops_empty() {
1540        let mut h = axum::http::HeaderMap::new();
1541        assert_eq!(target_header(&h), None);
1542        h.insert(auth::BRIDGE_TARGET_HEADER, "  2  ".parse().unwrap());
1543        assert_eq!(target_header(&h).as_deref(), Some("2"));
1544        h.insert(auth::BRIDGE_TARGET_HEADER, "   ".parse().unwrap());
1545        assert_eq!(target_header(&h), None);
1546    }
1547
1548    #[test]
1549    fn tab_list_renders_id_with_and_without_origin() {
1550        let mut tabs = HashMap::new();
1551        tabs.insert(1, tab(Some("https://a.test")));
1552        tabs.insert(2, tab(None));
1553        // Id-sorted; a tab that sent no `Origin` renders as the bare id.
1554        assert_eq!(tab_list(&tabs), "id 1: https://a.test, id 2");
1555    }
1556
1557    /// A minimal [`AppState`] for exercising `dispatch` / `start_stream` without
1558    /// a real WebSocket peer.
1559    fn test_state() -> AppState {
1560        AppState {
1561            token: Arc::new("t".to_string()),
1562            config: Arc::new(BridgeConfig {
1563                ws_port: 0,
1564                control_port: 0,
1565                request_timeout: Duration::from_secs(5),
1566                allow_origin: None,
1567                max_body_bytes: 1024,
1568                max_concurrent: 8,
1569            }),
1570            correlator: Correlator::new(),
1571            tabs: Arc::new(StdMutex::new(HashMap::new())),
1572            in_flight: Arc::new(Semaphore::new(8)),
1573            conn_counter: Arc::new(AtomicU64::new(1)),
1574        }
1575    }
1576
1577    /// Inserts a tab whose writer receiver is already dropped, so any send to it
1578    /// fails — modelling a tab that vanished between routing and dispatch.
1579    async fn insert_dead_tab(state: &AppState, id: u64) {
1580        let (sender, rx) = mpsc::unbounded_channel();
1581        drop(rx);
1582        state.lock_tabs().insert(
1583            id,
1584            WsConn {
1585                sender,
1586                origin: None,
1587            },
1588        );
1589    }
1590
1591    fn plain_request() -> ControlRequest {
1592        ControlRequest {
1593            url: "/x".to_string(),
1594            method: "GET".to_string(),
1595            headers: BTreeMap::new(),
1596            body: None,
1597            stream: false,
1598            target: None,
1599            allow_origin: None,
1600            credentials: None,
1601        }
1602    }
1603
1604    /// Builds a state whose `serve` global allow-origin is set, to prove a
1605    /// per-request override wins over (and a missing one falls back to) it.
1606    fn state_with_global_origin(global: Option<&str>) -> AppState {
1607        let mut state = test_state();
1608        let mut config = (*state.config).clone();
1609        config.allow_origin = global.map(str::to_string);
1610        state.config = Arc::new(config);
1611        state
1612    }
1613
1614    #[test]
1615    fn resolve_allow_origin_prefers_per_request_override() {
1616        let state = state_with_global_origin(Some("https://global.test"));
1617        let req = ControlRequest {
1618            allow_origin: Some("https://per-request.test".to_string()),
1619            ..plain_request()
1620        };
1621        // The per-request value wins over the serve global.
1622        assert_eq!(
1623            resolve_allow_origin(&req, &state),
1624            Some("https://per-request.test")
1625        );
1626        // A request carrying the override permits its matched cross-origin
1627        // target, and still rejects an unmatched one.
1628        assert_eq!(
1629            auth::validate_outbound_url(
1630                "https://per-request.test/x",
1631                resolve_allow_origin(&req, &state)
1632            ),
1633            Ok(())
1634        );
1635        assert!(auth::validate_outbound_url(
1636            "https://other.test/x",
1637            resolve_allow_origin(&req, &state)
1638        )
1639        .is_err());
1640    }
1641
1642    #[test]
1643    fn resolve_allow_origin_falls_back_to_global() {
1644        let state = state_with_global_origin(Some("https://global.test"));
1645        let req = plain_request();
1646        assert!(req.allow_origin.is_none());
1647        // With no per-request override, the serve global governs the scope.
1648        assert_eq!(
1649            resolve_allow_origin(&req, &state),
1650            Some("https://global.test")
1651        );
1652    }
1653
1654    #[test]
1655    fn per_request_override_does_not_affect_ws_origin_gate() {
1656        // The per-request override feeds only the outbound-URL check. The
1657        // connection-time WS gate reads the `serve` global directly, so a tab on
1658        // origin A stays connectable even when a request override permits B.
1659        let state = state_with_global_origin(None);
1660        let req = ControlRequest {
1661            allow_origin: Some("https://b.test".to_string()),
1662            ..plain_request()
1663        };
1664        // Outbound to B is permitted by the override...
1665        assert_eq!(
1666            auth::validate_outbound_url("https://b.test/x", resolve_allow_origin(&req, &state)),
1667            Ok(())
1668        );
1669        // ...while the WS upgrade gate (serve global = None) still admits any
1670        // origin, unchanged by the per-request value.
1671        assert!(auth::ws_origin_allowed(
1672            Some("https://a.test"),
1673            state.config.allow_origin.as_deref()
1674        ));
1675    }
1676
1677    #[tokio::test]
1678    async fn dispatch_returns_503_when_send_fails() {
1679        let state = test_state();
1680        insert_dead_tab(&state, 1).await;
1681        let err = dispatch(&state, plain_request()).await.unwrap_err();
1682        assert_eq!(err.0, StatusCode::SERVICE_UNAVAILABLE);
1683        // The failed dispatch leaves no dangling waiter behind.
1684        assert_eq!(state.correlator.pending_count(), 0);
1685    }
1686
1687    #[tokio::test]
1688    async fn start_stream_returns_503_when_send_fails() {
1689        let state = test_state();
1690        insert_dead_tab(&state, 1).await;
1691        let req = ControlRequest {
1692            stream: true,
1693            ..plain_request()
1694        };
1695        let err = start_stream(&state, req).await.err().map(|e| e.0);
1696        assert_eq!(err, Some(StatusCode::SERVICE_UNAVAILABLE));
1697        assert_eq!(state.correlator.pending_count(), 0);
1698    }
1699
1700    #[test]
1701    fn correlator_register_resolve_round_trip() {
1702        let c = Correlator::new();
1703        let (id, rx) = c.register();
1704        assert_eq!(c.pending_count(), 1);
1705        assert_eq!(c.deliver(buffered_frame(id)), None);
1706        assert_eq!(c.pending_count(), 0);
1707        let reply = rx.now_or_never().unwrap().unwrap();
1708        assert_eq!(reply.id, id);
1709    }
1710
1711    #[test]
1712    fn correlator_stream_forwards_items_until_terminal() {
1713        let c = Correlator::new();
1714        let (id, mut rx) = c.register_stream();
1715        assert_eq!(c.pending_count(), 1);
1716
1717        let mut head = buffered_frame(id);
1718        head.stream = Some(true);
1719        head.body = None;
1720        assert_eq!(c.deliver(head), None);
1721        assert!(matches!(
1722            rx.try_recv(),
1723            Ok(StreamItem::Head { status: 200, .. })
1724        ));
1725        // Head is non-terminal: the waiter stays registered.
1726        assert_eq!(c.pending_count(), 1);
1727
1728        let mut done = BrowserFrame {
1729            done: Some(true),
1730            ..buffered_frame(id)
1731        };
1732        done.body = None;
1733        assert_eq!(c.deliver(done), None);
1734        assert!(matches!(rx.try_recv(), Ok(StreamItem::End)));
1735        assert_eq!(c.pending_count(), 0);
1736    }
1737
1738    #[test]
1739    fn correlator_deliver_unknown_id_is_noop() {
1740        let c = Correlator::new();
1741        // A frame whose id was never registered (or already terminal) is dropped
1742        // without panicking and never asks the caller to cancel.
1743        assert_eq!(c.deliver(buffered_frame(999)), None);
1744        assert_eq!(c.pending_count(), 0);
1745    }
1746
1747    #[test]
1748    fn correlator_stream_signals_cancel_when_consumer_gone() {
1749        let c = Correlator::new();
1750        let (id, rx) = c.register_stream();
1751        drop(rx); // consumer disconnected
1752        let mut chunk = buffered_frame(id);
1753        chunk.chunk = Some("aGk=".into());
1754        chunk.body = None;
1755        // Delivery fails (receiver dropped) → caller is told to cancel `id`.
1756        assert_eq!(c.deliver(chunk), Some(id));
1757        assert_eq!(c.pending_count(), 0);
1758    }
1759
1760    #[test]
1761    fn correlator_ids_are_monotonic() {
1762        let c = Correlator::new();
1763        let (a, _ra) = c.register();
1764        let (b, _rb) = c.register();
1765        assert!(b > a);
1766    }
1767
1768    #[test]
1769    fn correlator_remove_drops_waiter() {
1770        let c = Correlator::new();
1771        let (id, _rx) = c.register();
1772        c.remove(id);
1773        assert_eq!(c.pending_count(), 0);
1774    }
1775
1776    #[test]
1777    fn extract_stream_flag_detects_and_strips_marker() {
1778        assert_eq!(extract_stream_flag(None), (false, None));
1779        assert_eq!(
1780            extract_stream_flag(Some("a=1&b=2")),
1781            (false, Some("a=1&b=2".to_string()))
1782        );
1783        assert_eq!(
1784            extract_stream_flag(Some("a=1&__stream=1&b=2")),
1785            (true, Some("a=1&b=2".to_string()))
1786        );
1787        // Bare marker, nothing else left.
1788        assert_eq!(extract_stream_flag(Some("__stream")), (true, None));
1789        // Explicit disable.
1790        assert_eq!(extract_stream_flag(Some("__stream=0")), (false, None));
1791    }
1792
1793    #[test]
1794    fn forwardable_headers_drops_control_headers() {
1795        let mut h = axum::http::HeaderMap::new();
1796        h.insert("host", "localhost:9998".parse().unwrap());
1797        h.insert("authorization", "Bearer x".parse().unwrap());
1798        h.insert("x-omni-bridge", "1".parse().unwrap());
1799        h.insert("accept", "application/json".parse().unwrap());
1800        let out = forwardable_headers(&h);
1801        assert!(!out.contains_key("host"));
1802        assert!(!out.contains_key("authorization"));
1803        assert!(!out.contains_key("x-omni-bridge"));
1804        assert_eq!(
1805            out.get("accept").map(String::as_str),
1806            Some("application/json")
1807        );
1808    }
1809
1810    #[test]
1811    fn envelope_to_response_passes_text_body_through() {
1812        let env = ResponseEnvelope {
1813            id: 1,
1814            status: 200,
1815            headers: BTreeMap::new(),
1816            body: "hello".into(),
1817            encoding: None,
1818        };
1819        assert_eq!(envelope_to_response(env).status(), StatusCode::OK);
1820    }
1821
1822    #[test]
1823    fn envelope_to_response_rejects_invalid_base64() {
1824        // `dispatch` validates base64 before this runs, so this path is only
1825        // reachable defensively — assert it still fails closed with 502.
1826        let env = ResponseEnvelope {
1827            id: 1,
1828            status: 200,
1829            headers: BTreeMap::new(),
1830            body: "not valid base64 @@@".into(),
1831            encoding: Some("base64".into()),
1832        };
1833        assert_eq!(envelope_to_response(env).status(), StatusCode::BAD_GATEWAY);
1834    }
1835
1836    use futures::FutureExt;
1837
1838    /// A `BridgeConfig` bound to random free ports, for lifecycle tests.
1839    fn ephemeral_config() -> BridgeConfig {
1840        BridgeConfig {
1841            ws_port: 0,
1842            control_port: 0,
1843            request_timeout: Duration::from_secs(5),
1844            allow_origin: None,
1845            max_body_bytes: 1024,
1846            max_concurrent: 8,
1847        }
1848    }
1849
1850    #[tokio::test]
1851    async fn bridge_server_start_status_shutdown() {
1852        let server = BridgeServer::start(ephemeral_config(), "tok".to_string())
1853            .await
1854            .unwrap();
1855        // Ports were resolved from the OS (port 0 → a real bound port).
1856        assert_ne!(server.control_port(), 0);
1857        assert_ne!(server.ws_port(), 0);
1858        // No browser is connected yet.
1859        let status = server.status();
1860        assert!(!status.connected);
1861        assert!(status.tabs.is_empty());
1862        assert_eq!(status.pending, 0);
1863        // Graceful shutdown drains and returns.
1864        server.shutdown().await;
1865    }
1866
1867    #[tokio::test]
1868    async fn disconnect_unknown_tab_is_error() {
1869        let server = BridgeServer::start(ephemeral_config(), "tok".to_string())
1870            .await
1871            .unwrap();
1872        let err = server.disconnect_tab(999).unwrap_err();
1873        assert!(err.to_string().contains("no connected tab"));
1874        server.shutdown().await;
1875    }
1876
1877    #[tokio::test]
1878    async fn start_fails_closed_on_taken_control_port() {
1879        // Occupy a port, then ask the bridge to bind the same one.
1880        let squatter = std::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).unwrap();
1881        let taken = squatter.local_addr().unwrap().port();
1882        let config = BridgeConfig {
1883            control_port: taken,
1884            ..ephemeral_config()
1885        };
1886        assert!(BridgeServer::start(config, "tok".to_string())
1887            .await
1888            .is_err());
1889    }
1890
1891    #[tokio::test]
1892    async fn rebind_same_fixed_port_after_close_succeeds() {
1893        // Regression for #990: a daemon/tray restart tears the planes down and
1894        // rebinds the *same* fixed ports. A just-closed connection can leave the
1895        // listener address in TIME_WAIT; without SO_REUSEADDR the rebind fails
1896        // with EADDRINUSE and the bridge is wedged not-running. With it, the
1897        // rebind succeeds.
1898        let server = BridgeServer::start(ephemeral_config(), "tok".to_string())
1899            .await
1900            .unwrap();
1901        let control_port = server.control_port();
1902        let ws_port = server.ws_port();
1903
1904        // Hold loopback connections to both planes across the teardown so the
1905        // server is the active closer and its listener ports pass through
1906        // TIME_WAIT.
1907        let control_conn = TcpStream::connect((Ipv4Addr::LOCALHOST, control_port))
1908            .await
1909            .unwrap();
1910        let ws_conn = TcpStream::connect((Ipv4Addr::LOCALHOST, ws_port))
1911            .await
1912            .unwrap();
1913
1914        server.shutdown().await;
1915        drop(control_conn);
1916        drop(ws_conn);
1917        // Let the four-way close settle so the ports are genuinely in TIME_WAIT.
1918        tokio::time::sleep(Duration::from_millis(50)).await;
1919
1920        let config = BridgeConfig {
1921            ws_port,
1922            control_port,
1923            ..ephemeral_config()
1924        };
1925        let server2 = BridgeServer::start(config, "tok".to_string())
1926            .await
1927            .expect("rebinding just-released fixed ports must succeed with SO_REUSEADDR");
1928        server2.shutdown().await;
1929    }
1930}