use std::collections::{BTreeMap, HashMap};
use std::net::Ipv4Addr;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex as StdMutex};
use std::time::Duration;
use anyhow::{Context, Result};
use axum::{
body::{Body, Bytes},
extract::{DefaultBodyLimit, Request, State},
http::{header, StatusCode},
middleware::{self, Next},
response::{IntoResponse, Response},
routing::{get, post},
Json, Router,
};
use base64::engine::general_purpose::STANDARD as BASE64;
use base64::Engine as _;
use futures::{SinkExt, StreamExt};
use tokio::net::{TcpListener, TcpStream};
use tokio::sync::{mpsc, oneshot, Mutex, OwnedSemaphorePermit, Semaphore};
use tokio_tungstenite::tungstenite::Message;
use crate::browser::auth;
use crate::browser::protocol::{
BrowserFrame, BrowserReply, CancelCommand, Command, ControlRequest, ReplyOutcome,
ResponseEnvelope, StatusResponse, StreamItem, StreamLine, TabInfo,
};
use crate::browser::snippet;
#[derive(Debug, Clone)]
pub struct BridgeConfig {
pub ws_port: u16,
pub control_port: u16,
pub request_timeout: Duration,
pub allow_origin: Option<String>,
pub max_body_bytes: usize,
pub max_concurrent: usize,
}
enum Waiter {
Buffered(oneshot::Sender<BrowserReply>),
Stream(mpsc::UnboundedSender<StreamItem>),
}
#[derive(Clone)]
struct Correlator {
pending: Arc<StdMutex<HashMap<u64, Waiter>>>,
next_id: Arc<AtomicU64>,
}
impl Correlator {
fn new() -> Self {
Self {
pending: Arc::new(StdMutex::new(HashMap::new())),
next_id: Arc::new(AtomicU64::new(1)),
}
}
fn register(&self) -> (u64, oneshot::Receiver<BrowserReply>) {
let id = self.next_id.fetch_add(1, Ordering::Relaxed);
let (tx, rx) = oneshot::channel();
self.lock().insert(id, Waiter::Buffered(tx));
(id, rx)
}
fn register_stream(&self) -> (u64, mpsc::UnboundedReceiver<StreamItem>) {
let id = self.next_id.fetch_add(1, Ordering::Relaxed);
let (tx, rx) = mpsc::unbounded_channel();
self.lock().insert(id, Waiter::Stream(tx));
(id, rx)
}
fn remove(&self, id: u64) {
self.lock().remove(&id);
}
fn deliver(&self, frame: BrowserFrame) -> Option<u64> {
let id = frame.id;
let mut guard = self.lock();
match guard.get(&id) {
Some(Waiter::Buffered(_)) => {
if let Some(Waiter::Buffered(tx)) = guard.remove(&id) {
let _ = tx.send(frame.into_reply());
}
None
}
Some(Waiter::Stream(_)) => {
let item = frame.stream_item();
let terminal = matches!(item, StreamItem::End | StreamItem::Error(_));
let send_failed = match guard.get(&id) {
Some(Waiter::Stream(tx)) => tx.send(item).is_err(),
_ => false,
};
if terminal || send_failed {
guard.remove(&id);
}
send_failed.then_some(id)
}
None => None,
}
}
fn pending_count(&self) -> usize {
self.lock().len()
}
fn lock(&self) -> std::sync::MutexGuard<'_, HashMap<u64, Waiter>> {
self.pending
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
}
struct WsConn {
sender: mpsc::UnboundedSender<Message>,
origin: Option<String>,
}
#[derive(Clone)]
struct AppState {
token: Arc<String>,
config: Arc<BridgeConfig>,
correlator: Correlator,
tabs: Arc<Mutex<HashMap<u64, WsConn>>>,
in_flight: Arc<Semaphore>,
conn_counter: Arc<AtomicU64>,
}
pub async fn run(mut config: BridgeConfig, token: String) -> Result<()> {
let control_listener = TcpListener::bind((Ipv4Addr::LOCALHOST, config.control_port))
.await
.with_context(|| {
format!(
"Failed to bind control plane to 127.0.0.1:{} (already in use?)",
config.control_port
)
})?;
let ws_listener = TcpListener::bind((Ipv4Addr::LOCALHOST, config.ws_port))
.await
.with_context(|| {
format!(
"Failed to bind WebSocket plane to 127.0.0.1:{} (already in use?)",
config.ws_port
)
})?;
config.control_port = control_listener.local_addr()?.port();
config.ws_port = ws_listener.local_addr()?.port();
let token = Arc::new(token);
let state = AppState {
token: token.clone(),
config: Arc::new(config.clone()),
correlator: Correlator::new(),
tabs: Arc::new(Mutex::new(HashMap::new())),
in_flight: Arc::new(Semaphore::new(config.max_concurrent)),
conn_counter: Arc::new(AtomicU64::new(1)),
};
print_startup(&config, &token);
let ws_state = state.clone();
tokio::spawn(async move {
loop {
match ws_listener.accept().await {
Ok((stream, _peer)) => {
tokio::spawn(handle_ws_conn(stream, ws_state.clone()));
}
Err(e) => tracing::warn!("WebSocket accept error: {e}"),
}
}
});
let app = control_router(state, config.max_body_bytes);
axum::serve(control_listener, app)
.await
.context("Control-plane server error")
}
fn print_startup(config: &BridgeConfig, token: &str) {
let snippet = snippet::render(config.ws_port, token);
println!("omni-dev browser bridge serve");
println!(" control plane : http://127.0.0.1:{}", config.control_port);
println!(" websocket : ws://127.0.0.1:{}", config.ws_port);
println!(" session token : {token}");
if let Some(origin) = &config.allow_origin {
println!(" allow-origin : {origin}");
}
println!();
println!("Paste this into the DevTools console of the authenticated tab:");
println!();
println!("{snippet}");
println!();
println!("Then drive requests, e.g.:");
println!(
" omni-dev browser bridge request --control-port {} --url /path",
config.control_port
);
}
#[allow(clippy::result_large_err)]
async fn handle_ws_conn(stream: TcpStream, state: AppState) {
use tokio_tungstenite::tungstenite::handshake::server::{ErrorResponse, Request, Response};
let token = state.token.clone();
let allow_origin = state.config.allow_origin.clone();
let captured_origin: Arc<StdMutex<Option<String>>> = Arc::new(StdMutex::new(None));
let co = captured_origin.clone();
let callback =
move |req: &Request, mut response: Response| -> Result<Response, ErrorResponse> {
let origin = req
.headers()
.get("origin")
.and_then(|v| v.to_str().ok())
.map(str::to_string);
if !auth::ws_origin_allowed(origin.as_deref(), allow_origin.as_deref()) {
tracing::warn!("Rejected WebSocket upgrade: origin not allowed");
return Err(ws_error(StatusCode::FORBIDDEN, "origin not allowed"));
}
let protocols: Vec<String> = req
.headers()
.get_all("sec-websocket-protocol")
.iter()
.filter_map(|v| v.to_str().ok())
.flat_map(|s| s.split(',').map(|p| p.trim().to_string()))
.collect();
let Some(matched) =
auth::ws_subprotocol_token(protocols.iter().map(String::as_str), &token)
else {
tracing::warn!("Rejected WebSocket upgrade: missing or invalid token");
return Err(ws_error(
StatusCode::UNAUTHORIZED,
"missing or invalid token",
));
};
if let Ok(value) = matched.parse() {
response
.headers_mut()
.insert("sec-websocket-protocol", value);
}
*co.lock().unwrap_or_else(std::sync::PoisonError::into_inner) = origin;
Ok(response)
};
let ws_stream = match tokio_tungstenite::accept_hdr_async(stream, callback).await {
Ok(s) => s,
Err(e) => {
tracing::debug!("WebSocket handshake failed: {e}");
return;
}
};
let origin = captured_origin
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.take();
let conn_id = state.conn_counter.fetch_add(1, Ordering::Relaxed);
tracing::info!(
"Browser connected (conn {conn_id}{})",
origin
.as_deref()
.map(|o| format!(", origin {o}"))
.unwrap_or_default()
);
let (tx, mut rx) = mpsc::unbounded_channel::<Message>();
{
let mut guard = state.tabs.lock().await;
guard.insert(conn_id, WsConn { sender: tx, origin });
}
let (mut sink, mut read) = ws_stream.split();
let writer = tokio::spawn(async move {
while let Some(msg) = rx.recv().await {
if sink.send(msg).await.is_err() {
break;
}
}
});
while let Some(Ok(msg)) = read.next().await {
match msg {
Message::Text(txt) => match serde_json::from_str::<BrowserFrame>(&txt) {
Ok(frame) => {
if let Some(cancel_id) = state.correlator.deliver(frame) {
send_cancel(&state, conn_id, cancel_id).await;
}
}
Err(e) => tracing::debug!("Unparseable browser frame: {e}"),
},
Message::Close(_) => break,
_ => {}
}
}
writer.abort();
if state.tabs.lock().await.remove(&conn_id).is_some() {
tracing::info!("Browser disconnected (conn {conn_id})");
}
}
fn ws_error(
code: StatusCode,
msg: &str,
) -> tokio_tungstenite::tungstenite::handshake::server::ErrorResponse {
let mut resp = tokio_tungstenite::tungstenite::http::Response::new(Some(msg.to_string()));
*resp.status_mut() = code;
resp
}
fn control_router(state: AppState, max_body_bytes: usize) -> Router {
Router::new()
.route("/__bridge/status", get(status_handler))
.route("/__bridge/request", post(request_handler))
.fallback(proxy_handler)
.layer(DefaultBodyLimit::max(max_body_bytes))
.layer(middleware::from_fn_with_state(state.clone(), guard))
.with_state(state)
}
async fn guard(State(state): State<AppState>, request: Request, next: Next) -> Response {
if request.method() == axum::http::Method::OPTIONS {
return (StatusCode::METHOD_NOT_ALLOWED, "OPTIONS not allowed").into_response();
}
let headers = request.headers();
let get = |name: &str| headers.get(name).and_then(|v| v.to_str().ok());
let host = get(header::HOST.as_str()).unwrap_or("");
if !auth::host_allowed(host, state.config.control_port) {
tracing::warn!("Rejected control-plane request: disallowed Host");
return (StatusCode::BAD_REQUEST, "host not allowed").into_response();
}
if auth::is_browser_originated(get("origin"), get("sec-fetch-site")) {
tracing::warn!("Rejected control-plane request: browser-originated");
return (
StatusCode::FORBIDDEN,
"browser-originated requests are denied",
)
.into_response();
}
if !auth::has_bridge_header(get(auth::BRIDGE_HEADER)) {
return (StatusCode::FORBIDDEN, "missing X-Omni-Bridge: 1").into_response();
}
if !auth::bearer_matches(get(header::AUTHORIZATION.as_str()), &state.token) {
return (StatusCode::UNAUTHORIZED, "invalid or missing bearer token").into_response();
}
next.run(request).await
}
async fn status_handler(State(state): State<AppState>) -> Json<StatusResponse> {
let mut tabs: Vec<TabInfo> = {
let guard = state.tabs.lock().await;
guard
.iter()
.map(|(id, conn)| TabInfo {
id: *id,
origin: conn.origin.clone(),
})
.collect()
};
tabs.sort_by_key(|t| t.id);
let browser_origin = match tabs.as_slice() {
[only] => only.origin.clone(),
_ => None,
};
Json(StatusResponse {
connected: !tabs.is_empty(),
browser_origin,
tabs,
pending: state.correlator.pending_count(),
})
}
async fn request_handler(
State(state): State<AppState>,
headers: axum::http::HeaderMap,
body: Bytes,
) -> Response {
let mut req: ControlRequest = match serde_json::from_slice(&body) {
Ok(r) => r,
Err(e) => {
return (StatusCode::BAD_REQUEST, format!("invalid JSON body: {e}")).into_response()
}
};
if let Some(target) = target_header(&headers) {
req.target = Some(target);
}
if req.stream {
return match start_stream(&state, req).await {
Ok((status, headers, driver)) => ndjson_stream_response(status, headers, driver),
Err((code, msg)) => (code, msg).into_response(),
};
}
match dispatch(&state, req).await {
Ok(env) => Json(env).into_response(),
Err((code, msg)) => (code, msg).into_response(),
}
}
async fn proxy_handler(State(state): State<AppState>, request: Request) -> Response {
let (parts, body) = request.into_parts();
let path = parts.uri.path();
if auth::normalize_request_path(path).is_none() {
return (StatusCode::BAD_REQUEST, "unsafe request path").into_response();
}
let (stream, forwarded_query) = extract_stream_flag(parts.uri.query());
let url = match forwarded_query.as_deref() {
Some(q) => format!("{path}?{q}"),
None => path.to_string(),
};
let headers = forwardable_headers(&parts.headers);
let Ok(body_bytes) = axum::body::to_bytes(body, state.config.max_body_bytes).await else {
return (StatusCode::PAYLOAD_TOO_LARGE, "request body too large").into_response();
};
let body = if body_bytes.is_empty() {
None
} else {
Some(String::from_utf8_lossy(&body_bytes).into_owned())
};
let req = ControlRequest {
url,
method: parts.method.to_string(),
headers,
body,
stream,
target: target_header(&parts.headers),
allow_origin: None,
credentials: None,
};
if stream {
return match start_stream(&state, req).await {
Ok((status, headers, driver)) => raw_stream_response(status, headers, driver),
Err((code, msg)) => (code, msg).into_response(),
};
}
match dispatch(&state, req).await {
Ok(env) => envelope_to_response(env),
Err((code, msg)) => (code, msg).into_response(),
}
}
fn extract_stream_flag(query: Option<&str>) -> (bool, Option<String>) {
let Some(query) = query else {
return (false, None);
};
let mut stream = false;
let kept: Vec<&str> = query
.split('&')
.filter(|kv| {
let (key, value) = match kv.split_once('=') {
Some((k, v)) => (k, Some(v)),
None => (*kv, None),
};
if key == "__stream" {
stream = !matches!(value, Some("0" | "false"));
false
} else {
true
}
})
.collect();
let rebuilt = (!kept.is_empty()).then(|| kept.join("&"));
(stream, rebuilt)
}
fn forwardable_headers(headers: &axum::http::HeaderMap) -> BTreeMap<String, String> {
const DROP: &[&str] = &[
"host",
"authorization",
auth::BRIDGE_HEADER,
auth::BRIDGE_TARGET_HEADER,
"content-length",
"connection",
"accept-encoding",
"origin",
"sec-fetch-site",
"sec-fetch-mode",
"sec-fetch-dest",
];
headers
.iter()
.filter_map(|(k, v)| {
let name = k.as_str();
if DROP.contains(&name) {
return None;
}
v.to_str()
.ok()
.map(|val| (name.to_string(), val.to_string()))
})
.collect()
}
fn target_header(headers: &axum::http::HeaderMap) -> Option<String> {
headers
.get(auth::BRIDGE_TARGET_HEADER)
.and_then(|v| v.to_str().ok())
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_string)
}
fn resolve_target(
tabs: &HashMap<u64, WsConn>,
target: Option<&str>,
) -> Result<(u64, mpsc::UnboundedSender<Message>), (StatusCode, String)> {
if tabs.is_empty() {
return Err((
StatusCode::SERVICE_UNAVAILABLE,
"no browser connected".to_string(),
));
}
let Some(sel) = target else {
let mut it = tabs.iter();
return match (it.next(), it.next()) {
(Some((id, conn)), None) => Ok((*id, conn.sender.clone())),
_ => Err((
StatusCode::CONFLICT,
format!(
"multiple tabs connected; select one with the X-Omni-Bridge-Target \
header or a `target` field ({})",
tab_list(tabs)
),
)),
};
};
if let Ok(id) = sel.parse::<u64>() {
return match tabs.get(&id) {
Some(conn) => Ok((id, conn.sender.clone())),
None => Err((
StatusCode::NOT_FOUND,
format!(
"no connected tab with id {id}; connected: {}",
tab_list(tabs)
),
)),
};
}
let mut hits = tabs
.iter()
.filter(|(_, c)| c.origin.as_deref() == Some(sel));
match (hits.next(), hits.next()) {
(Some((id, conn)), None) => Ok((*id, conn.sender.clone())),
(None, _) => Err((
StatusCode::NOT_FOUND,
format!(
"no connected tab with origin {sel}; connected: {}",
tab_list(tabs)
),
)),
(Some(_), Some(_)) => Err((
StatusCode::CONFLICT,
format!(
"origin {sel} matches multiple tabs; target by connection id ({})",
tab_list(tabs)
),
)),
}
}
fn tab_list(tabs: &HashMap<u64, WsConn>) -> String {
let mut items: Vec<(u64, Option<&str>)> = tabs
.iter()
.map(|(id, c)| (*id, c.origin.as_deref()))
.collect();
items.sort_by_key(|(id, _)| *id);
items
.iter()
.map(|(id, origin)| match origin {
Some(o) => format!("id {id}: {o}"),
None => format!("id {id}"),
})
.collect::<Vec<_>>()
.join(", ")
}
fn resolve_allow_origin<'a>(req: &'a ControlRequest, state: &'a AppState) -> Option<&'a str> {
match req.allow_origin.as_deref() {
Some(origin) => {
tracing::warn!(
"Per-request --allow-origin override in effect; outbound scope for this request \
widened to {origin}"
);
Some(origin)
}
None => state.config.allow_origin.as_deref(),
}
}
async fn dispatch(
state: &AppState,
req: ControlRequest,
) -> Result<ResponseEnvelope, (StatusCode, String)> {
auth::validate_outbound_url(&req.url, resolve_allow_origin(&req, state)).map_err(|_| {
(
StatusCode::FORBIDDEN,
"outbound URL is cross-origin; pass --allow-origin to permit it".to_string(),
)
})?;
for (name, value) in &req.headers {
if !auth::header_is_safe(name, value) {
return Err((
StatusCode::BAD_REQUEST,
"invalid header name or value".to_string(),
));
}
}
let _permit = state.in_flight.clone().try_acquire_owned().map_err(|_| {
(
StatusCode::TOO_MANY_REQUESTS,
"too many in-flight requests".to_string(),
)
})?;
let (id, rx) = state.correlator.register();
let command = Command {
id,
url: req.url,
method: req.method,
headers: req.headers,
body: req.body,
stream: false,
credentials: req.credentials,
};
let frame = match serde_json::to_string(&command) {
Ok(f) => f,
Err(e) => {
state.correlator.remove(id);
return Err((
StatusCode::INTERNAL_SERVER_ERROR,
format!("serialise error: {e}"),
));
}
};
{
let tabs = state.tabs.lock().await;
let (_conn_id, sender) = match resolve_target(&tabs, req.target.as_deref()) {
Ok(t) => t,
Err(e) => {
state.correlator.remove(id);
return Err(e);
}
};
if sender.send(Message::Text(frame)).is_err() {
state.correlator.remove(id);
return Err((
StatusCode::SERVICE_UNAVAILABLE,
"no browser connected".to_string(),
));
}
}
match tokio::time::timeout(state.config.request_timeout, rx).await {
Ok(Ok(reply)) => match reply.outcome() {
ReplyOutcome::Success {
status,
headers,
body,
encoding,
} => {
let decoded_len = match encoding.as_deref() {
None => body.len(),
Some("base64") => match BASE64.decode(body.as_bytes()) {
Ok(bytes) => bytes.len(),
Err(_) => {
return Err((
StatusCode::BAD_GATEWAY,
"browser sent an invalid base64 body".to_string(),
))
}
},
Some(other) => {
return Err((
StatusCode::BAD_GATEWAY,
format!("browser sent an unsupported body encoding: {other}"),
))
}
};
if decoded_len > state.config.max_body_bytes {
return Err((
StatusCode::BAD_GATEWAY,
format!(
"browser response body is {decoded_len} bytes, exceeding the \
--max-body-bytes limit of {} bytes; page the request to fetch \
less per call (e.g. narrow the time range or lower a `limit`/page \
size) or raise --max-body-bytes",
state.config.max_body_bytes
),
));
}
Ok(ResponseEnvelope {
id,
status,
headers,
body,
encoding,
})
}
ReplyOutcome::Error(msg) => Err((
StatusCode::BAD_GATEWAY,
format!("browser fetch failed: {msg}"),
)),
},
Ok(Err(_)) => Err((
StatusCode::BAD_GATEWAY,
"browser connection closed before replying".to_string(),
)),
Err(_) => {
state.correlator.remove(id);
Err((
StatusCode::GATEWAY_TIMEOUT,
"browser did not reply in time".to_string(),
))
}
}
}
async fn send_cancel(state: &AppState, conn_id: u64, id: u64) {
state.correlator.remove(id);
let Ok(frame) = serde_json::to_string(&CancelCommand::new(id)) else {
return;
};
if let Some(conn) = state.tabs.lock().await.get(&conn_id) {
let _ = conn.sender.send(Message::Text(frame));
}
}
async fn start_stream(
state: &AppState,
req: ControlRequest,
) -> Result<(u16, BTreeMap<String, String>, StreamDriver), (StatusCode, String)> {
auth::validate_outbound_url(&req.url, resolve_allow_origin(&req, state)).map_err(|_| {
(
StatusCode::FORBIDDEN,
"outbound URL is cross-origin; pass --allow-origin to permit it".to_string(),
)
})?;
for (name, value) in &req.headers {
if !auth::header_is_safe(name, value) {
return Err((
StatusCode::BAD_REQUEST,
"invalid header name or value".to_string(),
));
}
}
let permit = state.in_flight.clone().try_acquire_owned().map_err(|_| {
(
StatusCode::TOO_MANY_REQUESTS,
"too many in-flight requests".to_string(),
)
})?;
let (id, mut rx) = state.correlator.register_stream();
let command = Command {
id,
url: req.url,
method: req.method,
headers: req.headers,
body: req.body,
stream: true,
credentials: req.credentials,
};
let frame = match serde_json::to_string(&command) {
Ok(f) => f,
Err(e) => {
state.correlator.remove(id);
return Err((
StatusCode::INTERNAL_SERVER_ERROR,
format!("serialise error: {e}"),
));
}
};
let conn_id = {
let tabs = state.tabs.lock().await;
let (conn_id, sender) = match resolve_target(&tabs, req.target.as_deref()) {
Ok(t) => t,
Err(e) => {
state.correlator.remove(id);
return Err(e);
}
};
if sender.send(Message::Text(frame)).is_err() {
state.correlator.remove(id);
return Err((
StatusCode::SERVICE_UNAVAILABLE,
"no browser connected".to_string(),
));
}
conn_id
};
let idle = state.config.request_timeout;
let (status, headers) = match tokio::time::timeout(idle, rx.recv()).await {
Ok(Some(StreamItem::Head { status, headers })) => (status, headers),
Ok(Some(StreamItem::Error(msg))) => {
state.correlator.remove(id);
return Err((
StatusCode::BAD_GATEWAY,
format!("browser fetch failed: {msg}"),
));
}
Ok(Some(_)) => {
state.correlator.remove(id);
return Err((
StatusCode::BAD_GATEWAY,
"browser streamed a body chunk before the response head".to_string(),
));
}
Ok(None) => {
return Err((
StatusCode::BAD_GATEWAY,
"browser connection closed before replying".to_string(),
));
}
Err(_) => {
send_cancel(state, conn_id, id).await;
return Err((
StatusCode::GATEWAY_TIMEOUT,
"browser did not start streaming in time".to_string(),
));
}
};
let driver = StreamDriver {
state: state.clone(),
id,
conn_id,
rx,
idle,
max_body: state.config.max_body_bytes,
sent: 0,
_permit: permit,
done: false,
};
Ok((status, headers, driver))
}
struct StreamDriver {
state: AppState,
id: u64,
conn_id: u64,
rx: mpsc::UnboundedReceiver<StreamItem>,
idle: Duration,
max_body: usize,
sent: usize,
_permit: OwnedSemaphorePermit,
done: bool,
}
enum NextChunk {
Data {
seq: u64,
bytes: Vec<u8>,
},
End,
}
impl StreamDriver {
async fn next_chunk(&mut self) -> NextChunk {
if self.done {
return NextChunk::End;
}
loop {
match tokio::time::timeout(self.idle, self.rx.recv()).await {
Ok(Some(StreamItem::Chunk { seq, data })) => {
let Ok(bytes) = BASE64.decode(data.as_bytes()) else {
return self.abort().await;
};
self.sent = self.sent.saturating_add(bytes.len());
if self.sent > self.max_body {
return self.abort().await;
}
return NextChunk::Data { seq, bytes };
}
Ok(Some(StreamItem::Head { .. })) => {}
Ok(Some(StreamItem::End | StreamItem::Error(_)) | None) => {
return self.finish();
}
Err(_) => return self.abort().await,
}
}
}
fn finish(&mut self) -> NextChunk {
self.done = true;
self.state.correlator.remove(self.id);
NextChunk::End
}
async fn abort(&mut self) -> NextChunk {
self.done = true;
send_cancel(&self.state, self.conn_id, self.id).await;
NextChunk::End
}
}
fn to_ndjson_line(line: &StreamLine) -> String {
let mut s = serde_json::to_string(line).unwrap_or_else(|_| "{}".to_string());
s.push('\n');
s
}
fn raw_stream_response(
status: u16,
headers: BTreeMap<String, String>,
driver: StreamDriver,
) -> Response {
let code = StatusCode::from_u16(status).unwrap_or(StatusCode::BAD_GATEWAY);
let mut builder = Response::builder().status(code);
if let Some(ct) = headers.get("content-type") {
builder = builder.header(header::CONTENT_TYPE, ct);
}
let stream = futures::stream::unfold(driver, |mut driver| async move {
match driver.next_chunk().await {
NextChunk::Data { bytes, .. } => Some((
Ok::<_, std::convert::Infallible>(Bytes::from(bytes)),
driver,
)),
NextChunk::End => None,
}
});
builder
.body(Body::from_stream(stream))
.unwrap_or_else(|_| StatusCode::BAD_GATEWAY.into_response())
}
fn ndjson_stream_response(
status: u16,
headers: BTreeMap<String, String>,
driver: StreamDriver,
) -> Response {
let head_line = to_ndjson_line(&StreamLine::Head { status, headers });
let init = (Some(head_line), driver, false);
let stream = futures::stream::unfold(init, |(head, mut driver, done_emitted)| async move {
if let Some(line) = head {
return Some((
Ok::<_, std::convert::Infallible>(Bytes::from(line)),
(None, driver, done_emitted),
));
}
if done_emitted {
return None;
}
match driver.next_chunk().await {
NextChunk::Data { seq, bytes } => {
let line = to_ndjson_line(&StreamLine::Chunk {
seq,
chunk: BASE64.encode(&bytes),
});
Some((Ok(Bytes::from(line)), (None, driver, false)))
}
NextChunk::End => {
let line = to_ndjson_line(&StreamLine::Done { done: true });
Some((Ok(Bytes::from(line)), (None, driver, true)))
}
}
});
Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, "application/x-ndjson")
.body(Body::from_stream(stream))
.unwrap_or_else(|_| StatusCode::BAD_GATEWAY.into_response())
}
fn envelope_to_response(env: ResponseEnvelope) -> Response {
let status = StatusCode::from_u16(env.status).unwrap_or(StatusCode::BAD_GATEWAY);
let mut builder = Response::builder().status(status);
if let Some(ct) = env.headers.get("content-type") {
builder = builder.header(header::CONTENT_TYPE, ct);
}
let body = match env.encoding.as_deref() {
Some("base64") => match BASE64.decode(env.body.as_bytes()) {
Ok(bytes) => Body::from(bytes),
Err(_) => return StatusCode::BAD_GATEWAY.into_response(),
},
_ => Body::from(env.body),
};
builder
.body(body)
.unwrap_or_else(|_| StatusCode::BAD_GATEWAY.into_response())
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
fn buffered_frame(id: u64) -> BrowserFrame {
BrowserFrame {
id,
status: Some(200),
headers: None,
body: Some("ok".into()),
encoding: None,
error: None,
stream: None,
chunk: None,
seq: None,
done: None,
}
}
fn tab(origin: Option<&str>) -> WsConn {
let (sender, _rx) = mpsc::unbounded_channel();
WsConn {
sender,
origin: origin.map(str::to_string),
}
}
#[test]
fn resolve_target_no_tabs_is_503() {
let tabs = HashMap::new();
let err = resolve_target(&tabs, None).unwrap_err();
assert_eq!(err.0, StatusCode::SERVICE_UNAVAILABLE);
}
#[test]
fn resolve_target_single_tab_routes_without_target() {
let mut tabs = HashMap::new();
tabs.insert(1, tab(Some("https://a.test")));
let (id, _s) = resolve_target(&tabs, None).unwrap();
assert_eq!(id, 1);
}
#[test]
fn resolve_target_multiple_tabs_no_target_is_409() {
let mut tabs = HashMap::new();
tabs.insert(1, tab(Some("https://a.test")));
tabs.insert(2, tab(Some("https://b.test")));
let err = resolve_target(&tabs, None).unwrap_err();
assert_eq!(err.0, StatusCode::CONFLICT);
assert!(err.1.contains("id 1") && err.1.contains("id 2"));
}
#[test]
fn resolve_target_by_connection_id() {
let mut tabs = HashMap::new();
tabs.insert(1, tab(Some("https://a.test")));
tabs.insert(2, tab(Some("https://b.test")));
let (id, _s) = resolve_target(&tabs, Some("2")).unwrap();
assert_eq!(id, 2);
assert_eq!(
resolve_target(&tabs, Some("9")).unwrap_err().0,
StatusCode::NOT_FOUND
);
}
#[test]
fn resolve_target_by_unique_origin() {
let mut tabs = HashMap::new();
tabs.insert(1, tab(Some("https://a.test")));
tabs.insert(2, tab(Some("https://b.test")));
let (id, _s) = resolve_target(&tabs, Some("https://b.test")).unwrap();
assert_eq!(id, 2);
assert_eq!(
resolve_target(&tabs, Some("https://nope.test"))
.unwrap_err()
.0,
StatusCode::NOT_FOUND
);
}
#[test]
fn resolve_target_ambiguous_origin_is_409() {
let mut tabs = HashMap::new();
tabs.insert(1, tab(Some("https://a.test")));
tabs.insert(2, tab(Some("https://a.test")));
let err = resolve_target(&tabs, Some("https://a.test")).unwrap_err();
assert_eq!(err.0, StatusCode::CONFLICT);
assert!(err.1.contains("connection id"));
}
#[test]
fn target_header_trims_and_drops_empty() {
let mut h = axum::http::HeaderMap::new();
assert_eq!(target_header(&h), None);
h.insert(auth::BRIDGE_TARGET_HEADER, " 2 ".parse().unwrap());
assert_eq!(target_header(&h).as_deref(), Some("2"));
h.insert(auth::BRIDGE_TARGET_HEADER, " ".parse().unwrap());
assert_eq!(target_header(&h), None);
}
#[test]
fn tab_list_renders_id_with_and_without_origin() {
let mut tabs = HashMap::new();
tabs.insert(1, tab(Some("https://a.test")));
tabs.insert(2, tab(None));
assert_eq!(tab_list(&tabs), "id 1: https://a.test, id 2");
}
fn test_state() -> AppState {
AppState {
token: Arc::new("t".to_string()),
config: Arc::new(BridgeConfig {
ws_port: 0,
control_port: 0,
request_timeout: Duration::from_secs(5),
allow_origin: None,
max_body_bytes: 1024,
max_concurrent: 8,
}),
correlator: Correlator::new(),
tabs: Arc::new(Mutex::new(HashMap::new())),
in_flight: Arc::new(Semaphore::new(8)),
conn_counter: Arc::new(AtomicU64::new(1)),
}
}
async fn insert_dead_tab(state: &AppState, id: u64) {
let (sender, rx) = mpsc::unbounded_channel();
drop(rx);
state.tabs.lock().await.insert(
id,
WsConn {
sender,
origin: None,
},
);
}
fn plain_request() -> ControlRequest {
ControlRequest {
url: "/x".to_string(),
method: "GET".to_string(),
headers: BTreeMap::new(),
body: None,
stream: false,
target: None,
allow_origin: None,
credentials: None,
}
}
fn state_with_global_origin(global: Option<&str>) -> AppState {
let mut state = test_state();
let mut config = (*state.config).clone();
config.allow_origin = global.map(str::to_string);
state.config = Arc::new(config);
state
}
#[test]
fn resolve_allow_origin_prefers_per_request_override() {
let state = state_with_global_origin(Some("https://global.test"));
let req = ControlRequest {
allow_origin: Some("https://per-request.test".to_string()),
..plain_request()
};
assert_eq!(
resolve_allow_origin(&req, &state),
Some("https://per-request.test")
);
assert_eq!(
auth::validate_outbound_url(
"https://per-request.test/x",
resolve_allow_origin(&req, &state)
),
Ok(())
);
assert!(auth::validate_outbound_url(
"https://other.test/x",
resolve_allow_origin(&req, &state)
)
.is_err());
}
#[test]
fn resolve_allow_origin_falls_back_to_global() {
let state = state_with_global_origin(Some("https://global.test"));
let req = plain_request();
assert!(req.allow_origin.is_none());
assert_eq!(
resolve_allow_origin(&req, &state),
Some("https://global.test")
);
}
#[test]
fn per_request_override_does_not_affect_ws_origin_gate() {
let state = state_with_global_origin(None);
let req = ControlRequest {
allow_origin: Some("https://b.test".to_string()),
..plain_request()
};
assert_eq!(
auth::validate_outbound_url("https://b.test/x", resolve_allow_origin(&req, &state)),
Ok(())
);
assert!(auth::ws_origin_allowed(
Some("https://a.test"),
state.config.allow_origin.as_deref()
));
}
#[tokio::test]
async fn dispatch_returns_503_when_send_fails() {
let state = test_state();
insert_dead_tab(&state, 1).await;
let err = dispatch(&state, plain_request()).await.unwrap_err();
assert_eq!(err.0, StatusCode::SERVICE_UNAVAILABLE);
assert_eq!(state.correlator.pending_count(), 0);
}
#[tokio::test]
async fn start_stream_returns_503_when_send_fails() {
let state = test_state();
insert_dead_tab(&state, 1).await;
let req = ControlRequest {
stream: true,
..plain_request()
};
let err = start_stream(&state, req).await.err().map(|e| e.0);
assert_eq!(err, Some(StatusCode::SERVICE_UNAVAILABLE));
assert_eq!(state.correlator.pending_count(), 0);
}
#[test]
fn correlator_register_resolve_round_trip() {
let c = Correlator::new();
let (id, rx) = c.register();
assert_eq!(c.pending_count(), 1);
assert_eq!(c.deliver(buffered_frame(id)), None);
assert_eq!(c.pending_count(), 0);
let reply = rx.now_or_never().unwrap().unwrap();
assert_eq!(reply.id, id);
}
#[test]
fn correlator_stream_forwards_items_until_terminal() {
let c = Correlator::new();
let (id, mut rx) = c.register_stream();
assert_eq!(c.pending_count(), 1);
let mut head = buffered_frame(id);
head.stream = Some(true);
head.body = None;
assert_eq!(c.deliver(head), None);
assert!(matches!(
rx.try_recv(),
Ok(StreamItem::Head { status: 200, .. })
));
assert_eq!(c.pending_count(), 1);
let mut done = BrowserFrame {
done: Some(true),
..buffered_frame(id)
};
done.body = None;
assert_eq!(c.deliver(done), None);
assert!(matches!(rx.try_recv(), Ok(StreamItem::End)));
assert_eq!(c.pending_count(), 0);
}
#[test]
fn correlator_deliver_unknown_id_is_noop() {
let c = Correlator::new();
assert_eq!(c.deliver(buffered_frame(999)), None);
assert_eq!(c.pending_count(), 0);
}
#[test]
fn correlator_stream_signals_cancel_when_consumer_gone() {
let c = Correlator::new();
let (id, rx) = c.register_stream();
drop(rx); let mut chunk = buffered_frame(id);
chunk.chunk = Some("aGk=".into());
chunk.body = None;
assert_eq!(c.deliver(chunk), Some(id));
assert_eq!(c.pending_count(), 0);
}
#[test]
fn correlator_ids_are_monotonic() {
let c = Correlator::new();
let (a, _ra) = c.register();
let (b, _rb) = c.register();
assert!(b > a);
}
#[test]
fn correlator_remove_drops_waiter() {
let c = Correlator::new();
let (id, _rx) = c.register();
c.remove(id);
assert_eq!(c.pending_count(), 0);
}
#[test]
fn extract_stream_flag_detects_and_strips_marker() {
assert_eq!(extract_stream_flag(None), (false, None));
assert_eq!(
extract_stream_flag(Some("a=1&b=2")),
(false, Some("a=1&b=2".to_string()))
);
assert_eq!(
extract_stream_flag(Some("a=1&__stream=1&b=2")),
(true, Some("a=1&b=2".to_string()))
);
assert_eq!(extract_stream_flag(Some("__stream")), (true, None));
assert_eq!(extract_stream_flag(Some("__stream=0")), (false, None));
}
#[test]
fn forwardable_headers_drops_control_headers() {
let mut h = axum::http::HeaderMap::new();
h.insert("host", "localhost:9998".parse().unwrap());
h.insert("authorization", "Bearer x".parse().unwrap());
h.insert("x-omni-bridge", "1".parse().unwrap());
h.insert("accept", "application/json".parse().unwrap());
let out = forwardable_headers(&h);
assert!(!out.contains_key("host"));
assert!(!out.contains_key("authorization"));
assert!(!out.contains_key("x-omni-bridge"));
assert_eq!(
out.get("accept").map(String::as_str),
Some("application/json")
);
}
#[test]
fn envelope_to_response_passes_text_body_through() {
let env = ResponseEnvelope {
id: 1,
status: 200,
headers: BTreeMap::new(),
body: "hello".into(),
encoding: None,
};
assert_eq!(envelope_to_response(env).status(), StatusCode::OK);
}
#[test]
fn envelope_to_response_rejects_invalid_base64() {
let env = ResponseEnvelope {
id: 1,
status: 200,
headers: BTreeMap::new(),
body: "not valid base64 @@@".into(),
encoding: Some("base64".into()),
};
assert_eq!(envelope_to_response(env).status(), StatusCode::BAD_GATEWAY);
}
use futures::FutureExt;
}