1use std::collections::{BTreeMap, HashMap};
12use std::net::Ipv4Addr;
13use std::sync::atomic::{AtomicU64, Ordering};
14use std::sync::{Arc, Mutex as StdMutex};
15use std::time::Duration;
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, TcpStream};
31use tokio::sync::{mpsc, oneshot, Mutex, OwnedSemaphorePermit, Semaphore};
32use tokio_tungstenite::tungstenite::Message;
33
34use crate::browser::auth;
35use crate::browser::protocol::{
36 BrowserFrame, BrowserReply, CancelCommand, Command, ControlRequest, ReplyOutcome,
37 ResponseEnvelope, StatusResponse, StreamItem, StreamLine, TabInfo,
38};
39use crate::browser::snippet;
40
41#[derive(Debug, Clone)]
43pub struct BridgeConfig {
44 pub ws_port: u16,
46 pub control_port: u16,
48 pub request_timeout: Duration,
50 pub allow_origin: Option<String>,
52 pub max_body_bytes: usize,
54 pub max_concurrent: usize,
56}
57
58enum Waiter {
61 Buffered(oneshot::Sender<BrowserReply>),
63 Stream(mpsc::UnboundedSender<StreamItem>),
65}
66
67#[derive(Clone)]
69struct Correlator {
70 pending: Arc<StdMutex<HashMap<u64, Waiter>>>,
71 next_id: Arc<AtomicU64>,
72}
73
74impl Correlator {
75 fn new() -> Self {
76 Self {
77 pending: Arc::new(StdMutex::new(HashMap::new())),
78 next_id: Arc::new(AtomicU64::new(1)),
79 }
80 }
81
82 fn register(&self) -> (u64, oneshot::Receiver<BrowserReply>) {
84 let id = self.next_id.fetch_add(1, Ordering::Relaxed);
85 let (tx, rx) = oneshot::channel();
86 self.lock().insert(id, Waiter::Buffered(tx));
87 (id, rx)
88 }
89
90 fn register_stream(&self) -> (u64, mpsc::UnboundedReceiver<StreamItem>) {
93 let id = self.next_id.fetch_add(1, Ordering::Relaxed);
94 let (tx, rx) = mpsc::unbounded_channel();
95 self.lock().insert(id, Waiter::Stream(tx));
96 (id, rx)
97 }
98
99 fn remove(&self, id: u64) {
101 self.lock().remove(&id);
102 }
103
104 fn deliver(&self, frame: BrowserFrame) -> Option<u64> {
111 let id = frame.id;
112 let mut guard = self.lock();
113 match guard.get(&id) {
114 Some(Waiter::Buffered(_)) => {
115 if let Some(Waiter::Buffered(tx)) = guard.remove(&id) {
116 let _ = tx.send(frame.into_reply());
117 }
118 None
119 }
120 Some(Waiter::Stream(_)) => {
121 let item = frame.stream_item();
122 let terminal = matches!(item, StreamItem::End | StreamItem::Error(_));
123 let send_failed = match guard.get(&id) {
124 Some(Waiter::Stream(tx)) => tx.send(item).is_err(),
125 _ => false,
126 };
127 if terminal || send_failed {
128 guard.remove(&id);
129 }
130 send_failed.then_some(id)
131 }
132 None => None,
133 }
134 }
135
136 fn pending_count(&self) -> usize {
137 self.lock().len()
138 }
139
140 fn lock(&self) -> std::sync::MutexGuard<'_, HashMap<u64, Waiter>> {
141 self.pending
142 .lock()
143 .unwrap_or_else(std::sync::PoisonError::into_inner)
144 }
145}
146
147struct WsConn {
149 sender: mpsc::UnboundedSender<Message>,
151 origin: Option<String>,
153}
154
155#[derive(Clone)]
157struct AppState {
158 token: Arc<String>,
159 config: Arc<BridgeConfig>,
160 correlator: Correlator,
161 tabs: Arc<Mutex<HashMap<u64, WsConn>>>,
165 in_flight: Arc<Semaphore>,
166 conn_counter: Arc<AtomicU64>,
167}
168
169pub async fn run(mut config: BridgeConfig, token: String) -> Result<()> {
173 let control_listener = TcpListener::bind((Ipv4Addr::LOCALHOST, config.control_port))
174 .await
175 .with_context(|| {
176 format!(
177 "Failed to bind control plane to 127.0.0.1:{} (already in use?)",
178 config.control_port
179 )
180 })?;
181 let ws_listener = TcpListener::bind((Ipv4Addr::LOCALHOST, config.ws_port))
182 .await
183 .with_context(|| {
184 format!(
185 "Failed to bind WebSocket plane to 127.0.0.1:{} (already in use?)",
186 config.ws_port
187 )
188 })?;
189
190 config.control_port = control_listener.local_addr()?.port();
193 config.ws_port = ws_listener.local_addr()?.port();
194
195 let token = Arc::new(token);
196 let state = AppState {
197 token: token.clone(),
198 config: Arc::new(config.clone()),
199 correlator: Correlator::new(),
200 tabs: Arc::new(Mutex::new(HashMap::new())),
201 in_flight: Arc::new(Semaphore::new(config.max_concurrent)),
202 conn_counter: Arc::new(AtomicU64::new(1)),
203 };
204
205 print_startup(&config, &token);
206
207 let ws_state = state.clone();
209 tokio::spawn(async move {
210 loop {
211 match ws_listener.accept().await {
212 Ok((stream, _peer)) => {
213 tokio::spawn(handle_ws_conn(stream, ws_state.clone()));
214 }
215 Err(e) => tracing::warn!("WebSocket accept error: {e}"),
216 }
217 }
218 });
219
220 let app = control_router(state, config.max_body_bytes);
221 axum::serve(control_listener, app)
222 .await
223 .context("Control-plane server error")
224}
225
226fn print_startup(config: &BridgeConfig, token: &str) {
228 let snippet = snippet::render(config.ws_port, token);
229 println!("omni-dev browser bridge serve");
230 println!(" control plane : http://127.0.0.1:{}", config.control_port);
231 println!(" websocket : ws://127.0.0.1:{}", config.ws_port);
232 println!(" session token : {token}");
233 if let Some(origin) = &config.allow_origin {
234 println!(" allow-origin : {origin}");
235 }
236 println!();
237 println!("Paste this into the DevTools console of the authenticated tab:");
238 println!();
239 println!("{snippet}");
240 println!();
241 println!("Then drive requests, e.g.:");
242 println!(
243 " omni-dev browser bridge request --control-port {} --url /path",
244 config.control_port
245 );
246}
247
248#[allow(clippy::result_large_err)]
258async fn handle_ws_conn(stream: TcpStream, state: AppState) {
259 use tokio_tungstenite::tungstenite::handshake::server::{ErrorResponse, Request, Response};
260
261 let token = state.token.clone();
262 let allow_origin = state.config.allow_origin.clone();
263 let captured_origin: Arc<StdMutex<Option<String>>> = Arc::new(StdMutex::new(None));
264 let co = captured_origin.clone();
265
266 let callback =
267 move |req: &Request, mut response: Response| -> Result<Response, ErrorResponse> {
268 let origin = req
269 .headers()
270 .get("origin")
271 .and_then(|v| v.to_str().ok())
272 .map(str::to_string);
273
274 if !auth::ws_origin_allowed(origin.as_deref(), allow_origin.as_deref()) {
275 tracing::warn!("Rejected WebSocket upgrade: origin not allowed");
276 return Err(ws_error(StatusCode::FORBIDDEN, "origin not allowed"));
277 }
278
279 let protocols: Vec<String> = req
280 .headers()
281 .get_all("sec-websocket-protocol")
282 .iter()
283 .filter_map(|v| v.to_str().ok())
284 .flat_map(|s| s.split(',').map(|p| p.trim().to_string()))
285 .collect();
286
287 let Some(matched) =
288 auth::ws_subprotocol_token(protocols.iter().map(String::as_str), &token)
289 else {
290 tracing::warn!("Rejected WebSocket upgrade: missing or invalid token");
291 return Err(ws_error(
292 StatusCode::UNAUTHORIZED,
293 "missing or invalid token",
294 ));
295 };
296 if let Ok(value) = matched.parse() {
297 response
298 .headers_mut()
299 .insert("sec-websocket-protocol", value);
300 }
301 *co.lock().unwrap_or_else(std::sync::PoisonError::into_inner) = origin;
302 Ok(response)
303 };
304
305 let ws_stream = match tokio_tungstenite::accept_hdr_async(stream, callback).await {
306 Ok(s) => s,
307 Err(e) => {
308 tracing::debug!("WebSocket handshake failed: {e}");
309 return;
310 }
311 };
312
313 let origin = captured_origin
314 .lock()
315 .unwrap_or_else(std::sync::PoisonError::into_inner)
316 .take();
317 let conn_id = state.conn_counter.fetch_add(1, Ordering::Relaxed);
318 tracing::info!(
319 "Browser connected (conn {conn_id}{})",
320 origin
321 .as_deref()
322 .map(|o| format!(", origin {o}"))
323 .unwrap_or_default()
324 );
325
326 let (tx, mut rx) = mpsc::unbounded_channel::<Message>();
327 {
328 let mut guard = state.tabs.lock().await;
329 guard.insert(conn_id, WsConn { sender: tx, origin });
330 }
331
332 let (mut sink, mut read) = ws_stream.split();
333 let writer = tokio::spawn(async move {
334 while let Some(msg) = rx.recv().await {
335 if sink.send(msg).await.is_err() {
336 break;
337 }
338 }
339 });
340
341 while let Some(Ok(msg)) = read.next().await {
342 match msg {
343 Message::Text(txt) => match serde_json::from_str::<BrowserFrame>(&txt) {
344 Ok(frame) => {
345 if let Some(cancel_id) = state.correlator.deliver(frame) {
349 send_cancel(&state, conn_id, cancel_id).await;
350 }
351 }
352 Err(e) => tracing::debug!("Unparseable browser frame: {e}"),
353 },
354 Message::Close(_) => break,
355 _ => {}
356 }
357 }
358
359 writer.abort();
360 if state.tabs.lock().await.remove(&conn_id).is_some() {
363 tracing::info!("Browser disconnected (conn {conn_id})");
364 }
365}
366
367fn ws_error(
368 code: StatusCode,
369 msg: &str,
370) -> tokio_tungstenite::tungstenite::handshake::server::ErrorResponse {
371 let mut resp = tokio_tungstenite::tungstenite::http::Response::new(Some(msg.to_string()));
372 *resp.status_mut() = code;
373 resp
374}
375
376fn control_router(state: AppState, max_body_bytes: usize) -> Router {
379 Router::new()
380 .route("/__bridge/status", get(status_handler))
381 .route("/__bridge/request", post(request_handler))
382 .fallback(proxy_handler)
383 .layer(DefaultBodyLimit::max(max_body_bytes))
384 .layer(middleware::from_fn_with_state(state.clone(), guard))
385 .with_state(state)
386}
387
388async fn guard(State(state): State<AppState>, request: Request, next: Next) -> Response {
392 if request.method() == axum::http::Method::OPTIONS {
395 return (StatusCode::METHOD_NOT_ALLOWED, "OPTIONS not allowed").into_response();
396 }
397
398 let headers = request.headers();
399 let get = |name: &str| headers.get(name).and_then(|v| v.to_str().ok());
400
401 let host = get(header::HOST.as_str()).unwrap_or("");
402 if !auth::host_allowed(host, state.config.control_port) {
403 tracing::warn!("Rejected control-plane request: disallowed Host");
404 return (StatusCode::BAD_REQUEST, "host not allowed").into_response();
405 }
406
407 if auth::is_browser_originated(get("origin"), get("sec-fetch-site")) {
408 tracing::warn!("Rejected control-plane request: browser-originated");
409 return (
410 StatusCode::FORBIDDEN,
411 "browser-originated requests are denied",
412 )
413 .into_response();
414 }
415
416 if !auth::has_bridge_header(get(auth::BRIDGE_HEADER)) {
417 return (StatusCode::FORBIDDEN, "missing X-Omni-Bridge: 1").into_response();
418 }
419
420 if !auth::bearer_matches(get(header::AUTHORIZATION.as_str()), &state.token) {
421 return (StatusCode::UNAUTHORIZED, "invalid or missing bearer token").into_response();
422 }
423
424 next.run(request).await
425}
426
427async fn status_handler(State(state): State<AppState>) -> Json<StatusResponse> {
428 let mut tabs: Vec<TabInfo> = {
429 let guard = state.tabs.lock().await;
430 guard
431 .iter()
432 .map(|(id, conn)| TabInfo {
433 id: *id,
434 origin: conn.origin.clone(),
435 })
436 .collect()
437 };
438 tabs.sort_by_key(|t| t.id);
439 let browser_origin = match tabs.as_slice() {
442 [only] => only.origin.clone(),
443 _ => None,
444 };
445 Json(StatusResponse {
446 connected: !tabs.is_empty(),
447 browser_origin,
448 tabs,
449 pending: state.correlator.pending_count(),
450 })
451}
452
453async fn request_handler(
457 State(state): State<AppState>,
458 headers: axum::http::HeaderMap,
459 body: Bytes,
460) -> Response {
461 let mut req: ControlRequest = match serde_json::from_slice(&body) {
462 Ok(r) => r,
463 Err(e) => {
464 return (StatusCode::BAD_REQUEST, format!("invalid JSON body: {e}")).into_response()
465 }
466 };
467 if let Some(target) = target_header(&headers) {
469 req.target = Some(target);
470 }
471 if req.stream {
472 return match start_stream(&state, req).await {
473 Ok((status, headers, driver)) => ndjson_stream_response(status, headers, driver),
474 Err((code, msg)) => (code, msg).into_response(),
475 };
476 }
477 match dispatch(&state, req).await {
478 Ok(env) => Json(env).into_response(),
479 Err((code, msg)) => (code, msg).into_response(),
480 }
481}
482
483async fn proxy_handler(State(state): State<AppState>, request: Request) -> Response {
485 let (parts, body) = request.into_parts();
486
487 let path = parts.uri.path();
488 if auth::normalize_request_path(path).is_none() {
489 return (StatusCode::BAD_REQUEST, "unsafe request path").into_response();
490 }
491 let (stream, forwarded_query) = extract_stream_flag(parts.uri.query());
494 let url = match forwarded_query.as_deref() {
495 Some(q) => format!("{path}?{q}"),
496 None => path.to_string(),
497 };
498
499 let headers = forwardable_headers(&parts.headers);
500
501 let Ok(body_bytes) = axum::body::to_bytes(body, state.config.max_body_bytes).await else {
502 return (StatusCode::PAYLOAD_TOO_LARGE, "request body too large").into_response();
503 };
504 let body = if body_bytes.is_empty() {
505 None
506 } else {
507 Some(String::from_utf8_lossy(&body_bytes).into_owned())
508 };
509
510 let req = ControlRequest {
511 url,
512 method: parts.method.to_string(),
513 headers,
514 body,
515 stream,
516 target: target_header(&parts.headers),
517 allow_origin: None,
520 credentials: None,
523 };
524
525 if stream {
526 return match start_stream(&state, req).await {
527 Ok((status, headers, driver)) => raw_stream_response(status, headers, driver),
528 Err((code, msg)) => (code, msg).into_response(),
529 };
530 }
531
532 match dispatch(&state, req).await {
533 Ok(env) => envelope_to_response(env),
534 Err((code, msg)) => (code, msg).into_response(),
535 }
536}
537
538fn extract_stream_flag(query: Option<&str>) -> (bool, Option<String>) {
544 let Some(query) = query else {
545 return (false, None);
546 };
547 let mut stream = false;
548 let kept: Vec<&str> = query
549 .split('&')
550 .filter(|kv| {
551 let (key, value) = match kv.split_once('=') {
552 Some((k, v)) => (k, Some(v)),
553 None => (*kv, None),
554 };
555 if key == "__stream" {
556 stream = !matches!(value, Some("0" | "false"));
557 false
558 } else {
559 true
560 }
561 })
562 .collect();
563 let rebuilt = (!kept.is_empty()).then(|| kept.join("&"));
564 (stream, rebuilt)
565}
566
567fn forwardable_headers(headers: &axum::http::HeaderMap) -> BTreeMap<String, String> {
570 const DROP: &[&str] = &[
571 "host",
572 "authorization",
573 auth::BRIDGE_HEADER,
574 auth::BRIDGE_TARGET_HEADER,
575 "content-length",
576 "connection",
577 "accept-encoding",
578 "origin",
579 "sec-fetch-site",
580 "sec-fetch-mode",
581 "sec-fetch-dest",
582 ];
583 headers
584 .iter()
585 .filter_map(|(k, v)| {
586 let name = k.as_str();
587 if DROP.contains(&name) {
588 return None;
589 }
590 v.to_str()
591 .ok()
592 .map(|val| (name.to_string(), val.to_string()))
593 })
594 .collect()
595}
596
597fn target_header(headers: &axum::http::HeaderMap) -> Option<String> {
600 headers
601 .get(auth::BRIDGE_TARGET_HEADER)
602 .and_then(|v| v.to_str().ok())
603 .map(str::trim)
604 .filter(|s| !s.is_empty())
605 .map(str::to_string)
606}
607
608fn resolve_target(
615 tabs: &HashMap<u64, WsConn>,
616 target: Option<&str>,
617) -> Result<(u64, mpsc::UnboundedSender<Message>), (StatusCode, String)> {
618 if tabs.is_empty() {
619 return Err((
620 StatusCode::SERVICE_UNAVAILABLE,
621 "no browser connected".to_string(),
622 ));
623 }
624 let Some(sel) = target else {
625 let mut it = tabs.iter();
627 return match (it.next(), it.next()) {
628 (Some((id, conn)), None) => Ok((*id, conn.sender.clone())),
629 _ => Err((
630 StatusCode::CONFLICT,
631 format!(
632 "multiple tabs connected; select one with the X-Omni-Bridge-Target \
633 header or a `target` field ({})",
634 tab_list(tabs)
635 ),
636 )),
637 };
638 };
639
640 if let Ok(id) = sel.parse::<u64>() {
642 return match tabs.get(&id) {
643 Some(conn) => Ok((id, conn.sender.clone())),
644 None => Err((
645 StatusCode::NOT_FOUND,
646 format!(
647 "no connected tab with id {id}; connected: {}",
648 tab_list(tabs)
649 ),
650 )),
651 };
652 }
653
654 let mut hits = tabs
656 .iter()
657 .filter(|(_, c)| c.origin.as_deref() == Some(sel));
658 match (hits.next(), hits.next()) {
659 (Some((id, conn)), None) => Ok((*id, conn.sender.clone())),
660 (None, _) => Err((
661 StatusCode::NOT_FOUND,
662 format!(
663 "no connected tab with origin {sel}; connected: {}",
664 tab_list(tabs)
665 ),
666 )),
667 (Some(_), Some(_)) => Err((
668 StatusCode::CONFLICT,
669 format!(
670 "origin {sel} matches multiple tabs; target by connection id ({})",
671 tab_list(tabs)
672 ),
673 )),
674 }
675}
676
677fn tab_list(tabs: &HashMap<u64, WsConn>) -> String {
680 let mut items: Vec<(u64, Option<&str>)> = tabs
681 .iter()
682 .map(|(id, c)| (*id, c.origin.as_deref()))
683 .collect();
684 items.sort_by_key(|(id, _)| *id);
685 items
686 .iter()
687 .map(|(id, origin)| match origin {
688 Some(o) => format!("id {id}: {o}"),
689 None => format!("id {id}"),
690 })
691 .collect::<Vec<_>>()
692 .join(", ")
693}
694
695fn resolve_allow_origin<'a>(req: &'a ControlRequest, state: &'a AppState) -> Option<&'a str> {
706 match req.allow_origin.as_deref() {
707 Some(origin) => {
708 tracing::warn!(
709 "Per-request --allow-origin override in effect; outbound scope for this request \
710 widened to {origin}"
711 );
712 Some(origin)
713 }
714 None => state.config.allow_origin.as_deref(),
715 }
716}
717
718async fn dispatch(
721 state: &AppState,
722 req: ControlRequest,
723) -> Result<ResponseEnvelope, (StatusCode, String)> {
724 auth::validate_outbound_url(&req.url, resolve_allow_origin(&req, state)).map_err(|_| {
725 (
726 StatusCode::FORBIDDEN,
727 "outbound URL is cross-origin; pass --allow-origin to permit it".to_string(),
728 )
729 })?;
730
731 for (name, value) in &req.headers {
732 if !auth::header_is_safe(name, value) {
733 return Err((
734 StatusCode::BAD_REQUEST,
735 "invalid header name or value".to_string(),
736 ));
737 }
738 }
739
740 let _permit = state.in_flight.clone().try_acquire_owned().map_err(|_| {
741 (
742 StatusCode::TOO_MANY_REQUESTS,
743 "too many in-flight requests".to_string(),
744 )
745 })?;
746
747 let (id, rx) = state.correlator.register();
748 let command = Command {
749 id,
750 url: req.url,
751 method: req.method,
752 headers: req.headers,
753 body: req.body,
754 stream: false,
755 credentials: req.credentials,
756 };
757 let frame = match serde_json::to_string(&command) {
758 Ok(f) => f,
759 Err(e) => {
760 state.correlator.remove(id);
761 return Err((
762 StatusCode::INTERNAL_SERVER_ERROR,
763 format!("serialise error: {e}"),
764 ));
765 }
766 };
767
768 {
769 let tabs = state.tabs.lock().await;
770 let (_conn_id, sender) = match resolve_target(&tabs, req.target.as_deref()) {
771 Ok(t) => t,
772 Err(e) => {
773 state.correlator.remove(id);
774 return Err(e);
775 }
776 };
777 if sender.send(Message::Text(frame.into())).is_err() {
778 state.correlator.remove(id);
779 return Err((
780 StatusCode::SERVICE_UNAVAILABLE,
781 "no browser connected".to_string(),
782 ));
783 }
784 }
785
786 match tokio::time::timeout(state.config.request_timeout, rx).await {
787 Ok(Ok(reply)) => match reply.outcome() {
788 ReplyOutcome::Success {
789 status,
790 headers,
791 body,
792 encoding,
793 } => {
794 let decoded_len = match encoding.as_deref() {
798 None => body.len(),
799 Some("base64") => match BASE64.decode(body.as_bytes()) {
800 Ok(bytes) => bytes.len(),
801 Err(_) => {
802 return Err((
803 StatusCode::BAD_GATEWAY,
804 "browser sent an invalid base64 body".to_string(),
805 ))
806 }
807 },
808 Some(other) => {
809 return Err((
810 StatusCode::BAD_GATEWAY,
811 format!("browser sent an unsupported body encoding: {other}"),
812 ))
813 }
814 };
815 if decoded_len > state.config.max_body_bytes {
816 return Err((
817 StatusCode::BAD_GATEWAY,
818 format!(
819 "browser response body is {decoded_len} bytes, exceeding the \
820 --max-body-bytes limit of {} bytes; page the request to fetch \
821 less per call (e.g. narrow the time range or lower a `limit`/page \
822 size) or raise --max-body-bytes",
823 state.config.max_body_bytes
824 ),
825 ));
826 }
827 Ok(ResponseEnvelope {
828 id,
829 status,
830 headers,
831 body,
832 encoding,
833 })
834 }
835 ReplyOutcome::Error(msg) => Err((
836 StatusCode::BAD_GATEWAY,
837 format!("browser fetch failed: {msg}"),
838 )),
839 },
840 Ok(Err(_)) => Err((
841 StatusCode::BAD_GATEWAY,
842 "browser connection closed before replying".to_string(),
843 )),
844 Err(_) => {
845 state.correlator.remove(id);
846 Err((
847 StatusCode::GATEWAY_TIMEOUT,
848 "browser did not reply in time".to_string(),
849 ))
850 }
851 }
852}
853
854async fn send_cancel(state: &AppState, conn_id: u64, id: u64) {
859 state.correlator.remove(id);
860 let Ok(frame) = serde_json::to_string(&CancelCommand::new(id)) else {
861 return;
862 };
863 if let Some(conn) = state.tabs.lock().await.get(&conn_id) {
864 let _ = conn.sender.send(Message::Text(frame.into()));
865 }
866}
867
868async fn start_stream(
874 state: &AppState,
875 req: ControlRequest,
876) -> Result<(u16, BTreeMap<String, String>, StreamDriver), (StatusCode, String)> {
877 auth::validate_outbound_url(&req.url, resolve_allow_origin(&req, state)).map_err(|_| {
878 (
879 StatusCode::FORBIDDEN,
880 "outbound URL is cross-origin; pass --allow-origin to permit it".to_string(),
881 )
882 })?;
883
884 for (name, value) in &req.headers {
885 if !auth::header_is_safe(name, value) {
886 return Err((
887 StatusCode::BAD_REQUEST,
888 "invalid header name or value".to_string(),
889 ));
890 }
891 }
892
893 let permit = state.in_flight.clone().try_acquire_owned().map_err(|_| {
894 (
895 StatusCode::TOO_MANY_REQUESTS,
896 "too many in-flight requests".to_string(),
897 )
898 })?;
899
900 let (id, mut rx) = state.correlator.register_stream();
901 let command = Command {
902 id,
903 url: req.url,
904 method: req.method,
905 headers: req.headers,
906 body: req.body,
907 stream: true,
908 credentials: req.credentials,
909 };
910 let frame = match serde_json::to_string(&command) {
911 Ok(f) => f,
912 Err(e) => {
913 state.correlator.remove(id);
914 return Err((
915 StatusCode::INTERNAL_SERVER_ERROR,
916 format!("serialise error: {e}"),
917 ));
918 }
919 };
920
921 let conn_id = {
922 let tabs = state.tabs.lock().await;
923 let (conn_id, sender) = match resolve_target(&tabs, req.target.as_deref()) {
924 Ok(t) => t,
925 Err(e) => {
926 state.correlator.remove(id);
927 return Err(e);
928 }
929 };
930 if sender.send(Message::Text(frame.into())).is_err() {
931 state.correlator.remove(id);
932 return Err((
933 StatusCode::SERVICE_UNAVAILABLE,
934 "no browser connected".to_string(),
935 ));
936 }
937 conn_id
938 };
939
940 let idle = state.config.request_timeout;
941 let (status, headers) = match tokio::time::timeout(idle, rx.recv()).await {
942 Ok(Some(StreamItem::Head { status, headers })) => (status, headers),
943 Ok(Some(StreamItem::Error(msg))) => {
944 state.correlator.remove(id);
945 return Err((
946 StatusCode::BAD_GATEWAY,
947 format!("browser fetch failed: {msg}"),
948 ));
949 }
950 Ok(Some(_)) => {
951 state.correlator.remove(id);
952 return Err((
953 StatusCode::BAD_GATEWAY,
954 "browser streamed a body chunk before the response head".to_string(),
955 ));
956 }
957 Ok(None) => {
958 return Err((
959 StatusCode::BAD_GATEWAY,
960 "browser connection closed before replying".to_string(),
961 ));
962 }
963 Err(_) => {
964 send_cancel(state, conn_id, id).await;
965 return Err((
966 StatusCode::GATEWAY_TIMEOUT,
967 "browser did not start streaming in time".to_string(),
968 ));
969 }
970 };
971
972 let driver = StreamDriver {
973 state: state.clone(),
974 id,
975 conn_id,
976 rx,
977 idle,
978 max_body: state.config.max_body_bytes,
979 sent: 0,
980 _permit: permit,
981 done: false,
982 };
983 Ok((status, headers, driver))
984}
985
986struct StreamDriver {
991 state: AppState,
992 id: u64,
993 conn_id: u64,
995 rx: mpsc::UnboundedReceiver<StreamItem>,
996 idle: Duration,
997 max_body: usize,
998 sent: usize,
999 _permit: OwnedSemaphorePermit,
1000 done: bool,
1001}
1002
1003enum NextChunk {
1005 Data {
1007 seq: u64,
1009 bytes: Vec<u8>,
1011 },
1012 End,
1014}
1015
1016impl StreamDriver {
1017 async fn next_chunk(&mut self) -> NextChunk {
1020 if self.done {
1021 return NextChunk::End;
1022 }
1023 loop {
1024 match tokio::time::timeout(self.idle, self.rx.recv()).await {
1025 Ok(Some(StreamItem::Chunk { seq, data })) => {
1026 let Ok(bytes) = BASE64.decode(data.as_bytes()) else {
1027 return self.abort().await;
1028 };
1029 self.sent = self.sent.saturating_add(bytes.len());
1030 if self.sent > self.max_body {
1031 return self.abort().await;
1032 }
1033 return NextChunk::Data { seq, bytes };
1034 }
1035 Ok(Some(StreamItem::Head { .. })) => {}
1037 Ok(Some(StreamItem::End | StreamItem::Error(_)) | None) => {
1038 return self.finish();
1039 }
1040 Err(_) => return self.abort().await,
1042 }
1043 }
1044 }
1045
1046 fn finish(&mut self) -> NextChunk {
1049 self.done = true;
1050 self.state.correlator.remove(self.id);
1051 NextChunk::End
1052 }
1053
1054 async fn abort(&mut self) -> NextChunk {
1057 self.done = true;
1058 send_cancel(&self.state, self.conn_id, self.id).await;
1059 NextChunk::End
1060 }
1061}
1062
1063fn to_ndjson_line(line: &StreamLine) -> String {
1065 let mut s = serde_json::to_string(line).unwrap_or_else(|_| "{}".to_string());
1066 s.push('\n');
1067 s
1068}
1069
1070fn raw_stream_response(
1074 status: u16,
1075 headers: BTreeMap<String, String>,
1076 driver: StreamDriver,
1077) -> Response {
1078 let code = StatusCode::from_u16(status).unwrap_or(StatusCode::BAD_GATEWAY);
1079 let mut builder = Response::builder().status(code);
1080 if let Some(ct) = headers.get("content-type") {
1081 builder = builder.header(header::CONTENT_TYPE, ct);
1082 }
1083 let stream = futures::stream::unfold(driver, |mut driver| async move {
1084 match driver.next_chunk().await {
1085 NextChunk::Data { bytes, .. } => Some((
1086 Ok::<_, std::convert::Infallible>(Bytes::from(bytes)),
1087 driver,
1088 )),
1089 NextChunk::End => None,
1090 }
1091 });
1092 builder
1093 .body(Body::from_stream(stream))
1094 .unwrap_or_else(|_| StatusCode::BAD_GATEWAY.into_response())
1095}
1096
1097fn ndjson_stream_response(
1100 status: u16,
1101 headers: BTreeMap<String, String>,
1102 driver: StreamDriver,
1103) -> Response {
1104 let head_line = to_ndjson_line(&StreamLine::Head { status, headers });
1105 let init = (Some(head_line), driver, false);
1107 let stream = futures::stream::unfold(init, |(head, mut driver, done_emitted)| async move {
1108 if let Some(line) = head {
1109 return Some((
1110 Ok::<_, std::convert::Infallible>(Bytes::from(line)),
1111 (None, driver, done_emitted),
1112 ));
1113 }
1114 if done_emitted {
1115 return None;
1116 }
1117 match driver.next_chunk().await {
1118 NextChunk::Data { seq, bytes } => {
1119 let line = to_ndjson_line(&StreamLine::Chunk {
1120 seq,
1121 chunk: BASE64.encode(&bytes),
1122 });
1123 Some((Ok(Bytes::from(line)), (None, driver, false)))
1124 }
1125 NextChunk::End => {
1126 let line = to_ndjson_line(&StreamLine::Done { done: true });
1127 Some((Ok(Bytes::from(line)), (None, driver, true)))
1128 }
1129 }
1130 });
1131 Response::builder()
1132 .status(StatusCode::OK)
1133 .header(header::CONTENT_TYPE, "application/x-ndjson")
1134 .body(Body::from_stream(stream))
1135 .unwrap_or_else(|_| StatusCode::BAD_GATEWAY.into_response())
1136}
1137
1138fn envelope_to_response(env: ResponseEnvelope) -> Response {
1144 let status = StatusCode::from_u16(env.status).unwrap_or(StatusCode::BAD_GATEWAY);
1145 let mut builder = Response::builder().status(status);
1146 if let Some(ct) = env.headers.get("content-type") {
1147 builder = builder.header(header::CONTENT_TYPE, ct);
1148 }
1149 let body = match env.encoding.as_deref() {
1150 Some("base64") => match BASE64.decode(env.body.as_bytes()) {
1151 Ok(bytes) => Body::from(bytes),
1152 Err(_) => return StatusCode::BAD_GATEWAY.into_response(),
1153 },
1154 _ => Body::from(env.body),
1155 };
1156 builder
1157 .body(body)
1158 .unwrap_or_else(|_| StatusCode::BAD_GATEWAY.into_response())
1159}
1160
1161#[cfg(test)]
1162#[allow(clippy::unwrap_used, clippy::expect_used)]
1163mod tests {
1164 use super::*;
1165
1166 fn buffered_frame(id: u64) -> BrowserFrame {
1167 BrowserFrame {
1168 id,
1169 status: Some(200),
1170 headers: None,
1171 body: Some("ok".into()),
1172 encoding: None,
1173 error: None,
1174 stream: None,
1175 chunk: None,
1176 seq: None,
1177 done: None,
1178 }
1179 }
1180
1181 fn tab(origin: Option<&str>) -> WsConn {
1184 let (sender, _rx) = mpsc::unbounded_channel();
1185 WsConn {
1186 sender,
1187 origin: origin.map(str::to_string),
1188 }
1189 }
1190
1191 #[test]
1192 fn resolve_target_no_tabs_is_503() {
1193 let tabs = HashMap::new();
1194 let err = resolve_target(&tabs, None).unwrap_err();
1195 assert_eq!(err.0, StatusCode::SERVICE_UNAVAILABLE);
1196 }
1197
1198 #[test]
1199 fn resolve_target_single_tab_routes_without_target() {
1200 let mut tabs = HashMap::new();
1201 tabs.insert(1, tab(Some("https://a.test")));
1202 let (id, _s) = resolve_target(&tabs, None).unwrap();
1203 assert_eq!(id, 1);
1204 }
1205
1206 #[test]
1207 fn resolve_target_multiple_tabs_no_target_is_409() {
1208 let mut tabs = HashMap::new();
1209 tabs.insert(1, tab(Some("https://a.test")));
1210 tabs.insert(2, tab(Some("https://b.test")));
1211 let err = resolve_target(&tabs, None).unwrap_err();
1212 assert_eq!(err.0, StatusCode::CONFLICT);
1213 assert!(err.1.contains("id 1") && err.1.contains("id 2"));
1215 }
1216
1217 #[test]
1218 fn resolve_target_by_connection_id() {
1219 let mut tabs = HashMap::new();
1220 tabs.insert(1, tab(Some("https://a.test")));
1221 tabs.insert(2, tab(Some("https://b.test")));
1222 let (id, _s) = resolve_target(&tabs, Some("2")).unwrap();
1223 assert_eq!(id, 2);
1224 assert_eq!(
1226 resolve_target(&tabs, Some("9")).unwrap_err().0,
1227 StatusCode::NOT_FOUND
1228 );
1229 }
1230
1231 #[test]
1232 fn resolve_target_by_unique_origin() {
1233 let mut tabs = HashMap::new();
1234 tabs.insert(1, tab(Some("https://a.test")));
1235 tabs.insert(2, tab(Some("https://b.test")));
1236 let (id, _s) = resolve_target(&tabs, Some("https://b.test")).unwrap();
1237 assert_eq!(id, 2);
1238 assert_eq!(
1240 resolve_target(&tabs, Some("https://nope.test"))
1241 .unwrap_err()
1242 .0,
1243 StatusCode::NOT_FOUND
1244 );
1245 }
1246
1247 #[test]
1248 fn resolve_target_ambiguous_origin_is_409() {
1249 let mut tabs = HashMap::new();
1250 tabs.insert(1, tab(Some("https://a.test")));
1251 tabs.insert(2, tab(Some("https://a.test")));
1252 let err = resolve_target(&tabs, Some("https://a.test")).unwrap_err();
1253 assert_eq!(err.0, StatusCode::CONFLICT);
1254 assert!(err.1.contains("connection id"));
1256 }
1257
1258 #[test]
1259 fn target_header_trims_and_drops_empty() {
1260 let mut h = axum::http::HeaderMap::new();
1261 assert_eq!(target_header(&h), None);
1262 h.insert(auth::BRIDGE_TARGET_HEADER, " 2 ".parse().unwrap());
1263 assert_eq!(target_header(&h).as_deref(), Some("2"));
1264 h.insert(auth::BRIDGE_TARGET_HEADER, " ".parse().unwrap());
1265 assert_eq!(target_header(&h), None);
1266 }
1267
1268 #[test]
1269 fn tab_list_renders_id_with_and_without_origin() {
1270 let mut tabs = HashMap::new();
1271 tabs.insert(1, tab(Some("https://a.test")));
1272 tabs.insert(2, tab(None));
1273 assert_eq!(tab_list(&tabs), "id 1: https://a.test, id 2");
1275 }
1276
1277 fn test_state() -> AppState {
1280 AppState {
1281 token: Arc::new("t".to_string()),
1282 config: Arc::new(BridgeConfig {
1283 ws_port: 0,
1284 control_port: 0,
1285 request_timeout: Duration::from_secs(5),
1286 allow_origin: None,
1287 max_body_bytes: 1024,
1288 max_concurrent: 8,
1289 }),
1290 correlator: Correlator::new(),
1291 tabs: Arc::new(Mutex::new(HashMap::new())),
1292 in_flight: Arc::new(Semaphore::new(8)),
1293 conn_counter: Arc::new(AtomicU64::new(1)),
1294 }
1295 }
1296
1297 async fn insert_dead_tab(state: &AppState, id: u64) {
1300 let (sender, rx) = mpsc::unbounded_channel();
1301 drop(rx);
1302 state.tabs.lock().await.insert(
1303 id,
1304 WsConn {
1305 sender,
1306 origin: None,
1307 },
1308 );
1309 }
1310
1311 fn plain_request() -> ControlRequest {
1312 ControlRequest {
1313 url: "/x".to_string(),
1314 method: "GET".to_string(),
1315 headers: BTreeMap::new(),
1316 body: None,
1317 stream: false,
1318 target: None,
1319 allow_origin: None,
1320 credentials: None,
1321 }
1322 }
1323
1324 fn state_with_global_origin(global: Option<&str>) -> AppState {
1327 let mut state = test_state();
1328 let mut config = (*state.config).clone();
1329 config.allow_origin = global.map(str::to_string);
1330 state.config = Arc::new(config);
1331 state
1332 }
1333
1334 #[test]
1335 fn resolve_allow_origin_prefers_per_request_override() {
1336 let state = state_with_global_origin(Some("https://global.test"));
1337 let req = ControlRequest {
1338 allow_origin: Some("https://per-request.test".to_string()),
1339 ..plain_request()
1340 };
1341 assert_eq!(
1343 resolve_allow_origin(&req, &state),
1344 Some("https://per-request.test")
1345 );
1346 assert_eq!(
1349 auth::validate_outbound_url(
1350 "https://per-request.test/x",
1351 resolve_allow_origin(&req, &state)
1352 ),
1353 Ok(())
1354 );
1355 assert!(auth::validate_outbound_url(
1356 "https://other.test/x",
1357 resolve_allow_origin(&req, &state)
1358 )
1359 .is_err());
1360 }
1361
1362 #[test]
1363 fn resolve_allow_origin_falls_back_to_global() {
1364 let state = state_with_global_origin(Some("https://global.test"));
1365 let req = plain_request();
1366 assert!(req.allow_origin.is_none());
1367 assert_eq!(
1369 resolve_allow_origin(&req, &state),
1370 Some("https://global.test")
1371 );
1372 }
1373
1374 #[test]
1375 fn per_request_override_does_not_affect_ws_origin_gate() {
1376 let state = state_with_global_origin(None);
1380 let req = ControlRequest {
1381 allow_origin: Some("https://b.test".to_string()),
1382 ..plain_request()
1383 };
1384 assert_eq!(
1386 auth::validate_outbound_url("https://b.test/x", resolve_allow_origin(&req, &state)),
1387 Ok(())
1388 );
1389 assert!(auth::ws_origin_allowed(
1392 Some("https://a.test"),
1393 state.config.allow_origin.as_deref()
1394 ));
1395 }
1396
1397 #[tokio::test]
1398 async fn dispatch_returns_503_when_send_fails() {
1399 let state = test_state();
1400 insert_dead_tab(&state, 1).await;
1401 let err = dispatch(&state, plain_request()).await.unwrap_err();
1402 assert_eq!(err.0, StatusCode::SERVICE_UNAVAILABLE);
1403 assert_eq!(state.correlator.pending_count(), 0);
1405 }
1406
1407 #[tokio::test]
1408 async fn start_stream_returns_503_when_send_fails() {
1409 let state = test_state();
1410 insert_dead_tab(&state, 1).await;
1411 let req = ControlRequest {
1412 stream: true,
1413 ..plain_request()
1414 };
1415 let err = start_stream(&state, req).await.err().map(|e| e.0);
1416 assert_eq!(err, Some(StatusCode::SERVICE_UNAVAILABLE));
1417 assert_eq!(state.correlator.pending_count(), 0);
1418 }
1419
1420 #[test]
1421 fn correlator_register_resolve_round_trip() {
1422 let c = Correlator::new();
1423 let (id, rx) = c.register();
1424 assert_eq!(c.pending_count(), 1);
1425 assert_eq!(c.deliver(buffered_frame(id)), None);
1426 assert_eq!(c.pending_count(), 0);
1427 let reply = rx.now_or_never().unwrap().unwrap();
1428 assert_eq!(reply.id, id);
1429 }
1430
1431 #[test]
1432 fn correlator_stream_forwards_items_until_terminal() {
1433 let c = Correlator::new();
1434 let (id, mut rx) = c.register_stream();
1435 assert_eq!(c.pending_count(), 1);
1436
1437 let mut head = buffered_frame(id);
1438 head.stream = Some(true);
1439 head.body = None;
1440 assert_eq!(c.deliver(head), None);
1441 assert!(matches!(
1442 rx.try_recv(),
1443 Ok(StreamItem::Head { status: 200, .. })
1444 ));
1445 assert_eq!(c.pending_count(), 1);
1447
1448 let mut done = BrowserFrame {
1449 done: Some(true),
1450 ..buffered_frame(id)
1451 };
1452 done.body = None;
1453 assert_eq!(c.deliver(done), None);
1454 assert!(matches!(rx.try_recv(), Ok(StreamItem::End)));
1455 assert_eq!(c.pending_count(), 0);
1456 }
1457
1458 #[test]
1459 fn correlator_deliver_unknown_id_is_noop() {
1460 let c = Correlator::new();
1461 assert_eq!(c.deliver(buffered_frame(999)), None);
1464 assert_eq!(c.pending_count(), 0);
1465 }
1466
1467 #[test]
1468 fn correlator_stream_signals_cancel_when_consumer_gone() {
1469 let c = Correlator::new();
1470 let (id, rx) = c.register_stream();
1471 drop(rx); let mut chunk = buffered_frame(id);
1473 chunk.chunk = Some("aGk=".into());
1474 chunk.body = None;
1475 assert_eq!(c.deliver(chunk), Some(id));
1477 assert_eq!(c.pending_count(), 0);
1478 }
1479
1480 #[test]
1481 fn correlator_ids_are_monotonic() {
1482 let c = Correlator::new();
1483 let (a, _ra) = c.register();
1484 let (b, _rb) = c.register();
1485 assert!(b > a);
1486 }
1487
1488 #[test]
1489 fn correlator_remove_drops_waiter() {
1490 let c = Correlator::new();
1491 let (id, _rx) = c.register();
1492 c.remove(id);
1493 assert_eq!(c.pending_count(), 0);
1494 }
1495
1496 #[test]
1497 fn extract_stream_flag_detects_and_strips_marker() {
1498 assert_eq!(extract_stream_flag(None), (false, None));
1499 assert_eq!(
1500 extract_stream_flag(Some("a=1&b=2")),
1501 (false, Some("a=1&b=2".to_string()))
1502 );
1503 assert_eq!(
1504 extract_stream_flag(Some("a=1&__stream=1&b=2")),
1505 (true, Some("a=1&b=2".to_string()))
1506 );
1507 assert_eq!(extract_stream_flag(Some("__stream")), (true, None));
1509 assert_eq!(extract_stream_flag(Some("__stream=0")), (false, None));
1511 }
1512
1513 #[test]
1514 fn forwardable_headers_drops_control_headers() {
1515 let mut h = axum::http::HeaderMap::new();
1516 h.insert("host", "localhost:9998".parse().unwrap());
1517 h.insert("authorization", "Bearer x".parse().unwrap());
1518 h.insert("x-omni-bridge", "1".parse().unwrap());
1519 h.insert("accept", "application/json".parse().unwrap());
1520 let out = forwardable_headers(&h);
1521 assert!(!out.contains_key("host"));
1522 assert!(!out.contains_key("authorization"));
1523 assert!(!out.contains_key("x-omni-bridge"));
1524 assert_eq!(
1525 out.get("accept").map(String::as_str),
1526 Some("application/json")
1527 );
1528 }
1529
1530 #[test]
1531 fn envelope_to_response_passes_text_body_through() {
1532 let env = ResponseEnvelope {
1533 id: 1,
1534 status: 200,
1535 headers: BTreeMap::new(),
1536 body: "hello".into(),
1537 encoding: None,
1538 };
1539 assert_eq!(envelope_to_response(env).status(), StatusCode::OK);
1540 }
1541
1542 #[test]
1543 fn envelope_to_response_rejects_invalid_base64() {
1544 let env = ResponseEnvelope {
1547 id: 1,
1548 status: 200,
1549 headers: BTreeMap::new(),
1550 body: "not valid base64 @@@".into(),
1551 encoding: Some("base64".into()),
1552 };
1553 assert_eq!(envelope_to_response(env).status(), StatusCode::BAD_GATEWAY);
1554 }
1555
1556 use futures::FutureExt;
1557}