1use 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
44pub const DEFAULT_WS_PORT: u16 = 9999;
46pub const DEFAULT_CONTROL_PORT: u16 = 9998;
48pub const DEFAULT_TIMEOUT_SECS: u64 = 30;
50pub const DEFAULT_MAX_BODY_BYTES: usize = 8 * 1024 * 1024;
52pub const DEFAULT_MAX_CONCURRENT: usize = 64;
54
55#[derive(Debug, Clone)]
57pub struct BridgeConfig {
58 pub ws_port: u16,
60 pub control_port: u16,
62 pub request_timeout: Duration,
64 pub allow_origin: Option<String>,
66 pub max_body_bytes: usize,
68 pub max_concurrent: usize,
70}
71
72impl Default for BridgeConfig {
73 fn default() -> Self {
76 Self {
77 ws_port: DEFAULT_WS_PORT,
78 control_port: DEFAULT_CONTROL_PORT,
79 request_timeout: Duration::from_secs(DEFAULT_TIMEOUT_SECS),
80 allow_origin: None,
81 max_body_bytes: DEFAULT_MAX_BODY_BYTES,
82 max_concurrent: DEFAULT_MAX_CONCURRENT,
83 }
84 }
85}
86
87enum Waiter {
90 Buffered(oneshot::Sender<BrowserReply>),
92 Stream(mpsc::UnboundedSender<StreamItem>),
94}
95
96#[derive(Clone)]
98struct Correlator {
99 pending: Arc<StdMutex<HashMap<u64, Waiter>>>,
100 next_id: Arc<AtomicU64>,
101}
102
103impl Correlator {
104 fn new() -> Self {
105 Self {
106 pending: Arc::new(StdMutex::new(HashMap::new())),
107 next_id: Arc::new(AtomicU64::new(1)),
108 }
109 }
110
111 fn register(&self) -> (u64, oneshot::Receiver<BrowserReply>) {
113 let id = self.next_id.fetch_add(1, Ordering::Relaxed);
114 let (tx, rx) = oneshot::channel();
115 self.lock().insert(id, Waiter::Buffered(tx));
116 (id, rx)
117 }
118
119 fn register_stream(&self) -> (u64, mpsc::UnboundedReceiver<StreamItem>) {
122 let id = self.next_id.fetch_add(1, Ordering::Relaxed);
123 let (tx, rx) = mpsc::unbounded_channel();
124 self.lock().insert(id, Waiter::Stream(tx));
125 (id, rx)
126 }
127
128 fn remove(&self, id: u64) {
130 self.lock().remove(&id);
131 }
132
133 fn deliver(&self, frame: BrowserFrame) -> Option<u64> {
140 let id = frame.id;
141 let mut guard = self.lock();
142 match guard.get(&id) {
143 Some(Waiter::Buffered(_)) => {
144 if let Some(Waiter::Buffered(tx)) = guard.remove(&id) {
145 let _ = tx.send(frame.into_reply());
146 }
147 None
148 }
149 Some(Waiter::Stream(_)) => {
150 let item = frame.stream_item();
151 let terminal = matches!(item, StreamItem::End | StreamItem::Error(_));
152 let send_failed = match guard.get(&id) {
153 Some(Waiter::Stream(tx)) => tx.send(item).is_err(),
154 _ => false,
155 };
156 if terminal || send_failed {
157 guard.remove(&id);
158 }
159 send_failed.then_some(id)
160 }
161 None => None,
162 }
163 }
164
165 fn pending_count(&self) -> usize {
166 self.lock().len()
167 }
168
169 fn lock(&self) -> std::sync::MutexGuard<'_, HashMap<u64, Waiter>> {
170 self.pending
171 .lock()
172 .unwrap_or_else(std::sync::PoisonError::into_inner)
173 }
174}
175
176struct WsConn {
178 sender: mpsc::UnboundedSender<Message>,
180 origin: Option<String>,
182}
183
184#[derive(Clone)]
186struct AppState {
187 token: Arc<String>,
188 config: Arc<BridgeConfig>,
189 correlator: Correlator,
190 tabs: Arc<StdMutex<HashMap<u64, WsConn>>>,
198 in_flight: Arc<Semaphore>,
199 conn_counter: Arc<AtomicU64>,
200}
201
202impl AppState {
203 fn lock_tabs(&self) -> std::sync::MutexGuard<'_, HashMap<u64, WsConn>> {
206 self.tabs
207 .lock()
208 .unwrap_or_else(std::sync::PoisonError::into_inner)
209 }
210
211 fn status_snapshot(&self) -> StatusResponse {
215 let mut tabs: Vec<TabInfo> = {
216 let guard = self.lock_tabs();
217 guard
218 .iter()
219 .map(|(id, conn)| TabInfo {
220 id: *id,
221 origin: conn.origin.clone(),
222 })
223 .collect()
224 };
225 tabs.sort_by_key(|t| t.id);
226 let browser_origin = match tabs.as_slice() {
229 [only] => only.origin.clone(),
230 _ => None,
231 };
232 StatusResponse {
233 connected: !tabs.is_empty(),
234 browser_origin,
235 tabs,
236 pending: self.correlator.pending_count(),
237 }
238 }
239}
240
241pub struct BridgeServer {
250 state: AppState,
251 control_port: u16,
252 ws_port: u16,
253 shutdown: CancellationToken,
254 tasks: JoinSet<()>,
255}
256
257fn bind_loopback_reuse(addr: SocketAddr) -> std::io::Result<TcpListener> {
264 let socket = if addr.is_ipv4() {
265 TcpSocket::new_v4()?
266 } else {
267 TcpSocket::new_v6()?
268 };
269 socket.set_reuseaddr(true)?;
270 socket.bind(addr)?;
271 socket.listen(1024) }
273
274impl BridgeServer {
275 pub async fn start(mut config: BridgeConfig, token: String) -> Result<Self> {
279 let control_listener =
280 bind_loopback_reuse(SocketAddr::from((Ipv4Addr::LOCALHOST, config.control_port)))
281 .with_context(|| {
282 format!(
283 "Failed to bind control plane to 127.0.0.1:{} (already in use?)",
284 config.control_port
285 )
286 })?;
287 let ws_listener =
288 bind_loopback_reuse(SocketAddr::from((Ipv4Addr::LOCALHOST, config.ws_port)))
289 .with_context(|| {
290 format!(
291 "Failed to bind WebSocket plane to 127.0.0.1:{} (already in use?)",
292 config.ws_port
293 )
294 })?;
295
296 config.control_port = control_listener.local_addr()?.port();
299 config.ws_port = ws_listener.local_addr()?.port();
300 let control_port = config.control_port;
301 let ws_port = config.ws_port;
302 let max_body_bytes = config.max_body_bytes;
303 let max_concurrent = config.max_concurrent;
304
305 let ws_listener_v6 =
310 bind_loopback_reuse(SocketAddr::from((Ipv6Addr::LOCALHOST, ws_port))).ok();
311
312 let state = AppState {
313 token: Arc::new(token),
314 config: Arc::new(config),
315 correlator: Correlator::new(),
316 tabs: Arc::new(StdMutex::new(HashMap::new())),
317 in_flight: Arc::new(Semaphore::new(max_concurrent)),
318 conn_counter: Arc::new(AtomicU64::new(1)),
319 };
320
321 let shutdown = CancellationToken::new();
322 let mut tasks = JoinSet::new();
323
324 spawn_ws_accept(&mut tasks, ws_listener, state.clone(), shutdown.clone());
326 if let Some(listener) = ws_listener_v6 {
327 spawn_ws_accept(&mut tasks, listener, state.clone(), shutdown.clone());
328 } else {
329 tracing::debug!("IPv6 loopback (::1) WebSocket bind unavailable; IPv4 only");
330 }
331
332 let control_state = state.clone();
335 let control_shutdown = shutdown.clone();
336 tasks.spawn(async move {
337 let app = control_router(control_state, max_body_bytes);
338 if let Err(e) = axum::serve(control_listener, app)
339 .with_graceful_shutdown(control_shutdown.cancelled_owned())
340 .await
341 {
342 tracing::warn!("Control-plane server error: {e}");
343 }
344 });
345
346 Ok(Self {
347 state,
348 control_port,
349 ws_port,
350 shutdown,
351 tasks,
352 })
353 }
354
355 pub fn control_port(&self) -> u16 {
357 self.control_port
358 }
359
360 pub fn ws_port(&self) -> u16 {
362 self.ws_port
363 }
364
365 pub fn status(&self) -> StatusResponse {
368 self.state.status_snapshot()
369 }
370
371 pub fn disconnect_tab(&self, id: u64) -> Result<()> {
374 let mut tabs = self.state.lock_tabs();
375 match tabs.remove(&id) {
376 Some(conn) => {
377 let _ = conn.sender.send(Message::Close(None));
378 Ok(())
379 }
380 None => anyhow::bail!("no connected tab with id {id}"),
381 }
382 }
383
384 pub async fn wait(self) -> Result<()> {
387 let _ = tokio::signal::ctrl_c().await;
388 self.shutdown().await;
389 Ok(())
390 }
391
392 pub async fn shutdown(self) {
395 let Self {
396 mut tasks,
397 shutdown,
398 ..
399 } = self;
400 shutdown.cancel();
401 let drain = async { while tasks.join_next().await.is_some() {} };
402 if tokio::time::timeout(Duration::from_secs(10), drain)
403 .await
404 .is_err()
405 {
406 tracing::warn!("bridge shutdown timed out; remaining tasks will be aborted");
407 }
408 }
409}
410
411pub async fn run(config: BridgeConfig, token: String) -> Result<()> {
419 let server = BridgeServer::start(config, token).await?;
420 print_startup(server.state.config.as_ref(), &server.state.token);
421 server.wait().await
422}
423
424fn print_startup(config: &BridgeConfig, token: &str) {
426 let snippet = snippet::render(config.ws_port, token);
427 println!("omni-dev browser bridge serve");
428 println!(" control plane : http://127.0.0.1:{}", config.control_port);
429 println!(" websocket : ws://127.0.0.1:{}", config.ws_port);
430 println!(" session token : {token}");
431 if let Some(origin) = &config.allow_origin {
432 println!(" allow-origin : {origin}");
433 }
434 println!();
435 println!("Paste this into the DevTools console of the authenticated tab:");
436 println!();
437 println!("{snippet}");
438 println!();
439 println!("Then drive requests, e.g.:");
440 println!(
441 " omni-dev browser bridge request --control-port {} --url /path",
442 config.control_port
443 );
444}
445
446fn spawn_ws_accept(
452 tasks: &mut JoinSet<()>,
453 listener: TcpListener,
454 state: AppState,
455 shutdown: CancellationToken,
456) {
457 tasks.spawn(async move {
458 loop {
459 tokio::select! {
460 () = shutdown.cancelled() => break,
461 accepted = listener.accept() => match accepted {
462 Ok((stream, _peer)) => {
463 tokio::spawn(handle_ws_conn(stream, state.clone()));
464 }
465 Err(e) => tracing::warn!("WebSocket accept error: {e}"),
466 },
467 }
468 }
469 });
470}
471
472#[allow(clippy::result_large_err)]
480async fn handle_ws_conn(stream: TcpStream, state: AppState) {
481 use tokio_tungstenite::tungstenite::handshake::server::{ErrorResponse, Request, Response};
482
483 let token = state.token.clone();
484 let allow_origin = state.config.allow_origin.clone();
485 let captured_origin: Arc<StdMutex<Option<String>>> = Arc::new(StdMutex::new(None));
486 let co = captured_origin.clone();
487
488 let callback =
489 move |req: &Request, mut response: Response| -> Result<Response, ErrorResponse> {
490 let origin = req
491 .headers()
492 .get("origin")
493 .and_then(|v| v.to_str().ok())
494 .map(str::to_string);
495
496 if !auth::ws_origin_allowed(origin.as_deref(), allow_origin.as_deref()) {
497 tracing::warn!("Rejected WebSocket upgrade: origin not allowed");
498 return Err(ws_error(StatusCode::FORBIDDEN, "origin not allowed"));
499 }
500
501 let protocols: Vec<String> = req
502 .headers()
503 .get_all("sec-websocket-protocol")
504 .iter()
505 .filter_map(|v| v.to_str().ok())
506 .flat_map(|s| s.split(',').map(|p| p.trim().to_string()))
507 .collect();
508
509 let Some(matched) =
510 auth::ws_subprotocol_token(protocols.iter().map(String::as_str), &token)
511 else {
512 tracing::warn!("Rejected WebSocket upgrade: missing or invalid token");
513 return Err(ws_error(
514 StatusCode::UNAUTHORIZED,
515 "missing or invalid token",
516 ));
517 };
518 if let Ok(value) = matched.parse() {
519 response
520 .headers_mut()
521 .insert("sec-websocket-protocol", value);
522 }
523 *co.lock().unwrap_or_else(std::sync::PoisonError::into_inner) = origin;
524 Ok(response)
525 };
526
527 let ws_stream = match tokio_tungstenite::accept_hdr_async(stream, callback).await {
528 Ok(s) => s,
529 Err(e) => {
530 tracing::debug!("WebSocket handshake failed: {e}");
531 return;
532 }
533 };
534
535 let origin = captured_origin
536 .lock()
537 .unwrap_or_else(std::sync::PoisonError::into_inner)
538 .take();
539 let conn_id = state.conn_counter.fetch_add(1, Ordering::Relaxed);
540 tracing::info!(
541 "Browser connected (conn {conn_id}{})",
542 origin
543 .as_deref()
544 .map(|o| format!(", origin {o}"))
545 .unwrap_or_default()
546 );
547
548 let (tx, mut rx) = mpsc::unbounded_channel::<Message>();
549 state
550 .lock_tabs()
551 .insert(conn_id, WsConn { sender: tx, origin });
552
553 let (mut sink, mut read) = ws_stream.split();
554 let writer = tokio::spawn(async move {
555 while let Some(msg) = rx.recv().await {
556 if sink.send(msg).await.is_err() {
557 break;
558 }
559 }
560 });
561
562 while let Some(Ok(msg)) = read.next().await {
563 match msg {
564 Message::Text(txt) => match serde_json::from_str::<BrowserFrame>(&txt) {
565 Ok(frame) => {
566 if let Some(cancel_id) = state.correlator.deliver(frame) {
570 send_cancel(&state, conn_id, cancel_id).await;
571 }
572 }
573 Err(e) => tracing::debug!("Unparseable browser frame: {e}"),
574 },
575 Message::Close(_) => break,
576 _ => {}
577 }
578 }
579
580 writer.abort();
581 if state.lock_tabs().remove(&conn_id).is_some() {
584 tracing::info!("Browser disconnected (conn {conn_id})");
585 }
586}
587
588fn ws_error(
589 code: StatusCode,
590 msg: &str,
591) -> tokio_tungstenite::tungstenite::handshake::server::ErrorResponse {
592 let mut resp = tokio_tungstenite::tungstenite::http::Response::new(Some(msg.to_string()));
593 *resp.status_mut() = code;
594 resp
595}
596
597fn control_router(state: AppState, max_body_bytes: usize) -> Router {
600 Router::new()
601 .route("/__bridge/status", get(status_handler))
602 .route("/__bridge/request", post(request_handler))
603 .fallback(proxy_handler)
604 .layer(DefaultBodyLimit::max(max_body_bytes))
605 .layer(middleware::from_fn_with_state(state.clone(), guard))
606 .with_state(state)
607}
608
609async fn guard(State(state): State<AppState>, request: Request, next: Next) -> Response {
613 if request.method() == axum::http::Method::OPTIONS {
616 return (StatusCode::METHOD_NOT_ALLOWED, "OPTIONS not allowed").into_response();
617 }
618
619 let headers = request.headers();
620 let get = |name: &str| headers.get(name).and_then(|v| v.to_str().ok());
621
622 let host = get(header::HOST.as_str()).unwrap_or("");
623 if !auth::host_allowed(host, state.config.control_port) {
624 tracing::warn!("Rejected control-plane request: disallowed Host");
625 return (StatusCode::BAD_REQUEST, "host not allowed").into_response();
626 }
627
628 if auth::is_browser_originated(get("origin"), get("sec-fetch-site")) {
629 tracing::warn!("Rejected control-plane request: browser-originated");
630 return (
631 StatusCode::FORBIDDEN,
632 "browser-originated requests are denied",
633 )
634 .into_response();
635 }
636
637 if !auth::has_bridge_header(get(auth::BRIDGE_HEADER)) {
638 return (StatusCode::FORBIDDEN, "missing X-Omni-Bridge: 1").into_response();
639 }
640
641 if !auth::bearer_matches(get(header::AUTHORIZATION.as_str()), &state.token) {
642 return (StatusCode::UNAUTHORIZED, "invalid or missing bearer token").into_response();
643 }
644
645 next.run(request).await
646}
647
648async fn status_handler(State(state): State<AppState>) -> Json<StatusResponse> {
649 Json(state.status_snapshot())
650}
651
652async fn request_handler(
656 State(state): State<AppState>,
657 headers: axum::http::HeaderMap,
658 body: Bytes,
659) -> Response {
660 let mut req: ControlRequest = match serde_json::from_slice(&body) {
661 Ok(r) => r,
662 Err(e) => {
663 return (StatusCode::BAD_REQUEST, format!("invalid JSON body: {e}")).into_response()
664 }
665 };
666 if let Some(target) = target_header(&headers) {
668 req.target = Some(target);
669 }
670 if req.stream {
671 return match start_stream(&state, req).await {
672 Ok((status, headers, driver)) => ndjson_stream_response(status, headers, driver),
673 Err((code, msg)) => (code, msg).into_response(),
674 };
675 }
676 match dispatch(&state, req).await {
677 Ok(env) => Json(env).into_response(),
678 Err((code, msg)) => (code, msg).into_response(),
679 }
680}
681
682async fn proxy_handler(State(state): State<AppState>, request: Request) -> Response {
684 let (parts, body) = request.into_parts();
685
686 let path = parts.uri.path();
687 if auth::normalize_request_path(path).is_none() {
688 return (StatusCode::BAD_REQUEST, "unsafe request path").into_response();
689 }
690 let (stream, forwarded_query) = extract_stream_flag(parts.uri.query());
693 let url = match forwarded_query.as_deref() {
694 Some(q) => format!("{path}?{q}"),
695 None => path.to_string(),
696 };
697
698 let headers = forwardable_headers(&parts.headers);
699
700 let Ok(body_bytes) = axum::body::to_bytes(body, state.config.max_body_bytes).await else {
701 return (StatusCode::PAYLOAD_TOO_LARGE, "request body too large").into_response();
702 };
703 let body = if body_bytes.is_empty() {
704 None
705 } else {
706 Some(String::from_utf8_lossy(&body_bytes).into_owned())
707 };
708
709 let req = ControlRequest {
710 url,
711 method: parts.method.to_string(),
712 headers,
713 body,
714 stream,
715 target: target_header(&parts.headers),
716 allow_origin: None,
719 credentials: None,
722 };
723
724 if stream {
725 return match start_stream(&state, req).await {
726 Ok((status, headers, driver)) => raw_stream_response(status, headers, driver),
727 Err((code, msg)) => (code, msg).into_response(),
728 };
729 }
730
731 match dispatch(&state, req).await {
732 Ok(env) => envelope_to_response(env),
733 Err((code, msg)) => (code, msg).into_response(),
734 }
735}
736
737fn extract_stream_flag(query: Option<&str>) -> (bool, Option<String>) {
743 let Some(query) = query else {
744 return (false, None);
745 };
746 let mut stream = false;
747 let kept: Vec<&str> = query
748 .split('&')
749 .filter(|kv| {
750 let (key, value) = match kv.split_once('=') {
751 Some((k, v)) => (k, Some(v)),
752 None => (*kv, None),
753 };
754 if key == "__stream" {
755 stream = !matches!(value, Some("0" | "false"));
756 false
757 } else {
758 true
759 }
760 })
761 .collect();
762 let rebuilt = (!kept.is_empty()).then(|| kept.join("&"));
763 (stream, rebuilt)
764}
765
766fn forwardable_headers(headers: &axum::http::HeaderMap) -> BTreeMap<String, String> {
769 const DROP: &[&str] = &[
770 "host",
771 "authorization",
772 auth::BRIDGE_HEADER,
773 auth::BRIDGE_TARGET_HEADER,
774 "content-length",
775 "connection",
776 "accept-encoding",
777 "origin",
778 "sec-fetch-site",
779 "sec-fetch-mode",
780 "sec-fetch-dest",
781 ];
782 headers
783 .iter()
784 .filter_map(|(k, v)| {
785 let name = k.as_str();
786 if DROP.contains(&name) {
787 return None;
788 }
789 v.to_str()
790 .ok()
791 .map(|val| (name.to_string(), val.to_string()))
792 })
793 .collect()
794}
795
796fn target_header(headers: &axum::http::HeaderMap) -> Option<String> {
799 headers
800 .get(auth::BRIDGE_TARGET_HEADER)
801 .and_then(|v| v.to_str().ok())
802 .map(str::trim)
803 .filter(|s| !s.is_empty())
804 .map(str::to_string)
805}
806
807fn resolve_target(
814 tabs: &HashMap<u64, WsConn>,
815 target: Option<&str>,
816) -> Result<(u64, mpsc::UnboundedSender<Message>), (StatusCode, String)> {
817 if tabs.is_empty() {
818 return Err((
819 StatusCode::SERVICE_UNAVAILABLE,
820 "no browser connected".to_string(),
821 ));
822 }
823 let Some(sel) = target else {
824 let mut it = tabs.iter();
826 return match (it.next(), it.next()) {
827 (Some((id, conn)), None) => Ok((*id, conn.sender.clone())),
828 _ => Err((
829 StatusCode::CONFLICT,
830 format!(
831 "multiple tabs connected; select one with the X-Omni-Bridge-Target \
832 header or a `target` field ({})",
833 tab_list(tabs)
834 ),
835 )),
836 };
837 };
838
839 if let Ok(id) = sel.parse::<u64>() {
841 return match tabs.get(&id) {
842 Some(conn) => Ok((id, conn.sender.clone())),
843 None => Err((
844 StatusCode::NOT_FOUND,
845 format!(
846 "no connected tab with id {id}; connected: {}",
847 tab_list(tabs)
848 ),
849 )),
850 };
851 }
852
853 let mut hits = tabs
855 .iter()
856 .filter(|(_, c)| c.origin.as_deref() == Some(sel));
857 match (hits.next(), hits.next()) {
858 (Some((id, conn)), None) => Ok((*id, conn.sender.clone())),
859 (None, _) => Err((
860 StatusCode::NOT_FOUND,
861 format!(
862 "no connected tab with origin {sel}; connected: {}",
863 tab_list(tabs)
864 ),
865 )),
866 (Some(_), Some(_)) => Err((
867 StatusCode::CONFLICT,
868 format!(
869 "origin {sel} matches multiple tabs; target by connection id ({})",
870 tab_list(tabs)
871 ),
872 )),
873 }
874}
875
876fn tab_list(tabs: &HashMap<u64, WsConn>) -> String {
879 let mut items: Vec<(u64, Option<&str>)> = tabs
880 .iter()
881 .map(|(id, c)| (*id, c.origin.as_deref()))
882 .collect();
883 items.sort_by_key(|(id, _)| *id);
884 items
885 .iter()
886 .map(|(id, origin)| match origin {
887 Some(o) => format!("id {id}: {o}"),
888 None => format!("id {id}"),
889 })
890 .collect::<Vec<_>>()
891 .join(", ")
892}
893
894fn resolve_allow_origin<'a>(req: &'a ControlRequest, state: &'a AppState) -> Option<&'a str> {
905 match req.allow_origin.as_deref() {
906 Some(origin) => {
907 tracing::warn!(
908 "Per-request --allow-origin override in effect; outbound scope for this request \
909 widened to {origin}"
910 );
911 Some(origin)
912 }
913 None => state.config.allow_origin.as_deref(),
914 }
915}
916
917fn record_bridge_http(
922 method: &str,
923 url: &str,
924 started: Instant,
925 status: Option<u16>,
926 error: Option<&str>,
927) {
928 let via_daemon = matches!(
929 request_log::current_context().source,
930 request_log::Source::Daemon
931 );
932 request_log::record_http_with(
933 "browser-bridge",
934 method,
935 url,
936 started,
937 status,
938 error,
939 request_log::HttpExtra {
940 via_daemon,
941 ..Default::default()
942 },
943 );
944}
945
946async fn dispatch(
947 state: &AppState,
948 req: ControlRequest,
949) -> Result<ResponseEnvelope, (StatusCode, String)> {
950 let started = Instant::now();
951 auth::validate_outbound_url(&req.url, resolve_allow_origin(&req, state)).map_err(|_| {
952 (
953 StatusCode::FORBIDDEN,
954 "outbound URL is cross-origin; pass --allow-origin to permit it".to_string(),
955 )
956 })?;
957
958 for (name, value) in &req.headers {
959 if !auth::header_is_safe(name, value) {
960 return Err((
961 StatusCode::BAD_REQUEST,
962 "invalid header name or value".to_string(),
963 ));
964 }
965 }
966
967 let _permit = state.in_flight.clone().try_acquire_owned().map_err(|_| {
968 (
969 StatusCode::TOO_MANY_REQUESTS,
970 "too many in-flight requests".to_string(),
971 )
972 })?;
973
974 let log_method = req.method.clone();
976 let log_url = req.url.clone();
977 let (id, rx) = state.correlator.register();
978 let command = Command {
979 id,
980 url: req.url,
981 method: req.method,
982 headers: req.headers,
983 body: req.body,
984 stream: false,
985 credentials: req.credentials,
986 };
987 let frame = match serde_json::to_string(&command) {
988 Ok(f) => f,
989 Err(e) => {
990 state.correlator.remove(id);
991 return Err((
992 StatusCode::INTERNAL_SERVER_ERROR,
993 format!("serialise error: {e}"),
994 ));
995 }
996 };
997
998 {
999 let tabs = state.lock_tabs();
1000 let (_conn_id, sender) = match resolve_target(&tabs, req.target.as_deref()) {
1001 Ok(t) => t,
1002 Err(e) => {
1003 state.correlator.remove(id);
1004 return Err(e);
1005 }
1006 };
1007 if sender.send(Message::Text(frame.into())).is_err() {
1008 state.correlator.remove(id);
1009 return Err((
1010 StatusCode::SERVICE_UNAVAILABLE,
1011 "no browser connected".to_string(),
1012 ));
1013 }
1014 }
1015
1016 match tokio::time::timeout(state.config.request_timeout, rx).await {
1017 Ok(Ok(reply)) => match reply.outcome() {
1018 ReplyOutcome::Success {
1019 status,
1020 headers,
1021 body,
1022 encoding,
1023 } => {
1024 record_bridge_http(&log_method, &log_url, started, Some(status), None);
1025 let decoded_len = match encoding.as_deref() {
1029 None => body.len(),
1030 Some("base64") => match BASE64.decode(body.as_bytes()) {
1031 Ok(bytes) => bytes.len(),
1032 Err(_) => {
1033 return Err((
1034 StatusCode::BAD_GATEWAY,
1035 "browser sent an invalid base64 body".to_string(),
1036 ))
1037 }
1038 },
1039 Some(other) => {
1040 return Err((
1041 StatusCode::BAD_GATEWAY,
1042 format!("browser sent an unsupported body encoding: {other}"),
1043 ))
1044 }
1045 };
1046 if decoded_len > state.config.max_body_bytes {
1047 return Err((
1048 StatusCode::BAD_GATEWAY,
1049 format!(
1050 "browser response body is {decoded_len} bytes, exceeding the \
1051 --max-body-bytes limit of {} bytes; page the request to fetch \
1052 less per call (e.g. narrow the time range or lower a `limit`/page \
1053 size) or raise --max-body-bytes",
1054 state.config.max_body_bytes
1055 ),
1056 ));
1057 }
1058 Ok(ResponseEnvelope {
1059 id,
1060 status,
1061 headers,
1062 body,
1063 encoding,
1064 })
1065 }
1066 ReplyOutcome::Error(msg) => {
1067 record_bridge_http(&log_method, &log_url, started, None, Some(&msg));
1068 Err((
1069 StatusCode::BAD_GATEWAY,
1070 format!("browser fetch failed: {msg}"),
1071 ))
1072 }
1073 },
1074 Ok(Err(_)) => {
1075 record_bridge_http(
1076 &log_method,
1077 &log_url,
1078 started,
1079 None,
1080 Some("browser connection closed before replying"),
1081 );
1082 Err((
1083 StatusCode::BAD_GATEWAY,
1084 "browser connection closed before replying".to_string(),
1085 ))
1086 }
1087 Err(_) => {
1088 state.correlator.remove(id);
1089 record_bridge_http(
1090 &log_method,
1091 &log_url,
1092 started,
1093 None,
1094 Some("browser did not reply in time"),
1095 );
1096 Err((
1097 StatusCode::GATEWAY_TIMEOUT,
1098 "browser did not reply in time".to_string(),
1099 ))
1100 }
1101 }
1102}
1103
1104async fn send_cancel(state: &AppState, conn_id: u64, id: u64) {
1109 state.correlator.remove(id);
1110 let Ok(frame) = serde_json::to_string(&CancelCommand::new(id)) else {
1111 return;
1112 };
1113 let tabs = state.lock_tabs();
1114 if let Some(conn) = tabs.get(&conn_id) {
1115 let _ = conn.sender.send(Message::Text(frame.into()));
1116 }
1117}
1118
1119async fn start_stream(
1125 state: &AppState,
1126 req: ControlRequest,
1127) -> Result<(u16, BTreeMap<String, String>, StreamDriver), (StatusCode, String)> {
1128 let started = Instant::now();
1129 auth::validate_outbound_url(&req.url, resolve_allow_origin(&req, state)).map_err(|_| {
1130 (
1131 StatusCode::FORBIDDEN,
1132 "outbound URL is cross-origin; pass --allow-origin to permit it".to_string(),
1133 )
1134 })?;
1135
1136 for (name, value) in &req.headers {
1137 if !auth::header_is_safe(name, value) {
1138 return Err((
1139 StatusCode::BAD_REQUEST,
1140 "invalid header name or value".to_string(),
1141 ));
1142 }
1143 }
1144
1145 let permit = state.in_flight.clone().try_acquire_owned().map_err(|_| {
1146 (
1147 StatusCode::TOO_MANY_REQUESTS,
1148 "too many in-flight requests".to_string(),
1149 )
1150 })?;
1151
1152 let log_method = req.method.clone();
1154 let log_url = req.url.clone();
1155 let (id, mut rx) = state.correlator.register_stream();
1156 let command = Command {
1157 id,
1158 url: req.url,
1159 method: req.method,
1160 headers: req.headers,
1161 body: req.body,
1162 stream: true,
1163 credentials: req.credentials,
1164 };
1165 let frame = match serde_json::to_string(&command) {
1166 Ok(f) => f,
1167 Err(e) => {
1168 state.correlator.remove(id);
1169 return Err((
1170 StatusCode::INTERNAL_SERVER_ERROR,
1171 format!("serialise error: {e}"),
1172 ));
1173 }
1174 };
1175
1176 let conn_id = {
1177 let tabs = state.lock_tabs();
1178 let (conn_id, sender) = match resolve_target(&tabs, req.target.as_deref()) {
1179 Ok(t) => t,
1180 Err(e) => {
1181 state.correlator.remove(id);
1182 return Err(e);
1183 }
1184 };
1185 if sender.send(Message::Text(frame.into())).is_err() {
1186 state.correlator.remove(id);
1187 return Err((
1188 StatusCode::SERVICE_UNAVAILABLE,
1189 "no browser connected".to_string(),
1190 ));
1191 }
1192 conn_id
1193 };
1194
1195 let idle = state.config.request_timeout;
1196 let (status, headers) = match tokio::time::timeout(idle, rx.recv()).await {
1197 Ok(Some(StreamItem::Head { status, headers })) => {
1198 record_bridge_http(&log_method, &log_url, started, Some(status), None);
1199 (status, headers)
1200 }
1201 Ok(Some(StreamItem::Error(msg))) => {
1202 state.correlator.remove(id);
1203 record_bridge_http(&log_method, &log_url, started, None, Some(&msg));
1204 return Err((
1205 StatusCode::BAD_GATEWAY,
1206 format!("browser fetch failed: {msg}"),
1207 ));
1208 }
1209 Ok(Some(_)) => {
1210 state.correlator.remove(id);
1211 record_bridge_http(
1212 &log_method,
1213 &log_url,
1214 started,
1215 None,
1216 Some("browser streamed a body chunk before the response head"),
1217 );
1218 return Err((
1219 StatusCode::BAD_GATEWAY,
1220 "browser streamed a body chunk before the response head".to_string(),
1221 ));
1222 }
1223 Ok(None) => {
1224 record_bridge_http(
1225 &log_method,
1226 &log_url,
1227 started,
1228 None,
1229 Some("browser connection closed before replying"),
1230 );
1231 return Err((
1232 StatusCode::BAD_GATEWAY,
1233 "browser connection closed before replying".to_string(),
1234 ));
1235 }
1236 Err(_) => {
1237 send_cancel(state, conn_id, id).await;
1238 record_bridge_http(
1239 &log_method,
1240 &log_url,
1241 started,
1242 None,
1243 Some("browser did not start streaming in time"),
1244 );
1245 return Err((
1246 StatusCode::GATEWAY_TIMEOUT,
1247 "browser did not start streaming in time".to_string(),
1248 ));
1249 }
1250 };
1251
1252 let driver = StreamDriver {
1253 state: state.clone(),
1254 id,
1255 conn_id,
1256 rx,
1257 idle,
1258 max_body: state.config.max_body_bytes,
1259 sent: 0,
1260 _permit: permit,
1261 done: false,
1262 };
1263 Ok((status, headers, driver))
1264}
1265
1266struct StreamDriver {
1271 state: AppState,
1272 id: u64,
1273 conn_id: u64,
1275 rx: mpsc::UnboundedReceiver<StreamItem>,
1276 idle: Duration,
1277 max_body: usize,
1278 sent: usize,
1279 _permit: OwnedSemaphorePermit,
1280 done: bool,
1281}
1282
1283enum NextChunk {
1285 Data {
1287 seq: u64,
1289 bytes: Vec<u8>,
1291 },
1292 End,
1294}
1295
1296impl StreamDriver {
1297 async fn next_chunk(&mut self) -> NextChunk {
1300 if self.done {
1301 return NextChunk::End;
1302 }
1303 loop {
1304 match tokio::time::timeout(self.idle, self.rx.recv()).await {
1305 Ok(Some(StreamItem::Chunk { seq, data })) => {
1306 let Ok(bytes) = BASE64.decode(data.as_bytes()) else {
1307 return self.abort().await;
1308 };
1309 self.sent = self.sent.saturating_add(bytes.len());
1310 if self.sent > self.max_body {
1311 return self.abort().await;
1312 }
1313 return NextChunk::Data { seq, bytes };
1314 }
1315 Ok(Some(StreamItem::Head { .. })) => {}
1317 Ok(Some(StreamItem::End | StreamItem::Error(_)) | None) => {
1318 return self.finish();
1319 }
1320 Err(_) => return self.abort().await,
1322 }
1323 }
1324 }
1325
1326 fn finish(&mut self) -> NextChunk {
1329 self.done = true;
1330 self.state.correlator.remove(self.id);
1331 NextChunk::End
1332 }
1333
1334 async fn abort(&mut self) -> NextChunk {
1337 self.done = true;
1338 send_cancel(&self.state, self.conn_id, self.id).await;
1339 NextChunk::End
1340 }
1341}
1342
1343fn to_ndjson_line(line: &StreamLine) -> String {
1345 let mut s = serde_json::to_string(line).unwrap_or_else(|_| "{}".to_string());
1346 s.push('\n');
1347 s
1348}
1349
1350fn raw_stream_response(
1354 status: u16,
1355 headers: BTreeMap<String, String>,
1356 driver: StreamDriver,
1357) -> Response {
1358 let code = StatusCode::from_u16(status).unwrap_or(StatusCode::BAD_GATEWAY);
1359 let mut builder = Response::builder().status(code);
1360 if let Some(ct) = headers.get("content-type") {
1361 builder = builder.header(header::CONTENT_TYPE, ct);
1362 }
1363 let stream = futures::stream::unfold(driver, |mut driver| async move {
1364 match driver.next_chunk().await {
1365 NextChunk::Data { bytes, .. } => Some((
1366 Ok::<_, std::convert::Infallible>(Bytes::from(bytes)),
1367 driver,
1368 )),
1369 NextChunk::End => None,
1370 }
1371 });
1372 builder
1373 .body(Body::from_stream(stream))
1374 .unwrap_or_else(|_| StatusCode::BAD_GATEWAY.into_response())
1375}
1376
1377fn ndjson_stream_response(
1380 status: u16,
1381 headers: BTreeMap<String, String>,
1382 driver: StreamDriver,
1383) -> Response {
1384 let head_line = to_ndjson_line(&StreamLine::Head { status, headers });
1385 let init = (Some(head_line), driver, false);
1387 let stream = futures::stream::unfold(init, |(head, mut driver, done_emitted)| async move {
1388 if let Some(line) = head {
1389 return Some((
1390 Ok::<_, std::convert::Infallible>(Bytes::from(line)),
1391 (None, driver, done_emitted),
1392 ));
1393 }
1394 if done_emitted {
1395 return None;
1396 }
1397 match driver.next_chunk().await {
1398 NextChunk::Data { seq, bytes } => {
1399 let line = to_ndjson_line(&StreamLine::Chunk {
1400 seq,
1401 chunk: BASE64.encode(&bytes),
1402 });
1403 Some((Ok(Bytes::from(line)), (None, driver, false)))
1404 }
1405 NextChunk::End => {
1406 let line = to_ndjson_line(&StreamLine::Done { done: true });
1407 Some((Ok(Bytes::from(line)), (None, driver, true)))
1408 }
1409 }
1410 });
1411 Response::builder()
1412 .status(StatusCode::OK)
1413 .header(header::CONTENT_TYPE, "application/x-ndjson")
1414 .body(Body::from_stream(stream))
1415 .unwrap_or_else(|_| StatusCode::BAD_GATEWAY.into_response())
1416}
1417
1418fn envelope_to_response(env: ResponseEnvelope) -> Response {
1424 let status = StatusCode::from_u16(env.status).unwrap_or(StatusCode::BAD_GATEWAY);
1425 let mut builder = Response::builder().status(status);
1426 if let Some(ct) = env.headers.get("content-type") {
1427 builder = builder.header(header::CONTENT_TYPE, ct);
1428 }
1429 let body = match env.encoding.as_deref() {
1430 Some("base64") => match BASE64.decode(env.body.as_bytes()) {
1431 Ok(bytes) => Body::from(bytes),
1432 Err(_) => return StatusCode::BAD_GATEWAY.into_response(),
1433 },
1434 _ => Body::from(env.body),
1435 };
1436 builder
1437 .body(body)
1438 .unwrap_or_else(|_| StatusCode::BAD_GATEWAY.into_response())
1439}
1440
1441#[cfg(test)]
1442#[allow(clippy::unwrap_used, clippy::expect_used)]
1443mod tests {
1444 use super::*;
1445
1446 fn buffered_frame(id: u64) -> BrowserFrame {
1447 BrowserFrame {
1448 id,
1449 status: Some(200),
1450 headers: None,
1451 body: Some("ok".into()),
1452 encoding: None,
1453 error: None,
1454 stream: None,
1455 chunk: None,
1456 seq: None,
1457 done: None,
1458 }
1459 }
1460
1461 fn tab(origin: Option<&str>) -> WsConn {
1464 let (sender, _rx) = mpsc::unbounded_channel();
1465 WsConn {
1466 sender,
1467 origin: origin.map(str::to_string),
1468 }
1469 }
1470
1471 #[test]
1472 fn resolve_target_no_tabs_is_503() {
1473 let tabs = HashMap::new();
1474 let err = resolve_target(&tabs, None).unwrap_err();
1475 assert_eq!(err.0, StatusCode::SERVICE_UNAVAILABLE);
1476 }
1477
1478 #[test]
1479 fn resolve_target_single_tab_routes_without_target() {
1480 let mut tabs = HashMap::new();
1481 tabs.insert(1, tab(Some("https://a.test")));
1482 let (id, _s) = resolve_target(&tabs, None).unwrap();
1483 assert_eq!(id, 1);
1484 }
1485
1486 #[test]
1487 fn resolve_target_multiple_tabs_no_target_is_409() {
1488 let mut tabs = HashMap::new();
1489 tabs.insert(1, tab(Some("https://a.test")));
1490 tabs.insert(2, tab(Some("https://b.test")));
1491 let err = resolve_target(&tabs, None).unwrap_err();
1492 assert_eq!(err.0, StatusCode::CONFLICT);
1493 assert!(err.1.contains("id 1") && err.1.contains("id 2"));
1495 }
1496
1497 #[test]
1498 fn resolve_target_by_connection_id() {
1499 let mut tabs = HashMap::new();
1500 tabs.insert(1, tab(Some("https://a.test")));
1501 tabs.insert(2, tab(Some("https://b.test")));
1502 let (id, _s) = resolve_target(&tabs, Some("2")).unwrap();
1503 assert_eq!(id, 2);
1504 assert_eq!(
1506 resolve_target(&tabs, Some("9")).unwrap_err().0,
1507 StatusCode::NOT_FOUND
1508 );
1509 }
1510
1511 #[test]
1512 fn resolve_target_by_unique_origin() {
1513 let mut tabs = HashMap::new();
1514 tabs.insert(1, tab(Some("https://a.test")));
1515 tabs.insert(2, tab(Some("https://b.test")));
1516 let (id, _s) = resolve_target(&tabs, Some("https://b.test")).unwrap();
1517 assert_eq!(id, 2);
1518 assert_eq!(
1520 resolve_target(&tabs, Some("https://nope.test"))
1521 .unwrap_err()
1522 .0,
1523 StatusCode::NOT_FOUND
1524 );
1525 }
1526
1527 #[test]
1528 fn resolve_target_ambiguous_origin_is_409() {
1529 let mut tabs = HashMap::new();
1530 tabs.insert(1, tab(Some("https://a.test")));
1531 tabs.insert(2, tab(Some("https://a.test")));
1532 let err = resolve_target(&tabs, Some("https://a.test")).unwrap_err();
1533 assert_eq!(err.0, StatusCode::CONFLICT);
1534 assert!(err.1.contains("connection id"));
1536 }
1537
1538 #[test]
1539 fn target_header_trims_and_drops_empty() {
1540 let mut h = axum::http::HeaderMap::new();
1541 assert_eq!(target_header(&h), None);
1542 h.insert(auth::BRIDGE_TARGET_HEADER, " 2 ".parse().unwrap());
1543 assert_eq!(target_header(&h).as_deref(), Some("2"));
1544 h.insert(auth::BRIDGE_TARGET_HEADER, " ".parse().unwrap());
1545 assert_eq!(target_header(&h), None);
1546 }
1547
1548 #[test]
1549 fn tab_list_renders_id_with_and_without_origin() {
1550 let mut tabs = HashMap::new();
1551 tabs.insert(1, tab(Some("https://a.test")));
1552 tabs.insert(2, tab(None));
1553 assert_eq!(tab_list(&tabs), "id 1: https://a.test, id 2");
1555 }
1556
1557 fn test_state() -> AppState {
1560 AppState {
1561 token: Arc::new("t".to_string()),
1562 config: Arc::new(BridgeConfig {
1563 ws_port: 0,
1564 control_port: 0,
1565 request_timeout: Duration::from_secs(5),
1566 allow_origin: None,
1567 max_body_bytes: 1024,
1568 max_concurrent: 8,
1569 }),
1570 correlator: Correlator::new(),
1571 tabs: Arc::new(StdMutex::new(HashMap::new())),
1572 in_flight: Arc::new(Semaphore::new(8)),
1573 conn_counter: Arc::new(AtomicU64::new(1)),
1574 }
1575 }
1576
1577 async fn insert_dead_tab(state: &AppState, id: u64) {
1580 let (sender, rx) = mpsc::unbounded_channel();
1581 drop(rx);
1582 state.lock_tabs().insert(
1583 id,
1584 WsConn {
1585 sender,
1586 origin: None,
1587 },
1588 );
1589 }
1590
1591 fn plain_request() -> ControlRequest {
1592 ControlRequest {
1593 url: "/x".to_string(),
1594 method: "GET".to_string(),
1595 headers: BTreeMap::new(),
1596 body: None,
1597 stream: false,
1598 target: None,
1599 allow_origin: None,
1600 credentials: None,
1601 }
1602 }
1603
1604 fn state_with_global_origin(global: Option<&str>) -> AppState {
1607 let mut state = test_state();
1608 let mut config = (*state.config).clone();
1609 config.allow_origin = global.map(str::to_string);
1610 state.config = Arc::new(config);
1611 state
1612 }
1613
1614 #[test]
1615 fn resolve_allow_origin_prefers_per_request_override() {
1616 let state = state_with_global_origin(Some("https://global.test"));
1617 let req = ControlRequest {
1618 allow_origin: Some("https://per-request.test".to_string()),
1619 ..plain_request()
1620 };
1621 assert_eq!(
1623 resolve_allow_origin(&req, &state),
1624 Some("https://per-request.test")
1625 );
1626 assert_eq!(
1629 auth::validate_outbound_url(
1630 "https://per-request.test/x",
1631 resolve_allow_origin(&req, &state)
1632 ),
1633 Ok(())
1634 );
1635 assert!(auth::validate_outbound_url(
1636 "https://other.test/x",
1637 resolve_allow_origin(&req, &state)
1638 )
1639 .is_err());
1640 }
1641
1642 #[test]
1643 fn resolve_allow_origin_falls_back_to_global() {
1644 let state = state_with_global_origin(Some("https://global.test"));
1645 let req = plain_request();
1646 assert!(req.allow_origin.is_none());
1647 assert_eq!(
1649 resolve_allow_origin(&req, &state),
1650 Some("https://global.test")
1651 );
1652 }
1653
1654 #[test]
1655 fn per_request_override_does_not_affect_ws_origin_gate() {
1656 let state = state_with_global_origin(None);
1660 let req = ControlRequest {
1661 allow_origin: Some("https://b.test".to_string()),
1662 ..plain_request()
1663 };
1664 assert_eq!(
1666 auth::validate_outbound_url("https://b.test/x", resolve_allow_origin(&req, &state)),
1667 Ok(())
1668 );
1669 assert!(auth::ws_origin_allowed(
1672 Some("https://a.test"),
1673 state.config.allow_origin.as_deref()
1674 ));
1675 }
1676
1677 #[tokio::test]
1678 async fn dispatch_returns_503_when_send_fails() {
1679 let state = test_state();
1680 insert_dead_tab(&state, 1).await;
1681 let err = dispatch(&state, plain_request()).await.unwrap_err();
1682 assert_eq!(err.0, StatusCode::SERVICE_UNAVAILABLE);
1683 assert_eq!(state.correlator.pending_count(), 0);
1685 }
1686
1687 #[tokio::test]
1688 async fn start_stream_returns_503_when_send_fails() {
1689 let state = test_state();
1690 insert_dead_tab(&state, 1).await;
1691 let req = ControlRequest {
1692 stream: true,
1693 ..plain_request()
1694 };
1695 let err = start_stream(&state, req).await.err().map(|e| e.0);
1696 assert_eq!(err, Some(StatusCode::SERVICE_UNAVAILABLE));
1697 assert_eq!(state.correlator.pending_count(), 0);
1698 }
1699
1700 #[test]
1701 fn correlator_register_resolve_round_trip() {
1702 let c = Correlator::new();
1703 let (id, rx) = c.register();
1704 assert_eq!(c.pending_count(), 1);
1705 assert_eq!(c.deliver(buffered_frame(id)), None);
1706 assert_eq!(c.pending_count(), 0);
1707 let reply = rx.now_or_never().unwrap().unwrap();
1708 assert_eq!(reply.id, id);
1709 }
1710
1711 #[test]
1712 fn correlator_stream_forwards_items_until_terminal() {
1713 let c = Correlator::new();
1714 let (id, mut rx) = c.register_stream();
1715 assert_eq!(c.pending_count(), 1);
1716
1717 let mut head = buffered_frame(id);
1718 head.stream = Some(true);
1719 head.body = None;
1720 assert_eq!(c.deliver(head), None);
1721 assert!(matches!(
1722 rx.try_recv(),
1723 Ok(StreamItem::Head { status: 200, .. })
1724 ));
1725 assert_eq!(c.pending_count(), 1);
1727
1728 let mut done = BrowserFrame {
1729 done: Some(true),
1730 ..buffered_frame(id)
1731 };
1732 done.body = None;
1733 assert_eq!(c.deliver(done), None);
1734 assert!(matches!(rx.try_recv(), Ok(StreamItem::End)));
1735 assert_eq!(c.pending_count(), 0);
1736 }
1737
1738 #[test]
1739 fn correlator_deliver_unknown_id_is_noop() {
1740 let c = Correlator::new();
1741 assert_eq!(c.deliver(buffered_frame(999)), None);
1744 assert_eq!(c.pending_count(), 0);
1745 }
1746
1747 #[test]
1748 fn correlator_stream_signals_cancel_when_consumer_gone() {
1749 let c = Correlator::new();
1750 let (id, rx) = c.register_stream();
1751 drop(rx); let mut chunk = buffered_frame(id);
1753 chunk.chunk = Some("aGk=".into());
1754 chunk.body = None;
1755 assert_eq!(c.deliver(chunk), Some(id));
1757 assert_eq!(c.pending_count(), 0);
1758 }
1759
1760 #[test]
1761 fn correlator_ids_are_monotonic() {
1762 let c = Correlator::new();
1763 let (a, _ra) = c.register();
1764 let (b, _rb) = c.register();
1765 assert!(b > a);
1766 }
1767
1768 #[test]
1769 fn correlator_remove_drops_waiter() {
1770 let c = Correlator::new();
1771 let (id, _rx) = c.register();
1772 c.remove(id);
1773 assert_eq!(c.pending_count(), 0);
1774 }
1775
1776 #[test]
1777 fn extract_stream_flag_detects_and_strips_marker() {
1778 assert_eq!(extract_stream_flag(None), (false, None));
1779 assert_eq!(
1780 extract_stream_flag(Some("a=1&b=2")),
1781 (false, Some("a=1&b=2".to_string()))
1782 );
1783 assert_eq!(
1784 extract_stream_flag(Some("a=1&__stream=1&b=2")),
1785 (true, Some("a=1&b=2".to_string()))
1786 );
1787 assert_eq!(extract_stream_flag(Some("__stream")), (true, None));
1789 assert_eq!(extract_stream_flag(Some("__stream=0")), (false, None));
1791 }
1792
1793 #[test]
1794 fn forwardable_headers_drops_control_headers() {
1795 let mut h = axum::http::HeaderMap::new();
1796 h.insert("host", "localhost:9998".parse().unwrap());
1797 h.insert("authorization", "Bearer x".parse().unwrap());
1798 h.insert("x-omni-bridge", "1".parse().unwrap());
1799 h.insert("accept", "application/json".parse().unwrap());
1800 let out = forwardable_headers(&h);
1801 assert!(!out.contains_key("host"));
1802 assert!(!out.contains_key("authorization"));
1803 assert!(!out.contains_key("x-omni-bridge"));
1804 assert_eq!(
1805 out.get("accept").map(String::as_str),
1806 Some("application/json")
1807 );
1808 }
1809
1810 #[test]
1811 fn envelope_to_response_passes_text_body_through() {
1812 let env = ResponseEnvelope {
1813 id: 1,
1814 status: 200,
1815 headers: BTreeMap::new(),
1816 body: "hello".into(),
1817 encoding: None,
1818 };
1819 assert_eq!(envelope_to_response(env).status(), StatusCode::OK);
1820 }
1821
1822 #[test]
1823 fn envelope_to_response_rejects_invalid_base64() {
1824 let env = ResponseEnvelope {
1827 id: 1,
1828 status: 200,
1829 headers: BTreeMap::new(),
1830 body: "not valid base64 @@@".into(),
1831 encoding: Some("base64".into()),
1832 };
1833 assert_eq!(envelope_to_response(env).status(), StatusCode::BAD_GATEWAY);
1834 }
1835
1836 use futures::FutureExt;
1837
1838 fn ephemeral_config() -> BridgeConfig {
1840 BridgeConfig {
1841 ws_port: 0,
1842 control_port: 0,
1843 request_timeout: Duration::from_secs(5),
1844 allow_origin: None,
1845 max_body_bytes: 1024,
1846 max_concurrent: 8,
1847 }
1848 }
1849
1850 #[tokio::test]
1851 async fn bridge_server_start_status_shutdown() {
1852 let server = BridgeServer::start(ephemeral_config(), "tok".to_string())
1853 .await
1854 .unwrap();
1855 assert_ne!(server.control_port(), 0);
1857 assert_ne!(server.ws_port(), 0);
1858 let status = server.status();
1860 assert!(!status.connected);
1861 assert!(status.tabs.is_empty());
1862 assert_eq!(status.pending, 0);
1863 server.shutdown().await;
1865 }
1866
1867 #[tokio::test]
1868 async fn disconnect_unknown_tab_is_error() {
1869 let server = BridgeServer::start(ephemeral_config(), "tok".to_string())
1870 .await
1871 .unwrap();
1872 let err = server.disconnect_tab(999).unwrap_err();
1873 assert!(err.to_string().contains("no connected tab"));
1874 server.shutdown().await;
1875 }
1876
1877 #[tokio::test]
1878 async fn start_fails_closed_on_taken_control_port() {
1879 let squatter = std::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).unwrap();
1881 let taken = squatter.local_addr().unwrap().port();
1882 let config = BridgeConfig {
1883 control_port: taken,
1884 ..ephemeral_config()
1885 };
1886 assert!(BridgeServer::start(config, "tok".to_string())
1887 .await
1888 .is_err());
1889 }
1890
1891 #[tokio::test]
1892 async fn rebind_same_fixed_port_after_close_succeeds() {
1893 let server = BridgeServer::start(ephemeral_config(), "tok".to_string())
1899 .await
1900 .unwrap();
1901 let control_port = server.control_port();
1902 let ws_port = server.ws_port();
1903
1904 let control_conn = TcpStream::connect((Ipv4Addr::LOCALHOST, control_port))
1908 .await
1909 .unwrap();
1910 let ws_conn = TcpStream::connect((Ipv4Addr::LOCALHOST, ws_port))
1911 .await
1912 .unwrap();
1913
1914 server.shutdown().await;
1915 drop(control_conn);
1916 drop(ws_conn);
1917 tokio::time::sleep(Duration::from_millis(50)).await;
1919
1920 let config = BridgeConfig {
1921 ws_port,
1922 control_port,
1923 ..ephemeral_config()
1924 };
1925 let server2 = BridgeServer::start(config, "tok".to_string())
1926 .await
1927 .expect("rebinding just-released fixed ports must succeed with SO_REUSEADDR");
1928 server2.shutdown().await;
1929 }
1930}