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    // Correlate the HTTP records this request spawns to the originating client's
672    // invocation when it threaded its id on the origin header (#1198).
673    let origin = origin_header(&headers);
674    if req.stream {
675        let stream = start_stream(&state, req);
676        let result = match origin {
677            Some(id) => request_log::scope_origin_id(id, stream).await,
678            None => stream.await,
679        };
680        return match result {
681            Ok((status, headers, driver)) => ndjson_stream_response(status, headers, driver),
682            Err((code, msg)) => (code, msg).into_response(),
683        };
684    }
685    let dispatch = dispatch(&state, req);
686    let result = match origin {
687        Some(id) => request_log::scope_origin_id(id, dispatch).await,
688        None => dispatch.await,
689    };
690    match result {
691        Ok(env) => Json(env).into_response(),
692        Err((code, msg)) => (code, msg).into_response(),
693    }
694}
695
696/// Transparent proxy for any path not under `/__bridge/`.
697async fn proxy_handler(State(state): State<AppState>, request: Request) -> Response {
698    let (parts, body) = request.into_parts();
699
700    let path = parts.uri.path();
701    if auth::normalize_request_path(path).is_none() {
702        return (StatusCode::BAD_REQUEST, "unsafe request path").into_response();
703    }
704    // `?__stream=1` opts the proxied request into a streamed (chunked) response;
705    // the marker is stripped so it never reaches the upstream URL.
706    let (stream, forwarded_query) = extract_stream_flag(parts.uri.query());
707    let url = match forwarded_query.as_deref() {
708        Some(q) => format!("{path}?{q}"),
709        None => path.to_string(),
710    };
711
712    let headers = forwardable_headers(&parts.headers);
713
714    let Ok(body_bytes) = axum::body::to_bytes(body, state.config.max_body_bytes).await else {
715        return (StatusCode::PAYLOAD_TOO_LARGE, "request body too large").into_response();
716    };
717    let body = if body_bytes.is_empty() {
718        None
719    } else {
720        Some(String::from_utf8_lossy(&body_bytes).into_owned())
721    };
722
723    let req = ControlRequest {
724        url,
725        method: parts.method.to_string(),
726        headers,
727        body,
728        stream,
729        target: target_header(&parts.headers),
730        // The transparent proxy has no per-request override channel; cross-origin
731        // proxying is governed by the per-origin allowlist entry for the tab the
732        // request routes to (keyed by that tab's connecting `Origin`).
733        allow_origin: None,
734        // The transparent proxy has no per-request credentials control; it keeps
735        // the snippet default (`include`), matching pre-credentials behavior.
736        credentials: None,
737        // The proxy already lossily decodes the inbound body as UTF-8 text above,
738        // so it never tags a base64 request encoding.
739        encoding: None,
740    };
741
742    if stream {
743        return match start_stream(&state, req).await {
744            Ok((status, headers, driver)) => raw_stream_response(status, headers, driver),
745            Err((code, msg)) => (code, msg).into_response(),
746        };
747    }
748
749    match dispatch(&state, req).await {
750        Ok(env) => envelope_to_response(env),
751        Err((code, msg)) => (code, msg).into_response(),
752    }
753}
754
755/// Splits a `__stream` marker out of a query string.
756///
757/// Returns whether streaming was requested and the query with the marker
758/// removed (`None` when nothing remains). `__stream=0` / `__stream=false`
759/// explicitly disable it; any other presence enables it.
760fn extract_stream_flag(query: Option<&str>) -> (bool, Option<String>) {
761    let Some(query) = query else {
762        return (false, None);
763    };
764    let mut stream = false;
765    let kept: Vec<&str> = query
766        .split('&')
767        .filter(|kv| {
768            let (key, value) = match kv.split_once('=') {
769                Some((k, v)) => (k, Some(v)),
770                None => (*kv, None),
771            };
772            if key == "__stream" {
773                stream = !matches!(value, Some("0" | "false"));
774                false
775            } else {
776                true
777            }
778        })
779        .collect();
780    let rebuilt = (!kept.is_empty()).then(|| kept.join("&"));
781    (stream, rebuilt)
782}
783
784/// Copies request headers safe to forward to the browser, dropping the
785/// bridge-control and hop-by-hop headers a CLI client adds.
786fn forwardable_headers(headers: &axum::http::HeaderMap) -> BTreeMap<String, String> {
787    const DROP: &[&str] = &[
788        "host",
789        "authorization",
790        auth::BRIDGE_HEADER,
791        auth::BRIDGE_TARGET_HEADER,
792        "content-length",
793        "connection",
794        "accept-encoding",
795        "origin",
796        "sec-fetch-site",
797        "sec-fetch-mode",
798        "sec-fetch-dest",
799    ];
800    headers
801        .iter()
802        .filter_map(|(k, v)| {
803            let name = k.as_str();
804            if DROP.contains(&name) {
805                return None;
806            }
807            v.to_str()
808                .ok()
809                .map(|val| (name.to_string(), val.to_string()))
810        })
811        .collect()
812}
813
814/// Extracts the `X-Omni-Bridge-Target` selector from request headers, if present
815/// and non-empty.
816fn target_header(headers: &axum::http::HeaderMap) -> Option<String> {
817    headers
818        .get(auth::BRIDGE_TARGET_HEADER)
819        .and_then(|v| v.to_str().ok())
820        .map(str::trim)
821        .filter(|s| !s.is_empty())
822        .map(str::to_string)
823}
824
825/// Extracts the `X-Omni-Bridge-Origin` correlation id from request headers, if
826/// present and non-empty — the originating client's request-log `invocation_id`
827/// (#1198).
828fn origin_header(headers: &axum::http::HeaderMap) -> Option<String> {
829    headers
830        .get(auth::BRIDGE_ORIGIN_HEADER)
831        .and_then(|v| v.to_str().ok())
832        .map(str::trim)
833        .filter(|s| !s.is_empty())
834        .map(str::to_string)
835}
836
837/// Resolves which connected tab a request targets, returning its connection id
838/// and a clone of its outbound sender.
839///
840/// An explicit `target` is a connection id (canonical) or an `Origin` that
841/// uniquely matches one tab. With no target, routing succeeds only when exactly
842/// one tab is connected — otherwise the request is ambiguous and rejected.
843fn resolve_target(
844    tabs: &HashMap<u64, WsConn>,
845    target: Option<&str>,
846) -> Result<(u64, mpsc::UnboundedSender<Message>), (StatusCode, String)> {
847    if tabs.is_empty() {
848        return Err((
849            StatusCode::SERVICE_UNAVAILABLE,
850            "no browser connected".to_string(),
851        ));
852    }
853    let Some(sel) = target else {
854        // No target: route only when exactly one tab is connected.
855        let mut it = tabs.iter();
856        return match (it.next(), it.next()) {
857            (Some((id, conn)), None) => Ok((*id, conn.sender.clone())),
858            _ => Err((
859                StatusCode::CONFLICT,
860                format!(
861                    "multiple tabs connected; select one with the X-Omni-Bridge-Target \
862                     header or a `target` field ({})",
863                    tab_list(tabs)
864                ),
865            )),
866        };
867    };
868
869    // A bare integer selects by connection id (canonical, unambiguous).
870    if let Ok(id) = sel.parse::<u64>() {
871        return match tabs.get(&id) {
872            Some(conn) => Ok((id, conn.sender.clone())),
873            None => Err((
874                StatusCode::NOT_FOUND,
875                format!(
876                    "no connected tab with id {id}; connected: {}",
877                    tab_list(tabs)
878                ),
879            )),
880        };
881    }
882
883    // Otherwise match the selector against tab origins.
884    let mut hits = tabs
885        .iter()
886        .filter(|(_, c)| c.origin.as_deref() == Some(sel));
887    match (hits.next(), hits.next()) {
888        (Some((id, conn)), None) => Ok((*id, conn.sender.clone())),
889        (None, _) => Err((
890            StatusCode::NOT_FOUND,
891            format!(
892                "no connected tab with origin {sel}; connected: {}",
893                tab_list(tabs)
894            ),
895        )),
896        (Some(_), Some(_)) => Err((
897            StatusCode::CONFLICT,
898            format!(
899                "origin {sel} matches multiple tabs; target by connection id ({})",
900                tab_list(tabs)
901            ),
902        )),
903    }
904}
905
906/// Renders the connected tabs as `id N: origin, …` (id-sorted) for error
907/// messages. Carries no authenticated data beyond the origin already in status.
908fn tab_list(tabs: &HashMap<u64, WsConn>) -> String {
909    let mut items: Vec<(u64, Option<&str>)> = tabs
910        .iter()
911        .map(|(id, c)| (*id, c.origin.as_deref()))
912        .collect();
913    items.sort_by_key(|(id, _)| *id);
914    items
915        .iter()
916        .map(|(id, origin)| match origin {
917            Some(o) => format!("id {id}: {o}"),
918            None => format!("id {id}"),
919        })
920        .collect::<Vec<_>>()
921        .join(", ")
922}
923
924/// Resolves the outbound origins permitted for a single request, given the
925/// connecting origin of the tab it is routed to (`target_origin`).
926///
927/// Precedence: a per-request `request --allow-origin` override
928/// (`req.allow_origin`) wins, widening this one request's scope; otherwise the
929/// per-origin allowlist grants the outbound set bound to the target tab's
930/// connecting origin. The result reaches **only** [`auth::validate_outbound_url`]
931/// — never the connection-time WS-upgrade gate ([`auth::OriginAllowlist::permits_connection`])
932/// — so a request may target a cross-origin URL without the page's own tab being
933/// rejected at upgrade.
934///
935/// A WARN is logged whenever the per-request override is exercised, since it
936/// widens this request's outbound scope beyond what `serve` was started with.
937fn resolve_outbound<'a>(
938    req: &'a ControlRequest,
939    state: &'a AppState,
940    target_origin: Option<&'a str>,
941) -> Vec<&'a str> {
942    match req.allow_origin.as_deref() {
943        Some(origin) => {
944            tracing::warn!(
945                "Per-request --allow-origin override in effect; outbound scope for this request \
946                 widened to {origin}"
947            );
948            vec![origin]
949        }
950        None => state.config.allow_origins.outbound_for(target_origin),
951    }
952}
953
954/// The shared request path: scope-check, register a waiter, send the command,
955/// and await the browser's reply (or time out).
956/// Appends a best-effort `service = browser-bridge` HTTP record for one proxied
957/// request, flagging `via_daemon` when the bridge is hosted by the daemon.
958fn record_bridge_http(
959    method: &str,
960    url: &str,
961    started: Instant,
962    status: Option<u16>,
963    error: Option<&str>,
964) {
965    let via_daemon = matches!(
966        request_log::current_context().source,
967        request_log::Source::Daemon
968    );
969    request_log::record_http_with(
970        "browser-bridge",
971        method,
972        url,
973        started,
974        status,
975        error,
976        request_log::HttpExtra {
977            via_daemon,
978            ..Default::default()
979        },
980    );
981}
982
983async fn dispatch(
984    state: &AppState,
985    req: ControlRequest,
986) -> Result<ResponseEnvelope, (StatusCode, String)> {
987    let started = Instant::now();
988
989    // Resolve the target tab first: its connecting origin selects this request's
990    // per-origin outbound scope. Clone the sender + origin so the tab lock is
991    // released before the scope check and the await below.
992    let (sender, target_origin) = {
993        let tabs = state.lock_tabs();
994        let (conn_id, sender) = resolve_target(&tabs, req.target.as_deref())?;
995        let origin = tabs.get(&conn_id).and_then(|c| c.origin.clone());
996        (sender, origin)
997    };
998
999    let allowed = resolve_outbound(&req, state, target_origin.as_deref());
1000    auth::validate_outbound_url(&req.url, &allowed).map_err(|_| {
1001        (
1002            StatusCode::FORBIDDEN,
1003            "outbound URL is cross-origin; pass --allow-origin to permit it".to_string(),
1004        )
1005    })?;
1006
1007    for (name, value) in &req.headers {
1008        if !auth::header_is_safe(name, value) {
1009            return Err((
1010                StatusCode::BAD_REQUEST,
1011                "invalid header name or value".to_string(),
1012            ));
1013        }
1014    }
1015
1016    let _permit = state.in_flight.clone().try_acquire_owned().map_err(|_| {
1017        (
1018            StatusCode::TOO_MANY_REQUESTS,
1019            "too many in-flight requests".to_string(),
1020        )
1021    })?;
1022
1023    // Clone the method/URL for the request log before they move into `Command`.
1024    let log_method = req.method.clone();
1025    let log_url = req.url.clone();
1026    let (id, rx) = state.correlator.register();
1027    let command = Command {
1028        id,
1029        url: req.url,
1030        method: req.method,
1031        headers: req.headers,
1032        body: req.body,
1033        stream: false,
1034        credentials: req.credentials,
1035        encoding: req.encoding,
1036    };
1037    let frame = match serde_json::to_string(&command) {
1038        Ok(f) => f,
1039        Err(e) => {
1040            state.correlator.remove(id);
1041            return Err((
1042                StatusCode::INTERNAL_SERVER_ERROR,
1043                format!("serialise error: {e}"),
1044            ));
1045        }
1046    };
1047
1048    if sender.send(Message::Text(frame.into())).is_err() {
1049        state.correlator.remove(id);
1050        return Err((
1051            StatusCode::SERVICE_UNAVAILABLE,
1052            "no browser connected".to_string(),
1053        ));
1054    }
1055
1056    match tokio::time::timeout(state.config.request_timeout, rx).await {
1057        Ok(Ok(reply)) => match reply.outcome() {
1058            ReplyOutcome::Success {
1059                status,
1060                headers,
1061                body,
1062                encoding,
1063            } => {
1064                record_bridge_http(&log_method, &log_url, started, Some(status), None);
1065                // Size is accounted against the *decoded* body. For base64 that
1066                // means decoding here to learn the true byte length; the envelope
1067                // still carries the base64 string (the caller / proxy decodes).
1068                let decoded_len = match encoding.as_deref() {
1069                    None => body.len(),
1070                    Some("base64") => match BASE64.decode(body.as_bytes()) {
1071                        Ok(bytes) => bytes.len(),
1072                        Err(_) => {
1073                            return Err((
1074                                StatusCode::BAD_GATEWAY,
1075                                "browser sent an invalid base64 body".to_string(),
1076                            ))
1077                        }
1078                    },
1079                    Some(other) => {
1080                        return Err((
1081                            StatusCode::BAD_GATEWAY,
1082                            format!("browser sent an unsupported body encoding: {other}"),
1083                        ))
1084                    }
1085                };
1086                if decoded_len > state.config.max_body_bytes {
1087                    return Err((
1088                        StatusCode::BAD_GATEWAY,
1089                        format!(
1090                            "browser response body is {decoded_len} bytes, exceeding the \
1091                             --max-body-bytes limit of {} bytes; page the request to fetch \
1092                             less per call (e.g. narrow the time range or lower a `limit`/page \
1093                             size) or raise --max-body-bytes",
1094                            state.config.max_body_bytes
1095                        ),
1096                    ));
1097                }
1098                Ok(ResponseEnvelope {
1099                    id,
1100                    status,
1101                    headers,
1102                    body,
1103                    encoding,
1104                })
1105            }
1106            ReplyOutcome::Error(msg) => {
1107                record_bridge_http(&log_method, &log_url, started, None, Some(&msg));
1108                Err((
1109                    StatusCode::BAD_GATEWAY,
1110                    format!("browser fetch failed: {msg}"),
1111                ))
1112            }
1113        },
1114        Ok(Err(_)) => {
1115            record_bridge_http(
1116                &log_method,
1117                &log_url,
1118                started,
1119                None,
1120                Some("browser connection closed before replying"),
1121            );
1122            Err((
1123                StatusCode::BAD_GATEWAY,
1124                "browser connection closed before replying".to_string(),
1125            ))
1126        }
1127        Err(_) => {
1128            state.correlator.remove(id);
1129            record_bridge_http(
1130                &log_method,
1131                &log_url,
1132                started,
1133                None,
1134                Some("browser did not reply in time"),
1135            );
1136            Err((
1137                StatusCode::GATEWAY_TIMEOUT,
1138                "browser did not reply in time".to_string(),
1139            ))
1140        }
1141    }
1142}
1143
1144/// Sends a best-effort cancellation frame to the tab handling stream `id` and
1145/// drops the pending stream, so a stream whose consumer is gone (or which
1146/// tripped a limit) stops the in-page reader rather than fetching to completion.
1147/// A no-op if that tab has since disconnected.
1148async fn send_cancel(state: &AppState, conn_id: u64, id: u64) {
1149    state.correlator.remove(id);
1150    let Ok(frame) = serde_json::to_string(&CancelCommand::new(id)) else {
1151        return;
1152    };
1153    let tabs = state.lock_tabs();
1154    if let Some(conn) = tabs.get(&conn_id) {
1155        let _ = conn.sender.send(Message::Text(frame.into()));
1156    }
1157}
1158
1159/// The shared streaming request path: scope-check, register a stream waiter, send
1160/// the `stream: true` command, and await the head frame (status + headers) under
1161/// the inter-chunk idle timeout. Returns the head plus a [`StreamDriver`] that
1162/// pulls the remaining body chunks; the concurrency permit is held by the driver
1163/// for the stream's lifetime.
1164async fn start_stream(
1165    state: &AppState,
1166    req: ControlRequest,
1167) -> Result<(u16, BTreeMap<String, String>, StreamDriver), (StatusCode, String)> {
1168    let started = Instant::now();
1169
1170    // Resolve the target tab first: its connecting origin selects this request's
1171    // per-origin outbound scope, and its id is retained to cancel the stream if
1172    // the consumer goes away. Clone the sender + origin so the tab lock is
1173    // released before the scope check and the awaits below.
1174    let (conn_id, sender, target_origin) = {
1175        let tabs = state.lock_tabs();
1176        let (conn_id, sender) = resolve_target(&tabs, req.target.as_deref())?;
1177        let origin = tabs.get(&conn_id).and_then(|c| c.origin.clone());
1178        (conn_id, sender, origin)
1179    };
1180
1181    let allowed = resolve_outbound(&req, state, target_origin.as_deref());
1182    auth::validate_outbound_url(&req.url, &allowed).map_err(|_| {
1183        (
1184            StatusCode::FORBIDDEN,
1185            "outbound URL is cross-origin; pass --allow-origin to permit it".to_string(),
1186        )
1187    })?;
1188
1189    for (name, value) in &req.headers {
1190        if !auth::header_is_safe(name, value) {
1191            return Err((
1192                StatusCode::BAD_REQUEST,
1193                "invalid header name or value".to_string(),
1194            ));
1195        }
1196    }
1197
1198    let permit = state.in_flight.clone().try_acquire_owned().map_err(|_| {
1199        (
1200            StatusCode::TOO_MANY_REQUESTS,
1201            "too many in-flight requests".to_string(),
1202        )
1203    })?;
1204
1205    // Clone the method/URL for the request log before they move into `Command`.
1206    let log_method = req.method.clone();
1207    let log_url = req.url.clone();
1208    let (id, mut rx) = state.correlator.register_stream();
1209    let command = Command {
1210        id,
1211        url: req.url,
1212        method: req.method,
1213        headers: req.headers,
1214        body: req.body,
1215        stream: true,
1216        credentials: req.credentials,
1217        encoding: req.encoding,
1218    };
1219    let frame = match serde_json::to_string(&command) {
1220        Ok(f) => f,
1221        Err(e) => {
1222            state.correlator.remove(id);
1223            return Err((
1224                StatusCode::INTERNAL_SERVER_ERROR,
1225                format!("serialise error: {e}"),
1226            ));
1227        }
1228    };
1229
1230    if sender.send(Message::Text(frame.into())).is_err() {
1231        state.correlator.remove(id);
1232        return Err((
1233            StatusCode::SERVICE_UNAVAILABLE,
1234            "no browser connected".to_string(),
1235        ));
1236    }
1237
1238    let idle = state.config.request_timeout;
1239    let (status, headers) = match tokio::time::timeout(idle, rx.recv()).await {
1240        Ok(Some(StreamItem::Head { status, headers })) => {
1241            record_bridge_http(&log_method, &log_url, started, Some(status), None);
1242            (status, headers)
1243        }
1244        Ok(Some(StreamItem::Error(msg))) => {
1245            state.correlator.remove(id);
1246            record_bridge_http(&log_method, &log_url, started, None, Some(&msg));
1247            return Err((
1248                StatusCode::BAD_GATEWAY,
1249                format!("browser fetch failed: {msg}"),
1250            ));
1251        }
1252        Ok(Some(_)) => {
1253            state.correlator.remove(id);
1254            record_bridge_http(
1255                &log_method,
1256                &log_url,
1257                started,
1258                None,
1259                Some("browser streamed a body chunk before the response head"),
1260            );
1261            return Err((
1262                StatusCode::BAD_GATEWAY,
1263                "browser streamed a body chunk before the response head".to_string(),
1264            ));
1265        }
1266        Ok(None) => {
1267            record_bridge_http(
1268                &log_method,
1269                &log_url,
1270                started,
1271                None,
1272                Some("browser connection closed before replying"),
1273            );
1274            return Err((
1275                StatusCode::BAD_GATEWAY,
1276                "browser connection closed before replying".to_string(),
1277            ));
1278        }
1279        Err(_) => {
1280            send_cancel(state, conn_id, id).await;
1281            record_bridge_http(
1282                &log_method,
1283                &log_url,
1284                started,
1285                None,
1286                Some("browser did not start streaming in time"),
1287            );
1288            return Err((
1289                StatusCode::GATEWAY_TIMEOUT,
1290                "browser did not start streaming in time".to_string(),
1291            ));
1292        }
1293    };
1294
1295    let driver = StreamDriver {
1296        state: state.clone(),
1297        id,
1298        conn_id,
1299        rx,
1300        idle,
1301        max_body: state.config.max_body_bytes,
1302        sent: 0,
1303        _permit: permit,
1304        done: false,
1305    };
1306    Ok((status, headers, driver))
1307}
1308
1309/// Drives a registered stream's body chunks: applies the inter-chunk idle
1310/// timeout, decodes each base64 chunk, enforces the cumulative `--max-body-bytes`
1311/// ceiling, and cancels the browser stream on early/abnormal termination. Holds
1312/// the concurrency permit until dropped.
1313struct StreamDriver {
1314    state: AppState,
1315    id: u64,
1316    /// Connection id of the tab serving this stream; cancels route back to it.
1317    conn_id: u64,
1318    rx: mpsc::UnboundedReceiver<StreamItem>,
1319    idle: Duration,
1320    max_body: usize,
1321    sent: usize,
1322    _permit: OwnedSemaphorePermit,
1323    done: bool,
1324}
1325
1326/// One step of a [`StreamDriver`]: decoded chunk bytes, or end-of-stream.
1327enum NextChunk {
1328    /// A decoded body chunk and its sequence number.
1329    Data {
1330        /// Chunk sequence number reported by the browser.
1331        seq: u64,
1332        /// Decoded chunk bytes.
1333        bytes: Vec<u8>,
1334    },
1335    /// The stream is finished (normal end, error, idle timeout, or cap hit).
1336    End,
1337}
1338
1339impl StreamDriver {
1340    /// Pulls the next decoded chunk, ending the stream on a terminal item, an
1341    /// invalid chunk, an idle timeout, or the cumulative byte cap.
1342    async fn next_chunk(&mut self) -> NextChunk {
1343        if self.done {
1344            return NextChunk::End;
1345        }
1346        loop {
1347            match tokio::time::timeout(self.idle, self.rx.recv()).await {
1348                Ok(Some(StreamItem::Chunk { seq, data })) => {
1349                    let Ok(bytes) = BASE64.decode(data.as_bytes()) else {
1350                        return self.abort().await;
1351                    };
1352                    self.sent = self.sent.saturating_add(bytes.len());
1353                    if self.sent > self.max_body {
1354                        return self.abort().await;
1355                    }
1356                    return NextChunk::Data { seq, bytes };
1357                }
1358                // A stray head after the first is a protocol slip; ignore it.
1359                Ok(Some(StreamItem::Head { .. })) => {}
1360                Ok(Some(StreamItem::End | StreamItem::Error(_)) | None) => {
1361                    return self.finish();
1362                }
1363                // Inter-chunk idle timeout: stop the browser and end the stream.
1364                Err(_) => return self.abort().await,
1365            }
1366        }
1367    }
1368
1369    /// Ends the stream and removes the pending entry (terminal item / consumer
1370    /// gone — the browser is already done, so no cancel is sent).
1371    fn finish(&mut self) -> NextChunk {
1372        self.done = true;
1373        self.state.correlator.remove(self.id);
1374        NextChunk::End
1375    }
1376
1377    /// Ends the stream early and tells the browser to cancel its reader (idle
1378    /// timeout, cap exceeded, or an undecodable chunk).
1379    async fn abort(&mut self) -> NextChunk {
1380        self.done = true;
1381        send_cancel(&self.state, self.conn_id, self.id).await;
1382        NextChunk::End
1383    }
1384}
1385
1386/// Serialises a [`StreamLine`] as one NDJSON line (trailing newline).
1387fn to_ndjson_line(line: &StreamLine) -> String {
1388    let mut s = serde_json::to_string(line).unwrap_or_else(|_| "{}".to_string());
1389    s.push('\n');
1390    s
1391}
1392
1393/// Builds the transparent-proxy response for a streamed body: status and
1394/// `content-type` from the head frame, decoded chunk bytes streamed as a chunked
1395/// HTTP body.
1396fn raw_stream_response(
1397    status: u16,
1398    headers: BTreeMap<String, String>,
1399    driver: StreamDriver,
1400) -> Response {
1401    let code = StatusCode::from_u16(status).unwrap_or(StatusCode::BAD_GATEWAY);
1402    let mut builder = Response::builder().status(code);
1403    if let Some(ct) = headers.get("content-type") {
1404        builder = builder.header(header::CONTENT_TYPE, ct);
1405    }
1406    let stream = futures::stream::unfold(driver, |mut driver| async move {
1407        match driver.next_chunk().await {
1408            NextChunk::Data { bytes, .. } => Some((
1409                Ok::<_, std::convert::Infallible>(Bytes::from(bytes)),
1410                driver,
1411            )),
1412            NextChunk::End => None,
1413        }
1414    });
1415    builder
1416        .body(Body::from_stream(stream))
1417        .unwrap_or_else(|_| StatusCode::BAD_GATEWAY.into_response())
1418}
1419
1420/// Builds the `POST /__bridge/request` response for a streamed body: an NDJSON
1421/// body of a head line, `{seq,chunk}` lines, and a terminating `{done}` line.
1422fn ndjson_stream_response(
1423    status: u16,
1424    headers: BTreeMap<String, String>,
1425    driver: StreamDriver,
1426) -> Response {
1427    let head_line = to_ndjson_line(&StreamLine::Head { status, headers });
1428    // State: (pending head line, driver, done-line-emitted).
1429    let init = (Some(head_line), driver, false);
1430    let stream = futures::stream::unfold(init, |(head, mut driver, done_emitted)| async move {
1431        if let Some(line) = head {
1432            return Some((
1433                Ok::<_, std::convert::Infallible>(Bytes::from(line)),
1434                (None, driver, done_emitted),
1435            ));
1436        }
1437        if done_emitted {
1438            return None;
1439        }
1440        match driver.next_chunk().await {
1441            NextChunk::Data { seq, bytes } => {
1442                let line = to_ndjson_line(&StreamLine::Chunk {
1443                    seq,
1444                    chunk: BASE64.encode(&bytes),
1445                });
1446                Some((Ok(Bytes::from(line)), (None, driver, false)))
1447            }
1448            NextChunk::End => {
1449                let line = to_ndjson_line(&StreamLine::Done { done: true });
1450                Some((Ok(Bytes::from(line)), (None, driver, true)))
1451            }
1452        }
1453    });
1454    Response::builder()
1455        .status(StatusCode::OK)
1456        .header(header::CONTENT_TYPE, "application/x-ndjson")
1457        .body(Body::from_stream(stream))
1458        .unwrap_or_else(|_| StatusCode::BAD_GATEWAY.into_response())
1459}
1460
1461/// Renders a browser response envelope as the transparent-proxy HTTP response.
1462///
1463/// A base64-tagged body is decoded back to raw bytes so a `curl` client gets the
1464/// original bytes (image, gzip blob, …); the base64 is validated in `dispatch`,
1465/// but a decode failure here still fails closed with `502`.
1466fn envelope_to_response(env: ResponseEnvelope) -> Response {
1467    let status = StatusCode::from_u16(env.status).unwrap_or(StatusCode::BAD_GATEWAY);
1468    let mut builder = Response::builder().status(status);
1469    if let Some(ct) = env.headers.get("content-type") {
1470        builder = builder.header(header::CONTENT_TYPE, ct);
1471    }
1472    let body = match env.encoding.as_deref() {
1473        Some("base64") => match BASE64.decode(env.body.as_bytes()) {
1474            Ok(bytes) => Body::from(bytes),
1475            Err(_) => return StatusCode::BAD_GATEWAY.into_response(),
1476        },
1477        _ => Body::from(env.body),
1478    };
1479    builder
1480        .body(body)
1481        .unwrap_or_else(|_| StatusCode::BAD_GATEWAY.into_response())
1482}
1483
1484#[cfg(test)]
1485#[allow(clippy::unwrap_used, clippy::expect_used)]
1486mod tests {
1487    use super::*;
1488
1489    fn buffered_frame(id: u64) -> BrowserFrame {
1490        BrowserFrame {
1491            id,
1492            status: Some(200),
1493            headers: None,
1494            body: Some("ok".into()),
1495            encoding: None,
1496            error: None,
1497            stream: None,
1498            chunk: None,
1499            seq: None,
1500            done: None,
1501        }
1502    }
1503
1504    /// Builds a `tabs` map entry with a detached sender (the receiver is dropped;
1505    /// routing tests only assert *which* connection is chosen, not delivery).
1506    fn tab(origin: Option<&str>) -> WsConn {
1507        let (sender, _rx) = mpsc::unbounded_channel();
1508        WsConn {
1509            sender,
1510            origin: origin.map(str::to_string),
1511        }
1512    }
1513
1514    #[test]
1515    fn resolve_target_no_tabs_is_503() {
1516        let tabs = HashMap::new();
1517        let err = resolve_target(&tabs, None).unwrap_err();
1518        assert_eq!(err.0, StatusCode::SERVICE_UNAVAILABLE);
1519    }
1520
1521    #[test]
1522    fn resolve_target_single_tab_routes_without_target() {
1523        let mut tabs = HashMap::new();
1524        tabs.insert(1, tab(Some("https://a.test")));
1525        let (id, _s) = resolve_target(&tabs, None).unwrap();
1526        assert_eq!(id, 1);
1527    }
1528
1529    #[test]
1530    fn resolve_target_multiple_tabs_no_target_is_409() {
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 err = resolve_target(&tabs, None).unwrap_err();
1535        assert_eq!(err.0, StatusCode::CONFLICT);
1536        // The message lists both connected tabs to disambiguate.
1537        assert!(err.1.contains("id 1") && err.1.contains("id 2"));
1538    }
1539
1540    #[test]
1541    fn resolve_target_by_connection_id() {
1542        let mut tabs = HashMap::new();
1543        tabs.insert(1, tab(Some("https://a.test")));
1544        tabs.insert(2, tab(Some("https://b.test")));
1545        let (id, _s) = resolve_target(&tabs, Some("2")).unwrap();
1546        assert_eq!(id, 2);
1547        // Unknown id is a 404.
1548        assert_eq!(
1549            resolve_target(&tabs, Some("9")).unwrap_err().0,
1550            StatusCode::NOT_FOUND
1551        );
1552    }
1553
1554    #[test]
1555    fn resolve_target_by_unique_origin() {
1556        let mut tabs = HashMap::new();
1557        tabs.insert(1, tab(Some("https://a.test")));
1558        tabs.insert(2, tab(Some("https://b.test")));
1559        let (id, _s) = resolve_target(&tabs, Some("https://b.test")).unwrap();
1560        assert_eq!(id, 2);
1561        // Unknown origin is a 404.
1562        assert_eq!(
1563            resolve_target(&tabs, Some("https://nope.test"))
1564                .unwrap_err()
1565                .0,
1566            StatusCode::NOT_FOUND
1567        );
1568    }
1569
1570    #[test]
1571    fn resolve_target_ambiguous_origin_is_409() {
1572        let mut tabs = HashMap::new();
1573        tabs.insert(1, tab(Some("https://a.test")));
1574        tabs.insert(2, tab(Some("https://a.test")));
1575        let err = resolve_target(&tabs, Some("https://a.test")).unwrap_err();
1576        assert_eq!(err.0, StatusCode::CONFLICT);
1577        // Two tabs share the origin → caller is told to target by id.
1578        assert!(err.1.contains("connection id"));
1579    }
1580
1581    #[test]
1582    fn target_header_trims_and_drops_empty() {
1583        let mut h = axum::http::HeaderMap::new();
1584        assert_eq!(target_header(&h), None);
1585        h.insert(auth::BRIDGE_TARGET_HEADER, "  2  ".parse().unwrap());
1586        assert_eq!(target_header(&h).as_deref(), Some("2"));
1587        h.insert(auth::BRIDGE_TARGET_HEADER, "   ".parse().unwrap());
1588        assert_eq!(target_header(&h), None);
1589    }
1590
1591    #[test]
1592    fn origin_header_trims_and_drops_empty() {
1593        let mut h = axum::http::HeaderMap::new();
1594        assert_eq!(origin_header(&h), None);
1595        h.insert(auth::BRIDGE_ORIGIN_HEADER, "  cli-42  ".parse().unwrap());
1596        assert_eq!(origin_header(&h).as_deref(), Some("cli-42"));
1597        h.insert(auth::BRIDGE_ORIGIN_HEADER, "   ".parse().unwrap());
1598        assert_eq!(origin_header(&h), None);
1599    }
1600
1601    #[test]
1602    fn tab_list_renders_id_with_and_without_origin() {
1603        let mut tabs = HashMap::new();
1604        tabs.insert(1, tab(Some("https://a.test")));
1605        tabs.insert(2, tab(None));
1606        // Id-sorted; a tab that sent no `Origin` renders as the bare id.
1607        assert_eq!(tab_list(&tabs), "id 1: https://a.test, id 2");
1608    }
1609
1610    /// A minimal [`AppState`] for exercising `dispatch` / `start_stream` without
1611    /// a real WebSocket peer.
1612    fn test_state() -> AppState {
1613        AppState {
1614            token: Arc::new("t".to_string()),
1615            config: Arc::new(BridgeConfig {
1616                ws_port: 0,
1617                control_port: 0,
1618                request_timeout: Duration::from_secs(5),
1619                allow_origins: auth::OriginAllowlist::default(),
1620                max_body_bytes: 1024,
1621                max_concurrent: 8,
1622            }),
1623            correlator: Correlator::new(),
1624            tabs: Arc::new(StdMutex::new(HashMap::new())),
1625            in_flight: Arc::new(Semaphore::new(8)),
1626            conn_counter: Arc::new(AtomicU64::new(1)),
1627        }
1628    }
1629
1630    /// Inserts a tab whose writer receiver is already dropped, so any send to it
1631    /// fails — modelling a tab that vanished between routing and dispatch.
1632    async fn insert_dead_tab(state: &AppState, id: u64) {
1633        let (sender, rx) = mpsc::unbounded_channel();
1634        drop(rx);
1635        state.lock_tabs().insert(
1636            id,
1637            WsConn {
1638                sender,
1639                origin: None,
1640            },
1641        );
1642    }
1643
1644    fn plain_request() -> ControlRequest {
1645        ControlRequest {
1646            url: "/x".to_string(),
1647            method: "GET".to_string(),
1648            headers: BTreeMap::new(),
1649            body: None,
1650            stream: false,
1651            target: None,
1652            allow_origin: None,
1653            credentials: None,
1654            encoding: None,
1655        }
1656    }
1657
1658    /// Builds a state whose `serve` per-origin allowlist is set from
1659    /// `--allow-origin` values, to exercise `resolve_outbound`.
1660    fn state_with_allowlist(values: &[&str]) -> AppState {
1661        let mut state = test_state();
1662        let mut config = (*state.config).clone();
1663        config.allow_origins = auth::OriginAllowlist::parse(values).unwrap();
1664        state.config = Arc::new(config);
1665        state
1666    }
1667
1668    #[test]
1669    fn resolve_outbound_prefers_per_request_override() {
1670        let state = state_with_allowlist(&["https://grafana.internal"]);
1671        let req = ControlRequest {
1672            allow_origin: Some("https://per-request.test".to_string()),
1673            ..plain_request()
1674        };
1675        // The per-request value wins over the tab's per-origin grant.
1676        assert_eq!(
1677            resolve_outbound(&req, &state, Some("https://grafana.internal")),
1678            vec!["https://per-request.test"]
1679        );
1680        // A request carrying the override permits its matched cross-origin
1681        // target, and still rejects an unmatched one.
1682        let allowed = resolve_outbound(&req, &state, Some("https://grafana.internal"));
1683        assert_eq!(
1684            auth::validate_outbound_url("https://per-request.test/x", &allowed),
1685            Ok(())
1686        );
1687        assert!(auth::validate_outbound_url("https://other.test/x", &allowed).is_err());
1688    }
1689
1690    #[test]
1691    fn resolve_outbound_uses_the_target_tabs_grant() {
1692        let state = state_with_allowlist(&[
1693            "https://grafana.internal",
1694            "https://www.facebook.com=https://static.xx.fbcdn.net",
1695        ]);
1696        let req = plain_request();
1697        assert!(req.allow_origin.is_none());
1698        // With no override, the target tab's connecting origin selects its scope.
1699        assert_eq!(
1700            resolve_outbound(&req, &state, Some("https://grafana.internal")),
1701            vec!["https://grafana.internal"]
1702        );
1703        assert_eq!(
1704            resolve_outbound(&req, &state, Some("https://www.facebook.com")),
1705            vec!["https://static.xx.fbcdn.net"]
1706        );
1707        // A tab with no grant gets an empty (default-closed) outbound set.
1708        assert!(resolve_outbound(&req, &state, Some("https://other.test")).is_empty());
1709    }
1710
1711    #[test]
1712    fn per_request_override_does_not_affect_ws_connection_gate() {
1713        // The per-request override feeds only the outbound-URL check. The
1714        // connection-time WS gate reads the allowlist directly, so a request
1715        // override permitting B never widens which origins may connect.
1716        let state = state_with_allowlist(&["https://a.test"]);
1717        let req = ControlRequest {
1718            allow_origin: Some("https://b.test".to_string()),
1719            ..plain_request()
1720        };
1721        // Outbound to B is permitted by the override...
1722        let allowed = resolve_outbound(&req, &state, Some("https://a.test"));
1723        assert_eq!(
1724            auth::validate_outbound_url("https://b.test/x", &allowed),
1725            Ok(())
1726        );
1727        // ...while the WS upgrade gate admits A (a configured key) but not B.
1728        assert!(state
1729            .config
1730            .allow_origins
1731            .permits_connection(Some("https://a.test")));
1732        assert!(!state
1733            .config
1734            .allow_origins
1735            .permits_connection(Some("https://b.test")));
1736    }
1737
1738    #[tokio::test]
1739    async fn dispatch_returns_503_when_send_fails() {
1740        let state = test_state();
1741        insert_dead_tab(&state, 1).await;
1742        let err = dispatch(&state, plain_request()).await.unwrap_err();
1743        assert_eq!(err.0, StatusCode::SERVICE_UNAVAILABLE);
1744        // The failed dispatch leaves no dangling waiter behind.
1745        assert_eq!(state.correlator.pending_count(), 0);
1746    }
1747
1748    #[tokio::test]
1749    async fn start_stream_returns_503_when_send_fails() {
1750        let state = test_state();
1751        insert_dead_tab(&state, 1).await;
1752        let req = ControlRequest {
1753            stream: true,
1754            ..plain_request()
1755        };
1756        let err = start_stream(&state, req).await.err().map(|e| e.0);
1757        assert_eq!(err, Some(StatusCode::SERVICE_UNAVAILABLE));
1758        assert_eq!(state.correlator.pending_count(), 0);
1759    }
1760
1761    #[test]
1762    fn correlator_register_resolve_round_trip() {
1763        let c = Correlator::new();
1764        let (id, rx) = c.register();
1765        assert_eq!(c.pending_count(), 1);
1766        assert_eq!(c.deliver(buffered_frame(id)), None);
1767        assert_eq!(c.pending_count(), 0);
1768        let reply = rx.now_or_never().unwrap().unwrap();
1769        assert_eq!(reply.id, id);
1770    }
1771
1772    #[test]
1773    fn correlator_stream_forwards_items_until_terminal() {
1774        let c = Correlator::new();
1775        let (id, mut rx) = c.register_stream();
1776        assert_eq!(c.pending_count(), 1);
1777
1778        let mut head = buffered_frame(id);
1779        head.stream = Some(true);
1780        head.body = None;
1781        assert_eq!(c.deliver(head), None);
1782        assert!(matches!(
1783            rx.try_recv(),
1784            Ok(StreamItem::Head { status: 200, .. })
1785        ));
1786        // Head is non-terminal: the waiter stays registered.
1787        assert_eq!(c.pending_count(), 1);
1788
1789        let mut done = BrowserFrame {
1790            done: Some(true),
1791            ..buffered_frame(id)
1792        };
1793        done.body = None;
1794        assert_eq!(c.deliver(done), None);
1795        assert!(matches!(rx.try_recv(), Ok(StreamItem::End)));
1796        assert_eq!(c.pending_count(), 0);
1797    }
1798
1799    #[test]
1800    fn correlator_deliver_unknown_id_is_noop() {
1801        let c = Correlator::new();
1802        // A frame whose id was never registered (or already terminal) is dropped
1803        // without panicking and never asks the caller to cancel.
1804        assert_eq!(c.deliver(buffered_frame(999)), None);
1805        assert_eq!(c.pending_count(), 0);
1806    }
1807
1808    #[test]
1809    fn correlator_stream_signals_cancel_when_consumer_gone() {
1810        let c = Correlator::new();
1811        let (id, rx) = c.register_stream();
1812        drop(rx); // consumer disconnected
1813        let mut chunk = buffered_frame(id);
1814        chunk.chunk = Some("aGk=".into());
1815        chunk.body = None;
1816        // Delivery fails (receiver dropped) → caller is told to cancel `id`.
1817        assert_eq!(c.deliver(chunk), Some(id));
1818        assert_eq!(c.pending_count(), 0);
1819    }
1820
1821    #[test]
1822    fn correlator_ids_are_monotonic() {
1823        let c = Correlator::new();
1824        let (a, _ra) = c.register();
1825        let (b, _rb) = c.register();
1826        assert!(b > a);
1827    }
1828
1829    #[test]
1830    fn correlator_remove_drops_waiter() {
1831        let c = Correlator::new();
1832        let (id, _rx) = c.register();
1833        c.remove(id);
1834        assert_eq!(c.pending_count(), 0);
1835    }
1836
1837    #[test]
1838    fn extract_stream_flag_detects_and_strips_marker() {
1839        assert_eq!(extract_stream_flag(None), (false, None));
1840        assert_eq!(
1841            extract_stream_flag(Some("a=1&b=2")),
1842            (false, Some("a=1&b=2".to_string()))
1843        );
1844        assert_eq!(
1845            extract_stream_flag(Some("a=1&__stream=1&b=2")),
1846            (true, Some("a=1&b=2".to_string()))
1847        );
1848        // Bare marker, nothing else left.
1849        assert_eq!(extract_stream_flag(Some("__stream")), (true, None));
1850        // Explicit disable.
1851        assert_eq!(extract_stream_flag(Some("__stream=0")), (false, None));
1852    }
1853
1854    #[test]
1855    fn forwardable_headers_drops_control_headers() {
1856        let mut h = axum::http::HeaderMap::new();
1857        h.insert("host", "localhost:9998".parse().unwrap());
1858        h.insert("authorization", "Bearer x".parse().unwrap());
1859        h.insert("x-omni-bridge", "1".parse().unwrap());
1860        h.insert("accept", "application/json".parse().unwrap());
1861        let out = forwardable_headers(&h);
1862        assert!(!out.contains_key("host"));
1863        assert!(!out.contains_key("authorization"));
1864        assert!(!out.contains_key("x-omni-bridge"));
1865        assert_eq!(
1866            out.get("accept").map(String::as_str),
1867            Some("application/json")
1868        );
1869    }
1870
1871    #[test]
1872    fn envelope_to_response_passes_text_body_through() {
1873        let env = ResponseEnvelope {
1874            id: 1,
1875            status: 200,
1876            headers: BTreeMap::new(),
1877            body: "hello".into(),
1878            encoding: None,
1879        };
1880        assert_eq!(envelope_to_response(env).status(), StatusCode::OK);
1881    }
1882
1883    #[test]
1884    fn envelope_to_response_rejects_invalid_base64() {
1885        // `dispatch` validates base64 before this runs, so this path is only
1886        // reachable defensively — assert it still fails closed with 502.
1887        let env = ResponseEnvelope {
1888            id: 1,
1889            status: 200,
1890            headers: BTreeMap::new(),
1891            body: "not valid base64 @@@".into(),
1892            encoding: Some("base64".into()),
1893        };
1894        assert_eq!(envelope_to_response(env).status(), StatusCode::BAD_GATEWAY);
1895    }
1896
1897    use futures::FutureExt;
1898
1899    /// A `BridgeConfig` bound to random free ports, for lifecycle tests.
1900    fn ephemeral_config() -> BridgeConfig {
1901        BridgeConfig {
1902            ws_port: 0,
1903            control_port: 0,
1904            request_timeout: Duration::from_secs(5),
1905            allow_origins: auth::OriginAllowlist::default(),
1906            max_body_bytes: 1024,
1907            max_concurrent: 8,
1908        }
1909    }
1910
1911    #[tokio::test]
1912    async fn bridge_server_start_status_shutdown() {
1913        let server = BridgeServer::start(ephemeral_config(), "tok".to_string())
1914            .await
1915            .unwrap();
1916        // Ports were resolved from the OS (port 0 → a real bound port).
1917        assert_ne!(server.control_port(), 0);
1918        assert_ne!(server.ws_port(), 0);
1919        // No browser is connected yet.
1920        let status = server.status();
1921        assert!(!status.connected);
1922        assert!(status.tabs.is_empty());
1923        assert_eq!(status.pending, 0);
1924        // Graceful shutdown drains and returns.
1925        server.shutdown().await;
1926    }
1927
1928    #[tokio::test]
1929    async fn disconnect_unknown_tab_is_error() {
1930        let server = BridgeServer::start(ephemeral_config(), "tok".to_string())
1931            .await
1932            .unwrap();
1933        let err = server.disconnect_tab(999).unwrap_err();
1934        assert!(err.to_string().contains("no connected tab"));
1935        server.shutdown().await;
1936    }
1937
1938    #[tokio::test]
1939    async fn start_fails_closed_on_taken_control_port() {
1940        // Occupy a port, then ask the bridge to bind the same one.
1941        let squatter = std::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).unwrap();
1942        let taken = squatter.local_addr().unwrap().port();
1943        let config = BridgeConfig {
1944            control_port: taken,
1945            ..ephemeral_config()
1946        };
1947        assert!(BridgeServer::start(config, "tok".to_string())
1948            .await
1949            .is_err());
1950    }
1951
1952    #[tokio::test]
1953    async fn rebind_same_fixed_port_after_close_succeeds() {
1954        // Regression for #990: a daemon/tray restart tears the planes down and
1955        // rebinds the *same* fixed ports. A just-closed connection can leave the
1956        // listener address in TIME_WAIT; without SO_REUSEADDR the rebind fails
1957        // with EADDRINUSE and the bridge is wedged not-running. With it, the
1958        // rebind succeeds.
1959        let server = BridgeServer::start(ephemeral_config(), "tok".to_string())
1960            .await
1961            .unwrap();
1962        let control_port = server.control_port();
1963        let ws_port = server.ws_port();
1964
1965        // Hold loopback connections to both planes across the teardown so the
1966        // server is the active closer and its listener ports pass through
1967        // TIME_WAIT.
1968        let control_conn = TcpStream::connect((Ipv4Addr::LOCALHOST, control_port))
1969            .await
1970            .unwrap();
1971        let ws_conn = TcpStream::connect((Ipv4Addr::LOCALHOST, ws_port))
1972            .await
1973            .unwrap();
1974
1975        server.shutdown().await;
1976        drop(control_conn);
1977        drop(ws_conn);
1978        // Let the four-way close settle so the ports are genuinely in TIME_WAIT.
1979        tokio::time::sleep(Duration::from_millis(50)).await;
1980
1981        let config = BridgeConfig {
1982            ws_port,
1983            control_port,
1984            ..ephemeral_config()
1985        };
1986        let server2 = BridgeServer::start(config, "tok".to_string())
1987            .await
1988            .expect("rebinding just-released fixed ports must succeed with SO_REUSEADDR");
1989        server2.shutdown().await;
1990    }
1991}