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_origins: auth::OriginAllowlist,
67 pub max_body_bytes: usize,
69 pub max_concurrent: usize,
71}
72
73impl Default for BridgeConfig {
74 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
88enum Waiter {
91 Buffered(oneshot::Sender<BrowserReply>),
93 Stream(mpsc::UnboundedSender<StreamItem>),
95}
96
97#[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 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 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 fn remove(&self, id: u64) {
131 self.lock().remove(&id);
132 }
133
134 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
177struct WsConn {
179 sender: mpsc::UnboundedSender<Message>,
181 origin: Option<String>,
183}
184
185#[derive(Clone)]
187struct AppState {
188 token: Arc<String>,
189 config: Arc<BridgeConfig>,
190 correlator: Correlator,
191 tabs: Arc<StdMutex<HashMap<u64, WsConn>>>,
199 in_flight: Arc<Semaphore>,
200 conn_counter: Arc<AtomicU64>,
201}
202
203impl AppState {
204 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 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 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
242pub struct BridgeServer {
251 state: AppState,
252 control_port: u16,
253 ws_port: u16,
254 shutdown: CancellationToken,
255 tasks: JoinSet<()>,
256}
257
258fn 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) }
274
275impl BridgeServer {
276 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 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 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 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 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 pub fn control_port(&self) -> u16 {
358 self.control_port
359 }
360
361 pub fn ws_port(&self) -> u16 {
363 self.ws_port
364 }
365
366 pub fn status(&self) -> StatusResponse {
369 self.state.status_snapshot()
370 }
371
372 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 pub async fn wait(self) -> Result<()> {
388 let _ = tokio::signal::ctrl_c().await;
389 self.shutdown().await;
390 Ok(())
391 }
392
393 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
412pub 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
425fn 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
447fn 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#[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 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 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
598fn 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
610async fn guard(State(state): State<AppState>, request: Request, next: Next) -> Response {
614 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
653async 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 if let Some(target) = target_header(&headers) {
669 req.target = Some(target);
670 }
671 if req.stream {
672 return match start_stream(&state, req).await {
673 Ok((status, headers, driver)) => ndjson_stream_response(status, headers, driver),
674 Err((code, msg)) => (code, msg).into_response(),
675 };
676 }
677 match dispatch(&state, req).await {
678 Ok(env) => Json(env).into_response(),
679 Err((code, msg)) => (code, msg).into_response(),
680 }
681}
682
683async fn proxy_handler(State(state): State<AppState>, request: Request) -> Response {
685 let (parts, body) = request.into_parts();
686
687 let path = parts.uri.path();
688 if auth::normalize_request_path(path).is_none() {
689 return (StatusCode::BAD_REQUEST, "unsafe request path").into_response();
690 }
691 let (stream, forwarded_query) = extract_stream_flag(parts.uri.query());
694 let url = match forwarded_query.as_deref() {
695 Some(q) => format!("{path}?{q}"),
696 None => path.to_string(),
697 };
698
699 let headers = forwardable_headers(&parts.headers);
700
701 let Ok(body_bytes) = axum::body::to_bytes(body, state.config.max_body_bytes).await else {
702 return (StatusCode::PAYLOAD_TOO_LARGE, "request body too large").into_response();
703 };
704 let body = if body_bytes.is_empty() {
705 None
706 } else {
707 Some(String::from_utf8_lossy(&body_bytes).into_owned())
708 };
709
710 let req = ControlRequest {
711 url,
712 method: parts.method.to_string(),
713 headers,
714 body,
715 stream,
716 target: target_header(&parts.headers),
717 allow_origin: None,
721 credentials: None,
724 encoding: None,
727 };
728
729 if stream {
730 return match start_stream(&state, req).await {
731 Ok((status, headers, driver)) => raw_stream_response(status, headers, driver),
732 Err((code, msg)) => (code, msg).into_response(),
733 };
734 }
735
736 match dispatch(&state, req).await {
737 Ok(env) => envelope_to_response(env),
738 Err((code, msg)) => (code, msg).into_response(),
739 }
740}
741
742fn extract_stream_flag(query: Option<&str>) -> (bool, Option<String>) {
748 let Some(query) = query else {
749 return (false, None);
750 };
751 let mut stream = false;
752 let kept: Vec<&str> = query
753 .split('&')
754 .filter(|kv| {
755 let (key, value) = match kv.split_once('=') {
756 Some((k, v)) => (k, Some(v)),
757 None => (*kv, None),
758 };
759 if key == "__stream" {
760 stream = !matches!(value, Some("0" | "false"));
761 false
762 } else {
763 true
764 }
765 })
766 .collect();
767 let rebuilt = (!kept.is_empty()).then(|| kept.join("&"));
768 (stream, rebuilt)
769}
770
771fn forwardable_headers(headers: &axum::http::HeaderMap) -> BTreeMap<String, String> {
774 const DROP: &[&str] = &[
775 "host",
776 "authorization",
777 auth::BRIDGE_HEADER,
778 auth::BRIDGE_TARGET_HEADER,
779 "content-length",
780 "connection",
781 "accept-encoding",
782 "origin",
783 "sec-fetch-site",
784 "sec-fetch-mode",
785 "sec-fetch-dest",
786 ];
787 headers
788 .iter()
789 .filter_map(|(k, v)| {
790 let name = k.as_str();
791 if DROP.contains(&name) {
792 return None;
793 }
794 v.to_str()
795 .ok()
796 .map(|val| (name.to_string(), val.to_string()))
797 })
798 .collect()
799}
800
801fn target_header(headers: &axum::http::HeaderMap) -> Option<String> {
804 headers
805 .get(auth::BRIDGE_TARGET_HEADER)
806 .and_then(|v| v.to_str().ok())
807 .map(str::trim)
808 .filter(|s| !s.is_empty())
809 .map(str::to_string)
810}
811
812fn resolve_target(
819 tabs: &HashMap<u64, WsConn>,
820 target: Option<&str>,
821) -> Result<(u64, mpsc::UnboundedSender<Message>), (StatusCode, String)> {
822 if tabs.is_empty() {
823 return Err((
824 StatusCode::SERVICE_UNAVAILABLE,
825 "no browser connected".to_string(),
826 ));
827 }
828 let Some(sel) = target else {
829 let mut it = tabs.iter();
831 return match (it.next(), it.next()) {
832 (Some((id, conn)), None) => Ok((*id, conn.sender.clone())),
833 _ => Err((
834 StatusCode::CONFLICT,
835 format!(
836 "multiple tabs connected; select one with the X-Omni-Bridge-Target \
837 header or a `target` field ({})",
838 tab_list(tabs)
839 ),
840 )),
841 };
842 };
843
844 if let Ok(id) = sel.parse::<u64>() {
846 return match tabs.get(&id) {
847 Some(conn) => Ok((id, conn.sender.clone())),
848 None => Err((
849 StatusCode::NOT_FOUND,
850 format!(
851 "no connected tab with id {id}; connected: {}",
852 tab_list(tabs)
853 ),
854 )),
855 };
856 }
857
858 let mut hits = tabs
860 .iter()
861 .filter(|(_, c)| c.origin.as_deref() == Some(sel));
862 match (hits.next(), hits.next()) {
863 (Some((id, conn)), None) => Ok((*id, conn.sender.clone())),
864 (None, _) => Err((
865 StatusCode::NOT_FOUND,
866 format!(
867 "no connected tab with origin {sel}; connected: {}",
868 tab_list(tabs)
869 ),
870 )),
871 (Some(_), Some(_)) => Err((
872 StatusCode::CONFLICT,
873 format!(
874 "origin {sel} matches multiple tabs; target by connection id ({})",
875 tab_list(tabs)
876 ),
877 )),
878 }
879}
880
881fn tab_list(tabs: &HashMap<u64, WsConn>) -> String {
884 let mut items: Vec<(u64, Option<&str>)> = tabs
885 .iter()
886 .map(|(id, c)| (*id, c.origin.as_deref()))
887 .collect();
888 items.sort_by_key(|(id, _)| *id);
889 items
890 .iter()
891 .map(|(id, origin)| match origin {
892 Some(o) => format!("id {id}: {o}"),
893 None => format!("id {id}"),
894 })
895 .collect::<Vec<_>>()
896 .join(", ")
897}
898
899fn resolve_outbound<'a>(
913 req: &'a ControlRequest,
914 state: &'a AppState,
915 target_origin: Option<&'a str>,
916) -> Vec<&'a str> {
917 match req.allow_origin.as_deref() {
918 Some(origin) => {
919 tracing::warn!(
920 "Per-request --allow-origin override in effect; outbound scope for this request \
921 widened to {origin}"
922 );
923 vec![origin]
924 }
925 None => state.config.allow_origins.outbound_for(target_origin),
926 }
927}
928
929fn record_bridge_http(
934 method: &str,
935 url: &str,
936 started: Instant,
937 status: Option<u16>,
938 error: Option<&str>,
939) {
940 let via_daemon = matches!(
941 request_log::current_context().source,
942 request_log::Source::Daemon
943 );
944 request_log::record_http_with(
945 "browser-bridge",
946 method,
947 url,
948 started,
949 status,
950 error,
951 request_log::HttpExtra {
952 via_daemon,
953 ..Default::default()
954 },
955 );
956}
957
958async fn dispatch(
959 state: &AppState,
960 req: ControlRequest,
961) -> Result<ResponseEnvelope, (StatusCode, String)> {
962 let started = Instant::now();
963
964 let (sender, target_origin) = {
968 let tabs = state.lock_tabs();
969 let (conn_id, sender) = resolve_target(&tabs, req.target.as_deref())?;
970 let origin = tabs.get(&conn_id).and_then(|c| c.origin.clone());
971 (sender, origin)
972 };
973
974 let allowed = resolve_outbound(&req, state, target_origin.as_deref());
975 auth::validate_outbound_url(&req.url, &allowed).map_err(|_| {
976 (
977 StatusCode::FORBIDDEN,
978 "outbound URL is cross-origin; pass --allow-origin to permit it".to_string(),
979 )
980 })?;
981
982 for (name, value) in &req.headers {
983 if !auth::header_is_safe(name, value) {
984 return Err((
985 StatusCode::BAD_REQUEST,
986 "invalid header name or value".to_string(),
987 ));
988 }
989 }
990
991 let _permit = state.in_flight.clone().try_acquire_owned().map_err(|_| {
992 (
993 StatusCode::TOO_MANY_REQUESTS,
994 "too many in-flight requests".to_string(),
995 )
996 })?;
997
998 let log_method = req.method.clone();
1000 let log_url = req.url.clone();
1001 let (id, rx) = state.correlator.register();
1002 let command = Command {
1003 id,
1004 url: req.url,
1005 method: req.method,
1006 headers: req.headers,
1007 body: req.body,
1008 stream: false,
1009 credentials: req.credentials,
1010 encoding: req.encoding,
1011 };
1012 let frame = match serde_json::to_string(&command) {
1013 Ok(f) => f,
1014 Err(e) => {
1015 state.correlator.remove(id);
1016 return Err((
1017 StatusCode::INTERNAL_SERVER_ERROR,
1018 format!("serialise error: {e}"),
1019 ));
1020 }
1021 };
1022
1023 if sender.send(Message::Text(frame.into())).is_err() {
1024 state.correlator.remove(id);
1025 return Err((
1026 StatusCode::SERVICE_UNAVAILABLE,
1027 "no browser connected".to_string(),
1028 ));
1029 }
1030
1031 match tokio::time::timeout(state.config.request_timeout, rx).await {
1032 Ok(Ok(reply)) => match reply.outcome() {
1033 ReplyOutcome::Success {
1034 status,
1035 headers,
1036 body,
1037 encoding,
1038 } => {
1039 record_bridge_http(&log_method, &log_url, started, Some(status), None);
1040 let decoded_len = match encoding.as_deref() {
1044 None => body.len(),
1045 Some("base64") => match BASE64.decode(body.as_bytes()) {
1046 Ok(bytes) => bytes.len(),
1047 Err(_) => {
1048 return Err((
1049 StatusCode::BAD_GATEWAY,
1050 "browser sent an invalid base64 body".to_string(),
1051 ))
1052 }
1053 },
1054 Some(other) => {
1055 return Err((
1056 StatusCode::BAD_GATEWAY,
1057 format!("browser sent an unsupported body encoding: {other}"),
1058 ))
1059 }
1060 };
1061 if decoded_len > state.config.max_body_bytes {
1062 return Err((
1063 StatusCode::BAD_GATEWAY,
1064 format!(
1065 "browser response body is {decoded_len} bytes, exceeding the \
1066 --max-body-bytes limit of {} bytes; page the request to fetch \
1067 less per call (e.g. narrow the time range or lower a `limit`/page \
1068 size) or raise --max-body-bytes",
1069 state.config.max_body_bytes
1070 ),
1071 ));
1072 }
1073 Ok(ResponseEnvelope {
1074 id,
1075 status,
1076 headers,
1077 body,
1078 encoding,
1079 })
1080 }
1081 ReplyOutcome::Error(msg) => {
1082 record_bridge_http(&log_method, &log_url, started, None, Some(&msg));
1083 Err((
1084 StatusCode::BAD_GATEWAY,
1085 format!("browser fetch failed: {msg}"),
1086 ))
1087 }
1088 },
1089 Ok(Err(_)) => {
1090 record_bridge_http(
1091 &log_method,
1092 &log_url,
1093 started,
1094 None,
1095 Some("browser connection closed before replying"),
1096 );
1097 Err((
1098 StatusCode::BAD_GATEWAY,
1099 "browser connection closed before replying".to_string(),
1100 ))
1101 }
1102 Err(_) => {
1103 state.correlator.remove(id);
1104 record_bridge_http(
1105 &log_method,
1106 &log_url,
1107 started,
1108 None,
1109 Some("browser did not reply in time"),
1110 );
1111 Err((
1112 StatusCode::GATEWAY_TIMEOUT,
1113 "browser did not reply in time".to_string(),
1114 ))
1115 }
1116 }
1117}
1118
1119async fn send_cancel(state: &AppState, conn_id: u64, id: u64) {
1124 state.correlator.remove(id);
1125 let Ok(frame) = serde_json::to_string(&CancelCommand::new(id)) else {
1126 return;
1127 };
1128 let tabs = state.lock_tabs();
1129 if let Some(conn) = tabs.get(&conn_id) {
1130 let _ = conn.sender.send(Message::Text(frame.into()));
1131 }
1132}
1133
1134async fn start_stream(
1140 state: &AppState,
1141 req: ControlRequest,
1142) -> Result<(u16, BTreeMap<String, String>, StreamDriver), (StatusCode, String)> {
1143 let started = Instant::now();
1144
1145 let (conn_id, sender, target_origin) = {
1150 let tabs = state.lock_tabs();
1151 let (conn_id, sender) = resolve_target(&tabs, req.target.as_deref())?;
1152 let origin = tabs.get(&conn_id).and_then(|c| c.origin.clone());
1153 (conn_id, sender, origin)
1154 };
1155
1156 let allowed = resolve_outbound(&req, state, target_origin.as_deref());
1157 auth::validate_outbound_url(&req.url, &allowed).map_err(|_| {
1158 (
1159 StatusCode::FORBIDDEN,
1160 "outbound URL is cross-origin; pass --allow-origin to permit it".to_string(),
1161 )
1162 })?;
1163
1164 for (name, value) in &req.headers {
1165 if !auth::header_is_safe(name, value) {
1166 return Err((
1167 StatusCode::BAD_REQUEST,
1168 "invalid header name or value".to_string(),
1169 ));
1170 }
1171 }
1172
1173 let permit = state.in_flight.clone().try_acquire_owned().map_err(|_| {
1174 (
1175 StatusCode::TOO_MANY_REQUESTS,
1176 "too many in-flight requests".to_string(),
1177 )
1178 })?;
1179
1180 let log_method = req.method.clone();
1182 let log_url = req.url.clone();
1183 let (id, mut rx) = state.correlator.register_stream();
1184 let command = Command {
1185 id,
1186 url: req.url,
1187 method: req.method,
1188 headers: req.headers,
1189 body: req.body,
1190 stream: true,
1191 credentials: req.credentials,
1192 encoding: req.encoding,
1193 };
1194 let frame = match serde_json::to_string(&command) {
1195 Ok(f) => f,
1196 Err(e) => {
1197 state.correlator.remove(id);
1198 return Err((
1199 StatusCode::INTERNAL_SERVER_ERROR,
1200 format!("serialise error: {e}"),
1201 ));
1202 }
1203 };
1204
1205 if sender.send(Message::Text(frame.into())).is_err() {
1206 state.correlator.remove(id);
1207 return Err((
1208 StatusCode::SERVICE_UNAVAILABLE,
1209 "no browser connected".to_string(),
1210 ));
1211 }
1212
1213 let idle = state.config.request_timeout;
1214 let (status, headers) = match tokio::time::timeout(idle, rx.recv()).await {
1215 Ok(Some(StreamItem::Head { status, headers })) => {
1216 record_bridge_http(&log_method, &log_url, started, Some(status), None);
1217 (status, headers)
1218 }
1219 Ok(Some(StreamItem::Error(msg))) => {
1220 state.correlator.remove(id);
1221 record_bridge_http(&log_method, &log_url, started, None, Some(&msg));
1222 return Err((
1223 StatusCode::BAD_GATEWAY,
1224 format!("browser fetch failed: {msg}"),
1225 ));
1226 }
1227 Ok(Some(_)) => {
1228 state.correlator.remove(id);
1229 record_bridge_http(
1230 &log_method,
1231 &log_url,
1232 started,
1233 None,
1234 Some("browser streamed a body chunk before the response head"),
1235 );
1236 return Err((
1237 StatusCode::BAD_GATEWAY,
1238 "browser streamed a body chunk before the response head".to_string(),
1239 ));
1240 }
1241 Ok(None) => {
1242 record_bridge_http(
1243 &log_method,
1244 &log_url,
1245 started,
1246 None,
1247 Some("browser connection closed before replying"),
1248 );
1249 return Err((
1250 StatusCode::BAD_GATEWAY,
1251 "browser connection closed before replying".to_string(),
1252 ));
1253 }
1254 Err(_) => {
1255 send_cancel(state, conn_id, id).await;
1256 record_bridge_http(
1257 &log_method,
1258 &log_url,
1259 started,
1260 None,
1261 Some("browser did not start streaming in time"),
1262 );
1263 return Err((
1264 StatusCode::GATEWAY_TIMEOUT,
1265 "browser did not start streaming in time".to_string(),
1266 ));
1267 }
1268 };
1269
1270 let driver = StreamDriver {
1271 state: state.clone(),
1272 id,
1273 conn_id,
1274 rx,
1275 idle,
1276 max_body: state.config.max_body_bytes,
1277 sent: 0,
1278 _permit: permit,
1279 done: false,
1280 };
1281 Ok((status, headers, driver))
1282}
1283
1284struct StreamDriver {
1289 state: AppState,
1290 id: u64,
1291 conn_id: u64,
1293 rx: mpsc::UnboundedReceiver<StreamItem>,
1294 idle: Duration,
1295 max_body: usize,
1296 sent: usize,
1297 _permit: OwnedSemaphorePermit,
1298 done: bool,
1299}
1300
1301enum NextChunk {
1303 Data {
1305 seq: u64,
1307 bytes: Vec<u8>,
1309 },
1310 End,
1312}
1313
1314impl StreamDriver {
1315 async fn next_chunk(&mut self) -> NextChunk {
1318 if self.done {
1319 return NextChunk::End;
1320 }
1321 loop {
1322 match tokio::time::timeout(self.idle, self.rx.recv()).await {
1323 Ok(Some(StreamItem::Chunk { seq, data })) => {
1324 let Ok(bytes) = BASE64.decode(data.as_bytes()) else {
1325 return self.abort().await;
1326 };
1327 self.sent = self.sent.saturating_add(bytes.len());
1328 if self.sent > self.max_body {
1329 return self.abort().await;
1330 }
1331 return NextChunk::Data { seq, bytes };
1332 }
1333 Ok(Some(StreamItem::Head { .. })) => {}
1335 Ok(Some(StreamItem::End | StreamItem::Error(_)) | None) => {
1336 return self.finish();
1337 }
1338 Err(_) => return self.abort().await,
1340 }
1341 }
1342 }
1343
1344 fn finish(&mut self) -> NextChunk {
1347 self.done = true;
1348 self.state.correlator.remove(self.id);
1349 NextChunk::End
1350 }
1351
1352 async fn abort(&mut self) -> NextChunk {
1355 self.done = true;
1356 send_cancel(&self.state, self.conn_id, self.id).await;
1357 NextChunk::End
1358 }
1359}
1360
1361fn to_ndjson_line(line: &StreamLine) -> String {
1363 let mut s = serde_json::to_string(line).unwrap_or_else(|_| "{}".to_string());
1364 s.push('\n');
1365 s
1366}
1367
1368fn raw_stream_response(
1372 status: u16,
1373 headers: BTreeMap<String, String>,
1374 driver: StreamDriver,
1375) -> Response {
1376 let code = StatusCode::from_u16(status).unwrap_or(StatusCode::BAD_GATEWAY);
1377 let mut builder = Response::builder().status(code);
1378 if let Some(ct) = headers.get("content-type") {
1379 builder = builder.header(header::CONTENT_TYPE, ct);
1380 }
1381 let stream = futures::stream::unfold(driver, |mut driver| async move {
1382 match driver.next_chunk().await {
1383 NextChunk::Data { bytes, .. } => Some((
1384 Ok::<_, std::convert::Infallible>(Bytes::from(bytes)),
1385 driver,
1386 )),
1387 NextChunk::End => None,
1388 }
1389 });
1390 builder
1391 .body(Body::from_stream(stream))
1392 .unwrap_or_else(|_| StatusCode::BAD_GATEWAY.into_response())
1393}
1394
1395fn ndjson_stream_response(
1398 status: u16,
1399 headers: BTreeMap<String, String>,
1400 driver: StreamDriver,
1401) -> Response {
1402 let head_line = to_ndjson_line(&StreamLine::Head { status, headers });
1403 let init = (Some(head_line), driver, false);
1405 let stream = futures::stream::unfold(init, |(head, mut driver, done_emitted)| async move {
1406 if let Some(line) = head {
1407 return Some((
1408 Ok::<_, std::convert::Infallible>(Bytes::from(line)),
1409 (None, driver, done_emitted),
1410 ));
1411 }
1412 if done_emitted {
1413 return None;
1414 }
1415 match driver.next_chunk().await {
1416 NextChunk::Data { seq, bytes } => {
1417 let line = to_ndjson_line(&StreamLine::Chunk {
1418 seq,
1419 chunk: BASE64.encode(&bytes),
1420 });
1421 Some((Ok(Bytes::from(line)), (None, driver, false)))
1422 }
1423 NextChunk::End => {
1424 let line = to_ndjson_line(&StreamLine::Done { done: true });
1425 Some((Ok(Bytes::from(line)), (None, driver, true)))
1426 }
1427 }
1428 });
1429 Response::builder()
1430 .status(StatusCode::OK)
1431 .header(header::CONTENT_TYPE, "application/x-ndjson")
1432 .body(Body::from_stream(stream))
1433 .unwrap_or_else(|_| StatusCode::BAD_GATEWAY.into_response())
1434}
1435
1436fn envelope_to_response(env: ResponseEnvelope) -> Response {
1442 let status = StatusCode::from_u16(env.status).unwrap_or(StatusCode::BAD_GATEWAY);
1443 let mut builder = Response::builder().status(status);
1444 if let Some(ct) = env.headers.get("content-type") {
1445 builder = builder.header(header::CONTENT_TYPE, ct);
1446 }
1447 let body = match env.encoding.as_deref() {
1448 Some("base64") => match BASE64.decode(env.body.as_bytes()) {
1449 Ok(bytes) => Body::from(bytes),
1450 Err(_) => return StatusCode::BAD_GATEWAY.into_response(),
1451 },
1452 _ => Body::from(env.body),
1453 };
1454 builder
1455 .body(body)
1456 .unwrap_or_else(|_| StatusCode::BAD_GATEWAY.into_response())
1457}
1458
1459#[cfg(test)]
1460#[allow(clippy::unwrap_used, clippy::expect_used)]
1461mod tests {
1462 use super::*;
1463
1464 fn buffered_frame(id: u64) -> BrowserFrame {
1465 BrowserFrame {
1466 id,
1467 status: Some(200),
1468 headers: None,
1469 body: Some("ok".into()),
1470 encoding: None,
1471 error: None,
1472 stream: None,
1473 chunk: None,
1474 seq: None,
1475 done: None,
1476 }
1477 }
1478
1479 fn tab(origin: Option<&str>) -> WsConn {
1482 let (sender, _rx) = mpsc::unbounded_channel();
1483 WsConn {
1484 sender,
1485 origin: origin.map(str::to_string),
1486 }
1487 }
1488
1489 #[test]
1490 fn resolve_target_no_tabs_is_503() {
1491 let tabs = HashMap::new();
1492 let err = resolve_target(&tabs, None).unwrap_err();
1493 assert_eq!(err.0, StatusCode::SERVICE_UNAVAILABLE);
1494 }
1495
1496 #[test]
1497 fn resolve_target_single_tab_routes_without_target() {
1498 let mut tabs = HashMap::new();
1499 tabs.insert(1, tab(Some("https://a.test")));
1500 let (id, _s) = resolve_target(&tabs, None).unwrap();
1501 assert_eq!(id, 1);
1502 }
1503
1504 #[test]
1505 fn resolve_target_multiple_tabs_no_target_is_409() {
1506 let mut tabs = HashMap::new();
1507 tabs.insert(1, tab(Some("https://a.test")));
1508 tabs.insert(2, tab(Some("https://b.test")));
1509 let err = resolve_target(&tabs, None).unwrap_err();
1510 assert_eq!(err.0, StatusCode::CONFLICT);
1511 assert!(err.1.contains("id 1") && err.1.contains("id 2"));
1513 }
1514
1515 #[test]
1516 fn resolve_target_by_connection_id() {
1517 let mut tabs = HashMap::new();
1518 tabs.insert(1, tab(Some("https://a.test")));
1519 tabs.insert(2, tab(Some("https://b.test")));
1520 let (id, _s) = resolve_target(&tabs, Some("2")).unwrap();
1521 assert_eq!(id, 2);
1522 assert_eq!(
1524 resolve_target(&tabs, Some("9")).unwrap_err().0,
1525 StatusCode::NOT_FOUND
1526 );
1527 }
1528
1529 #[test]
1530 fn resolve_target_by_unique_origin() {
1531 let mut tabs = HashMap::new();
1532 tabs.insert(1, tab(Some("https://a.test")));
1533 tabs.insert(2, tab(Some("https://b.test")));
1534 let (id, _s) = resolve_target(&tabs, Some("https://b.test")).unwrap();
1535 assert_eq!(id, 2);
1536 assert_eq!(
1538 resolve_target(&tabs, Some("https://nope.test"))
1539 .unwrap_err()
1540 .0,
1541 StatusCode::NOT_FOUND
1542 );
1543 }
1544
1545 #[test]
1546 fn resolve_target_ambiguous_origin_is_409() {
1547 let mut tabs = HashMap::new();
1548 tabs.insert(1, tab(Some("https://a.test")));
1549 tabs.insert(2, tab(Some("https://a.test")));
1550 let err = resolve_target(&tabs, Some("https://a.test")).unwrap_err();
1551 assert_eq!(err.0, StatusCode::CONFLICT);
1552 assert!(err.1.contains("connection id"));
1554 }
1555
1556 #[test]
1557 fn target_header_trims_and_drops_empty() {
1558 let mut h = axum::http::HeaderMap::new();
1559 assert_eq!(target_header(&h), None);
1560 h.insert(auth::BRIDGE_TARGET_HEADER, " 2 ".parse().unwrap());
1561 assert_eq!(target_header(&h).as_deref(), Some("2"));
1562 h.insert(auth::BRIDGE_TARGET_HEADER, " ".parse().unwrap());
1563 assert_eq!(target_header(&h), None);
1564 }
1565
1566 #[test]
1567 fn tab_list_renders_id_with_and_without_origin() {
1568 let mut tabs = HashMap::new();
1569 tabs.insert(1, tab(Some("https://a.test")));
1570 tabs.insert(2, tab(None));
1571 assert_eq!(tab_list(&tabs), "id 1: https://a.test, id 2");
1573 }
1574
1575 fn test_state() -> AppState {
1578 AppState {
1579 token: Arc::new("t".to_string()),
1580 config: Arc::new(BridgeConfig {
1581 ws_port: 0,
1582 control_port: 0,
1583 request_timeout: Duration::from_secs(5),
1584 allow_origins: auth::OriginAllowlist::default(),
1585 max_body_bytes: 1024,
1586 max_concurrent: 8,
1587 }),
1588 correlator: Correlator::new(),
1589 tabs: Arc::new(StdMutex::new(HashMap::new())),
1590 in_flight: Arc::new(Semaphore::new(8)),
1591 conn_counter: Arc::new(AtomicU64::new(1)),
1592 }
1593 }
1594
1595 async fn insert_dead_tab(state: &AppState, id: u64) {
1598 let (sender, rx) = mpsc::unbounded_channel();
1599 drop(rx);
1600 state.lock_tabs().insert(
1601 id,
1602 WsConn {
1603 sender,
1604 origin: None,
1605 },
1606 );
1607 }
1608
1609 fn plain_request() -> ControlRequest {
1610 ControlRequest {
1611 url: "/x".to_string(),
1612 method: "GET".to_string(),
1613 headers: BTreeMap::new(),
1614 body: None,
1615 stream: false,
1616 target: None,
1617 allow_origin: None,
1618 credentials: None,
1619 encoding: None,
1620 }
1621 }
1622
1623 fn state_with_allowlist(values: &[&str]) -> AppState {
1626 let mut state = test_state();
1627 let mut config = (*state.config).clone();
1628 config.allow_origins = auth::OriginAllowlist::parse(values).unwrap();
1629 state.config = Arc::new(config);
1630 state
1631 }
1632
1633 #[test]
1634 fn resolve_outbound_prefers_per_request_override() {
1635 let state = state_with_allowlist(&["https://grafana.internal"]);
1636 let req = ControlRequest {
1637 allow_origin: Some("https://per-request.test".to_string()),
1638 ..plain_request()
1639 };
1640 assert_eq!(
1642 resolve_outbound(&req, &state, Some("https://grafana.internal")),
1643 vec!["https://per-request.test"]
1644 );
1645 let allowed = resolve_outbound(&req, &state, Some("https://grafana.internal"));
1648 assert_eq!(
1649 auth::validate_outbound_url("https://per-request.test/x", &allowed),
1650 Ok(())
1651 );
1652 assert!(auth::validate_outbound_url("https://other.test/x", &allowed).is_err());
1653 }
1654
1655 #[test]
1656 fn resolve_outbound_uses_the_target_tabs_grant() {
1657 let state = state_with_allowlist(&[
1658 "https://grafana.internal",
1659 "https://www.facebook.com=https://static.xx.fbcdn.net",
1660 ]);
1661 let req = plain_request();
1662 assert!(req.allow_origin.is_none());
1663 assert_eq!(
1665 resolve_outbound(&req, &state, Some("https://grafana.internal")),
1666 vec!["https://grafana.internal"]
1667 );
1668 assert_eq!(
1669 resolve_outbound(&req, &state, Some("https://www.facebook.com")),
1670 vec!["https://static.xx.fbcdn.net"]
1671 );
1672 assert!(resolve_outbound(&req, &state, Some("https://other.test")).is_empty());
1674 }
1675
1676 #[test]
1677 fn per_request_override_does_not_affect_ws_connection_gate() {
1678 let state = state_with_allowlist(&["https://a.test"]);
1682 let req = ControlRequest {
1683 allow_origin: Some("https://b.test".to_string()),
1684 ..plain_request()
1685 };
1686 let allowed = resolve_outbound(&req, &state, Some("https://a.test"));
1688 assert_eq!(
1689 auth::validate_outbound_url("https://b.test/x", &allowed),
1690 Ok(())
1691 );
1692 assert!(state
1694 .config
1695 .allow_origins
1696 .permits_connection(Some("https://a.test")));
1697 assert!(!state
1698 .config
1699 .allow_origins
1700 .permits_connection(Some("https://b.test")));
1701 }
1702
1703 #[tokio::test]
1704 async fn dispatch_returns_503_when_send_fails() {
1705 let state = test_state();
1706 insert_dead_tab(&state, 1).await;
1707 let err = dispatch(&state, plain_request()).await.unwrap_err();
1708 assert_eq!(err.0, StatusCode::SERVICE_UNAVAILABLE);
1709 assert_eq!(state.correlator.pending_count(), 0);
1711 }
1712
1713 #[tokio::test]
1714 async fn start_stream_returns_503_when_send_fails() {
1715 let state = test_state();
1716 insert_dead_tab(&state, 1).await;
1717 let req = ControlRequest {
1718 stream: true,
1719 ..plain_request()
1720 };
1721 let err = start_stream(&state, req).await.err().map(|e| e.0);
1722 assert_eq!(err, Some(StatusCode::SERVICE_UNAVAILABLE));
1723 assert_eq!(state.correlator.pending_count(), 0);
1724 }
1725
1726 #[test]
1727 fn correlator_register_resolve_round_trip() {
1728 let c = Correlator::new();
1729 let (id, rx) = c.register();
1730 assert_eq!(c.pending_count(), 1);
1731 assert_eq!(c.deliver(buffered_frame(id)), None);
1732 assert_eq!(c.pending_count(), 0);
1733 let reply = rx.now_or_never().unwrap().unwrap();
1734 assert_eq!(reply.id, id);
1735 }
1736
1737 #[test]
1738 fn correlator_stream_forwards_items_until_terminal() {
1739 let c = Correlator::new();
1740 let (id, mut rx) = c.register_stream();
1741 assert_eq!(c.pending_count(), 1);
1742
1743 let mut head = buffered_frame(id);
1744 head.stream = Some(true);
1745 head.body = None;
1746 assert_eq!(c.deliver(head), None);
1747 assert!(matches!(
1748 rx.try_recv(),
1749 Ok(StreamItem::Head { status: 200, .. })
1750 ));
1751 assert_eq!(c.pending_count(), 1);
1753
1754 let mut done = BrowserFrame {
1755 done: Some(true),
1756 ..buffered_frame(id)
1757 };
1758 done.body = None;
1759 assert_eq!(c.deliver(done), None);
1760 assert!(matches!(rx.try_recv(), Ok(StreamItem::End)));
1761 assert_eq!(c.pending_count(), 0);
1762 }
1763
1764 #[test]
1765 fn correlator_deliver_unknown_id_is_noop() {
1766 let c = Correlator::new();
1767 assert_eq!(c.deliver(buffered_frame(999)), None);
1770 assert_eq!(c.pending_count(), 0);
1771 }
1772
1773 #[test]
1774 fn correlator_stream_signals_cancel_when_consumer_gone() {
1775 let c = Correlator::new();
1776 let (id, rx) = c.register_stream();
1777 drop(rx); let mut chunk = buffered_frame(id);
1779 chunk.chunk = Some("aGk=".into());
1780 chunk.body = None;
1781 assert_eq!(c.deliver(chunk), Some(id));
1783 assert_eq!(c.pending_count(), 0);
1784 }
1785
1786 #[test]
1787 fn correlator_ids_are_monotonic() {
1788 let c = Correlator::new();
1789 let (a, _ra) = c.register();
1790 let (b, _rb) = c.register();
1791 assert!(b > a);
1792 }
1793
1794 #[test]
1795 fn correlator_remove_drops_waiter() {
1796 let c = Correlator::new();
1797 let (id, _rx) = c.register();
1798 c.remove(id);
1799 assert_eq!(c.pending_count(), 0);
1800 }
1801
1802 #[test]
1803 fn extract_stream_flag_detects_and_strips_marker() {
1804 assert_eq!(extract_stream_flag(None), (false, None));
1805 assert_eq!(
1806 extract_stream_flag(Some("a=1&b=2")),
1807 (false, Some("a=1&b=2".to_string()))
1808 );
1809 assert_eq!(
1810 extract_stream_flag(Some("a=1&__stream=1&b=2")),
1811 (true, Some("a=1&b=2".to_string()))
1812 );
1813 assert_eq!(extract_stream_flag(Some("__stream")), (true, None));
1815 assert_eq!(extract_stream_flag(Some("__stream=0")), (false, None));
1817 }
1818
1819 #[test]
1820 fn forwardable_headers_drops_control_headers() {
1821 let mut h = axum::http::HeaderMap::new();
1822 h.insert("host", "localhost:9998".parse().unwrap());
1823 h.insert("authorization", "Bearer x".parse().unwrap());
1824 h.insert("x-omni-bridge", "1".parse().unwrap());
1825 h.insert("accept", "application/json".parse().unwrap());
1826 let out = forwardable_headers(&h);
1827 assert!(!out.contains_key("host"));
1828 assert!(!out.contains_key("authorization"));
1829 assert!(!out.contains_key("x-omni-bridge"));
1830 assert_eq!(
1831 out.get("accept").map(String::as_str),
1832 Some("application/json")
1833 );
1834 }
1835
1836 #[test]
1837 fn envelope_to_response_passes_text_body_through() {
1838 let env = ResponseEnvelope {
1839 id: 1,
1840 status: 200,
1841 headers: BTreeMap::new(),
1842 body: "hello".into(),
1843 encoding: None,
1844 };
1845 assert_eq!(envelope_to_response(env).status(), StatusCode::OK);
1846 }
1847
1848 #[test]
1849 fn envelope_to_response_rejects_invalid_base64() {
1850 let env = ResponseEnvelope {
1853 id: 1,
1854 status: 200,
1855 headers: BTreeMap::new(),
1856 body: "not valid base64 @@@".into(),
1857 encoding: Some("base64".into()),
1858 };
1859 assert_eq!(envelope_to_response(env).status(), StatusCode::BAD_GATEWAY);
1860 }
1861
1862 use futures::FutureExt;
1863
1864 fn ephemeral_config() -> BridgeConfig {
1866 BridgeConfig {
1867 ws_port: 0,
1868 control_port: 0,
1869 request_timeout: Duration::from_secs(5),
1870 allow_origins: auth::OriginAllowlist::default(),
1871 max_body_bytes: 1024,
1872 max_concurrent: 8,
1873 }
1874 }
1875
1876 #[tokio::test]
1877 async fn bridge_server_start_status_shutdown() {
1878 let server = BridgeServer::start(ephemeral_config(), "tok".to_string())
1879 .await
1880 .unwrap();
1881 assert_ne!(server.control_port(), 0);
1883 assert_ne!(server.ws_port(), 0);
1884 let status = server.status();
1886 assert!(!status.connected);
1887 assert!(status.tabs.is_empty());
1888 assert_eq!(status.pending, 0);
1889 server.shutdown().await;
1891 }
1892
1893 #[tokio::test]
1894 async fn disconnect_unknown_tab_is_error() {
1895 let server = BridgeServer::start(ephemeral_config(), "tok".to_string())
1896 .await
1897 .unwrap();
1898 let err = server.disconnect_tab(999).unwrap_err();
1899 assert!(err.to_string().contains("no connected tab"));
1900 server.shutdown().await;
1901 }
1902
1903 #[tokio::test]
1904 async fn start_fails_closed_on_taken_control_port() {
1905 let squatter = std::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).unwrap();
1907 let taken = squatter.local_addr().unwrap().port();
1908 let config = BridgeConfig {
1909 control_port: taken,
1910 ..ephemeral_config()
1911 };
1912 assert!(BridgeServer::start(config, "tok".to_string())
1913 .await
1914 .is_err());
1915 }
1916
1917 #[tokio::test]
1918 async fn rebind_same_fixed_port_after_close_succeeds() {
1919 let server = BridgeServer::start(ephemeral_config(), "tok".to_string())
1925 .await
1926 .unwrap();
1927 let control_port = server.control_port();
1928 let ws_port = server.ws_port();
1929
1930 let control_conn = TcpStream::connect((Ipv4Addr::LOCALHOST, control_port))
1934 .await
1935 .unwrap();
1936 let ws_conn = TcpStream::connect((Ipv4Addr::LOCALHOST, ws_port))
1937 .await
1938 .unwrap();
1939
1940 server.shutdown().await;
1941 drop(control_conn);
1942 drop(ws_conn);
1943 tokio::time::sleep(Duration::from_millis(50)).await;
1945
1946 let config = BridgeConfig {
1947 ws_port,
1948 control_port,
1949 ..ephemeral_config()
1950 };
1951 let server2 = BridgeServer::start(config, "tok".to_string())
1952 .await
1953 .expect("rebinding just-released fixed ports must succeed with SO_REUSEADDR");
1954 server2.shutdown().await;
1955 }
1956}