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