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