Make dashboard work from proxied origins via same-origin proxy (#1111)
* Restore dashboard session proxy routes Change-Id: I36ffc3727ce44100121bc94a81510a5f009ee0bc Signed-off-by: Thomas Kosiewski <tk@coder.com> * Port dashboard frontend and docs Change-Id: I80356f64d618dab9d07b610ba67def14539f98ac Signed-off-by: Thomas Kosiewski <tk@coder.com> * docs: restore dashboard note in skill Change-Id: Id0913c64e7a6f2cbbfc429ef03b34dae185d8487 Signed-off-by: Thomas Kosiewski <tk@coder.com> * fix: tighten dashboard proxy same-origin checks Change-Id: I792bc859a24cd47314bd46c94344ef3dfb7d6db5 Signed-off-by: Thomas Kosiewski <tk@coder.com> --------- Signed-off-by: Thomas Kosiewski <tk@coder.com>
This commit is contained in:
@@ -752,7 +752,7 @@ agent-browser open example.com
|
||||
agent-browser dashboard stop
|
||||
```
|
||||
|
||||
The dashboard runs as a standalone background process on port 4848, independent of browser sessions. It stays available even when no sessions are running. All sessions automatically stream to the dashboard.
|
||||
The dashboard runs as a standalone background process on port 4848, independent of browser sessions. It stays available even when no sessions are running, and it works from `http://localhost:4848` or a proxied/forwarded URL that reaches the dashboard server, such as `https://dashboard.agent-browser.localhost` or a Coder workspace URL. The browser stays on the dashboard origin; session-specific tabs, status, and stream traffic are proxied internally, so session ports do not need to be exposed.
|
||||
|
||||
The dashboard displays:
|
||||
- **Live viewport** -- real-time JPEG frames from the browser
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use tokio::io::AsyncWriteExt;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::TcpListener;
|
||||
use tokio_tungstenite::tungstenite::Message;
|
||||
|
||||
use crate::connection::get_socket_dir;
|
||||
|
||||
@@ -9,6 +10,418 @@ use super::chat::{chat_status_json, handle_chat_request, handle_models_request};
|
||||
use super::discovery::discover_sessions;
|
||||
use super::http::{serve_embedded_file, CORS_HEADERS};
|
||||
|
||||
/// Dashboard same-origin proxy endpoints for session metadata and streams.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum SessionProxyEndpoint {
|
||||
Tabs,
|
||||
Status,
|
||||
Stream,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
struct DashboardProxyError {
|
||||
status: &'static str,
|
||||
message: String,
|
||||
}
|
||||
|
||||
impl DashboardProxyError {
|
||||
fn not_found(message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
status: "404 Not Found",
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn bad_gateway(message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
status: "502 Bad Gateway",
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const PROXY_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
|
||||
const PROXY_MAX_RESPONSE_SIZE: u64 = 16 * 1024 * 1024;
|
||||
|
||||
fn build_json_error_body(error: &str) -> String {
|
||||
let escaped = serde_json::to_string(error).unwrap_or_else(|_| format!("\"{}\"", error));
|
||||
format!(r#"{{"success":false,"error":{escaped}}}"#)
|
||||
}
|
||||
|
||||
async fn write_http_response_inner(
|
||||
stream: &mut tokio::net::TcpStream,
|
||||
status: &str,
|
||||
content_type: &str,
|
||||
body: &[u8],
|
||||
include_cors: bool,
|
||||
) {
|
||||
let cors_headers = if include_cors { CORS_HEADERS } else { "" };
|
||||
let response = format!(
|
||||
"HTTP/1.1 {status}\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\nConnection: close\r\n{cors_headers}\r\n",
|
||||
body.len()
|
||||
);
|
||||
let _ = stream.write_all(response.as_bytes()).await;
|
||||
let _ = stream.write_all(body).await;
|
||||
}
|
||||
|
||||
async fn write_http_response(
|
||||
stream: &mut tokio::net::TcpStream,
|
||||
status: &str,
|
||||
content_type: &str,
|
||||
body: &[u8],
|
||||
) {
|
||||
write_http_response_inner(stream, status, content_type, body, true).await;
|
||||
}
|
||||
|
||||
async fn write_http_response_no_cors(
|
||||
stream: &mut tokio::net::TcpStream,
|
||||
status: &str,
|
||||
content_type: &str,
|
||||
body: &[u8],
|
||||
) {
|
||||
write_http_response_inner(stream, status, content_type, body, false).await;
|
||||
}
|
||||
|
||||
async fn write_json_error_response_no_cors(
|
||||
stream: &mut tokio::net::TcpStream,
|
||||
status: &'static str,
|
||||
error: &str,
|
||||
) {
|
||||
let body = build_json_error_body(error);
|
||||
write_http_response_no_cors(
|
||||
stream,
|
||||
status,
|
||||
"application/json; charset=utf-8",
|
||||
body.as_bytes(),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
fn parse_request_method_and_path(request: &str) -> (&str, &str) {
|
||||
let first_line = request.lines().next().unwrap_or("");
|
||||
let method = first_line.split_whitespace().next().unwrap_or("GET");
|
||||
let path = first_line.split_whitespace().nth(1).unwrap_or("/");
|
||||
(method, path)
|
||||
}
|
||||
|
||||
fn is_websocket_upgrade(request: &str) -> bool {
|
||||
request.lines().any(|line| {
|
||||
if let Some((name, value)) = line.split_once(':') {
|
||||
name.trim().eq_ignore_ascii_case("upgrade")
|
||||
&& value.trim().eq_ignore_ascii_case("websocket")
|
||||
} else {
|
||||
false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn request_header_value<'a>(request: &'a str, name: &str) -> Option<&'a str> {
|
||||
request.lines().find_map(|line| {
|
||||
let (header_name, value) = line.split_once(':')?;
|
||||
if header_name.trim().eq_ignore_ascii_case(name) {
|
||||
Some(value.trim())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn normalize_origin_authority(origin: &str) -> Option<String> {
|
||||
let url = url::Url::parse(origin).ok()?;
|
||||
let host = url.host_str()?.to_ascii_lowercase();
|
||||
let host = if host.contains(':') {
|
||||
format!("[{host}]")
|
||||
} else {
|
||||
host
|
||||
};
|
||||
Some(match url.port() {
|
||||
Some(port) => format!("{host}:{port}"),
|
||||
None => host,
|
||||
})
|
||||
}
|
||||
|
||||
fn normalize_host_authority(host: &str) -> String {
|
||||
let host = host.trim().to_ascii_lowercase();
|
||||
|
||||
if let Some(bracket_end) = host.rfind(']') {
|
||||
if bracket_end == host.len() - 1 {
|
||||
return host;
|
||||
}
|
||||
|
||||
if host.as_bytes().get(bracket_end + 1) == Some(&b':') {
|
||||
let port = &host[bracket_end + 2..];
|
||||
if port == "80" || port == "443" {
|
||||
return host[..=bracket_end].to_string();
|
||||
}
|
||||
}
|
||||
|
||||
return host;
|
||||
}
|
||||
|
||||
if let Some((name, port)) = host.rsplit_once(':') {
|
||||
if !name.contains(':') && (port == "80" || port == "443") {
|
||||
return name.to_string();
|
||||
}
|
||||
}
|
||||
|
||||
host
|
||||
}
|
||||
|
||||
fn header_matches_host(request: &str, header_name: &str) -> Option<bool> {
|
||||
let authority =
|
||||
request_header_value(request, header_name).and_then(normalize_origin_authority)?;
|
||||
let host = request_header_value(request, "host").map(normalize_host_authority)?;
|
||||
Some(authority == host)
|
||||
}
|
||||
|
||||
/// Validates that a proxied WebSocket request either has no Origin header or
|
||||
/// presents an Origin whose authority matches the request Host header.
|
||||
fn is_same_origin_ws_request(request: &str) -> bool {
|
||||
match header_matches_host(request, "origin") {
|
||||
Some(matches) => matches,
|
||||
None => request_header_value(request, "origin").is_none(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Validates that an HTTP session-proxy request came from a same-origin page.
|
||||
///
|
||||
/// For GET requests we require either a same-origin `Origin` or a same-origin
|
||||
/// `Referer` so browsers cannot hit the proxy routes via side-channel tags or
|
||||
/// arbitrary cross-origin fetches.
|
||||
fn is_same_origin_http_request(request: &str) -> bool {
|
||||
matches!(header_matches_host(request, "origin"), Some(true))
|
||||
|| matches!(header_matches_host(request, "referer"), Some(true))
|
||||
}
|
||||
|
||||
/// Parse a dashboard route of the form `/api/session/<port>/<endpoint>`.
|
||||
fn parse_session_proxy_route(path: &str) -> Result<(u16, SessionProxyEndpoint), &'static str> {
|
||||
if !path.starts_with("/api/session/") {
|
||||
return Err("Invalid session proxy route.");
|
||||
}
|
||||
|
||||
let mut parts = path.split('/');
|
||||
if parts.next() != Some("") || parts.next() != Some("api") || parts.next() != Some("session") {
|
||||
return Err("Invalid session proxy route.");
|
||||
}
|
||||
|
||||
let port_str = parts.next().ok_or("Missing session proxy port.")?;
|
||||
if port_str.is_empty() {
|
||||
return Err("Missing session proxy port.");
|
||||
}
|
||||
|
||||
let endpoint = match parts.next().ok_or("Missing session proxy endpoint.")? {
|
||||
"tabs" => SessionProxyEndpoint::Tabs,
|
||||
"status" => SessionProxyEndpoint::Status,
|
||||
"stream" => SessionProxyEndpoint::Stream,
|
||||
_ => return Err("Unknown session proxy endpoint."),
|
||||
};
|
||||
|
||||
if parts.next().is_some() {
|
||||
return Err("Unexpected path segments in session proxy route.");
|
||||
}
|
||||
|
||||
let port = port_str
|
||||
.parse::<u16>()
|
||||
.map_err(|_| "Session proxy port must be a valid TCP port.")?;
|
||||
if port == 0 {
|
||||
return Err("Session proxy port must be a valid TCP port.");
|
||||
}
|
||||
|
||||
Ok((port, endpoint))
|
||||
}
|
||||
|
||||
fn sessions_json_has_active_port(sessions_json: &str, port: u16) -> Result<bool, String> {
|
||||
let sessions: Vec<Value> = serde_json::from_str(sessions_json)
|
||||
.map_err(|e| format!("Failed to parse active sessions: {e}"))?;
|
||||
Ok(sessions.iter().any(|session| {
|
||||
session
|
||||
.get("port")
|
||||
.and_then(|value| value.as_u64())
|
||||
.map(|value| value == u64::from(port))
|
||||
.unwrap_or(false)
|
||||
}))
|
||||
}
|
||||
|
||||
fn require_active_session_port(port: u16) -> Result<(), DashboardProxyError> {
|
||||
let sessions_json = discover_sessions();
|
||||
let is_active = sessions_json_has_active_port(&sessions_json, port)
|
||||
.map_err(DashboardProxyError::bad_gateway)?;
|
||||
if is_active {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(DashboardProxyError::not_found(format!(
|
||||
"No active session is listening on port {port}."
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
fn split_http_response(response: &[u8]) -> Result<(&[u8], &[u8]), String> {
|
||||
if let Some(header_end) = response.windows(4).position(|window| window == b"\r\n\r\n") {
|
||||
let body_start = header_end + 4;
|
||||
return Ok((&response[..header_end], &response[body_start..]));
|
||||
}
|
||||
|
||||
if let Some(header_end) = response.windows(2).position(|window| window == b"\n\n") {
|
||||
let body_start = header_end + 2;
|
||||
return Ok((&response[..header_end], &response[body_start..]));
|
||||
}
|
||||
|
||||
Err("Upstream response was missing an HTTP header terminator.".to_string())
|
||||
}
|
||||
|
||||
fn parse_upstream_http_response(response: &[u8]) -> Result<(String, String, Vec<u8>), String> {
|
||||
let (header_bytes, body) = split_http_response(response)?;
|
||||
let header_str = std::str::from_utf8(header_bytes)
|
||||
.map_err(|e| format!("Upstream response headers were not valid UTF-8: {e}"))?;
|
||||
|
||||
let mut lines = header_str.lines();
|
||||
let status_line = lines
|
||||
.next()
|
||||
.ok_or_else(|| "Upstream response was missing a status line.".to_string())?;
|
||||
let status = status_line
|
||||
.split_once(' ')
|
||||
.map(|(_, status)| status.trim().to_string())
|
||||
.filter(|status| !status.is_empty())
|
||||
.ok_or_else(|| "Upstream response status line was malformed.".to_string())?;
|
||||
let content_type = lines
|
||||
.find_map(|line| {
|
||||
let (name, value) = line.split_once(':')?;
|
||||
if name.trim().eq_ignore_ascii_case("content-type") {
|
||||
Some(value.trim().to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.unwrap_or_else(|| "application/json; charset=utf-8".to_string());
|
||||
|
||||
Ok((status, content_type, body.to_vec()))
|
||||
}
|
||||
|
||||
/// Proxy dashboard-origin HTTP requests for session tabs or status to the loopback session server.
|
||||
async fn proxy_session_http_route(
|
||||
port: u16,
|
||||
endpoint: SessionProxyEndpoint,
|
||||
) -> Result<(String, String, Vec<u8>), DashboardProxyError> {
|
||||
debug_assert!(matches!(
|
||||
endpoint,
|
||||
SessionProxyEndpoint::Tabs | SessionProxyEndpoint::Status
|
||||
));
|
||||
|
||||
require_active_session_port(port)?;
|
||||
|
||||
let upstream_path = match endpoint {
|
||||
SessionProxyEndpoint::Tabs => "/api/tabs",
|
||||
SessionProxyEndpoint::Status => "/api/status",
|
||||
SessionProxyEndpoint::Stream => unreachable!("stream routes use the WebSocket proxy"),
|
||||
};
|
||||
let request = format!(
|
||||
"GET {upstream_path} HTTP/1.1\r\nHost: 127.0.0.1:{port}\r\nConnection: close\r\n\r\n"
|
||||
);
|
||||
|
||||
tokio::time::timeout(PROXY_TIMEOUT, async {
|
||||
let mut upstream = tokio::net::TcpStream::connect(("127.0.0.1", port))
|
||||
.await
|
||||
.map_err(|e| {
|
||||
DashboardProxyError::bad_gateway(format!(
|
||||
"Failed to connect to session {port}: {e}"
|
||||
))
|
||||
})?;
|
||||
upstream.write_all(request.as_bytes()).await.map_err(|e| {
|
||||
DashboardProxyError::bad_gateway(format!(
|
||||
"Failed to proxy request to session {port}: {e}"
|
||||
))
|
||||
})?;
|
||||
|
||||
let mut response = Vec::new();
|
||||
(&mut upstream)
|
||||
.take(PROXY_MAX_RESPONSE_SIZE + 1)
|
||||
.read_to_end(&mut response)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
DashboardProxyError::bad_gateway(format!(
|
||||
"Failed to read session {port} response: {e}"
|
||||
))
|
||||
})?;
|
||||
if response.len() as u64 > PROXY_MAX_RESPONSE_SIZE {
|
||||
return Err(DashboardProxyError::bad_gateway(format!(
|
||||
"Session {port} response exceeded {PROXY_MAX_RESPONSE_SIZE} bytes."
|
||||
)));
|
||||
}
|
||||
|
||||
parse_upstream_http_response(&response).map_err(DashboardProxyError::bad_gateway)
|
||||
})
|
||||
.await
|
||||
.map_err(|_| {
|
||||
DashboardProxyError::bad_gateway(format!(
|
||||
"Session {port} proxy request timed out after {}s.",
|
||||
PROXY_TIMEOUT.as_secs()
|
||||
))
|
||||
})?
|
||||
}
|
||||
|
||||
/// Bridge a dashboard-origin WebSocket upgrade to the loopback session stream.
|
||||
async fn proxy_session_stream(mut stream: tokio::net::TcpStream, port: u16) {
|
||||
let upstream_url = format!("ws://127.0.0.1:{port}");
|
||||
let (upstream_ws, _) = match tokio_tungstenite::connect_async(&upstream_url).await {
|
||||
Ok(ws) => ws,
|
||||
Err(error) => {
|
||||
write_json_error_response_no_cors(
|
||||
&mut stream,
|
||||
"502 Bad Gateway",
|
||||
&format!("Failed to connect to session {port}: {error}"),
|
||||
)
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
};
|
||||
let client_ws = match tokio_tungstenite::accept_async(stream).await {
|
||||
Ok(ws) => ws,
|
||||
Err(_) => return,
|
||||
};
|
||||
|
||||
let (mut client_tx, mut client_rx) = client_ws.split();
|
||||
let (mut upstream_tx, mut upstream_rx) = upstream_ws.split();
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
message = client_rx.next() => {
|
||||
match message {
|
||||
Some(Ok(message)) => {
|
||||
let is_close = matches!(message, Message::Close(_));
|
||||
if upstream_tx.send(message).await.is_err() {
|
||||
break;
|
||||
}
|
||||
if is_close {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Some(Err(_)) | None => {
|
||||
let _ = upstream_tx.send(Message::Close(None)).await;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
message = upstream_rx.next() => {
|
||||
match message {
|
||||
Some(Ok(message)) => {
|
||||
let is_close = matches!(message, Message::Close(_));
|
||||
if client_tx.send(message).await.is_err() {
|
||||
break;
|
||||
}
|
||||
if is_close {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Some(Err(_)) | None => {
|
||||
let _ = client_tx.send(Message::Close(None)).await;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn run_dashboard_server(port: u16) {
|
||||
let addr = format!("127.0.0.1:{}", port);
|
||||
let listener = match TcpListener::bind(&addr).await {
|
||||
@@ -30,25 +443,82 @@ pub async fn run_dashboard_server(port: u16) {
|
||||
}
|
||||
|
||||
async fn handle_dashboard_connection(mut stream: tokio::net::TcpStream) {
|
||||
use tokio::io::AsyncReadExt;
|
||||
|
||||
let mut buf = vec![0u8; 8192];
|
||||
let peeked_len = match stream.peek(&mut buf).await {
|
||||
Ok(n) if n > 0 => n,
|
||||
_ => return,
|
||||
};
|
||||
let peeked_request = String::from_utf8_lossy(&buf[..peeked_len]);
|
||||
let (peeked_method, peeked_path) = parse_request_method_and_path(&peeked_request);
|
||||
|
||||
if peeked_path.starts_with("/api/session/") {
|
||||
let (port, endpoint) = match parse_session_proxy_route(peeked_path) {
|
||||
Ok(route) => route,
|
||||
Err(error) => {
|
||||
write_json_error_response_no_cors(&mut stream, "400 Bad Request", error).await;
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
match endpoint {
|
||||
SessionProxyEndpoint::Stream => {
|
||||
if peeked_method != "GET" {
|
||||
write_json_error_response_no_cors(
|
||||
&mut stream,
|
||||
"400 Bad Request",
|
||||
"Session stream proxy only supports GET WebSocket upgrades.",
|
||||
)
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
if !is_websocket_upgrade(&peeked_request) {
|
||||
write_json_error_response_no_cors(
|
||||
&mut stream,
|
||||
"400 Bad Request",
|
||||
"Session stream proxy requires a WebSocket upgrade request.",
|
||||
)
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
if !is_same_origin_ws_request(&peeked_request) {
|
||||
write_json_error_response_no_cors(
|
||||
&mut stream,
|
||||
"403 Forbidden",
|
||||
"Origin does not match Host header.",
|
||||
)
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
if let Err(error) = require_active_session_port(port) {
|
||||
write_json_error_response_no_cors(&mut stream, error.status, &error.message)
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
proxy_session_stream(stream, port).await;
|
||||
return;
|
||||
}
|
||||
SessionProxyEndpoint::Tabs | SessionProxyEndpoint::Status => {
|
||||
if peeked_method != "GET" {
|
||||
write_json_error_response_no_cors(
|
||||
&mut stream,
|
||||
"400 Bad Request",
|
||||
"Session proxy routes only support GET requests.",
|
||||
)
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let n = match stream.read(&mut buf).await {
|
||||
Ok(n) if n > 0 => n,
|
||||
_ => return,
|
||||
};
|
||||
|
||||
let header_str = std::str::from_utf8(&buf[..n]).unwrap_or("");
|
||||
let first_line = header_str.lines().next().unwrap_or("").to_string();
|
||||
let method = first_line.split_whitespace().next().unwrap_or("GET");
|
||||
let path = first_line.split_whitespace().nth(1).unwrap_or("/");
|
||||
let origin = header_str.lines().find_map(|line| {
|
||||
if line.len() > 8 && line[..8].eq_ignore_ascii_case("origin: ") {
|
||||
Some(line[8..].trim().to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
});
|
||||
let request = String::from_utf8_lossy(&buf[..n]).to_string();
|
||||
let (method, path) = parse_request_method_and_path(&request);
|
||||
let origin = request_header_value(&request, "origin").map(|value| value.to_string());
|
||||
|
||||
if method == "OPTIONS" {
|
||||
let response = format!(
|
||||
@@ -80,23 +550,67 @@ async fn handle_dashboard_connection(mut stream: tokio::net::TcpStream) {
|
||||
};
|
||||
let (status, resp_body) = match result {
|
||||
Ok(msg) => ("200 OK", msg),
|
||||
Err(e) => (
|
||||
"400 Bad Request",
|
||||
format!(
|
||||
r#"{{"success":false,"error":{}}}"#,
|
||||
serde_json::to_string(&e).unwrap_or_else(|_| format!("\"{}\"", e))
|
||||
),
|
||||
),
|
||||
Err(e) => ("400 Bad Request", build_json_error_body(&e)),
|
||||
};
|
||||
let response = format!(
|
||||
"HTTP/1.1 {status}\r\nContent-Type: application/json; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n{CORS_HEADERS}\r\n",
|
||||
resp_body.len()
|
||||
);
|
||||
let _ = stream.write_all(response.as_bytes()).await;
|
||||
let _ = stream.write_all(resp_body.as_bytes()).await;
|
||||
write_http_response(
|
||||
&mut stream,
|
||||
status,
|
||||
"application/json; charset=utf-8",
|
||||
resp_body.as_bytes(),
|
||||
)
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
|
||||
if path.starts_with("/api/session/") {
|
||||
let (port, endpoint) = match parse_session_proxy_route(path) {
|
||||
Ok(route) => route,
|
||||
Err(error) => {
|
||||
write_json_error_response_no_cors(&mut stream, "400 Bad Request", error).await;
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
match endpoint {
|
||||
SessionProxyEndpoint::Tabs | SessionProxyEndpoint::Status => {
|
||||
if !is_same_origin_http_request(&request) {
|
||||
write_json_error_response_no_cors(
|
||||
&mut stream,
|
||||
"403 Forbidden",
|
||||
"Origin or Referer does not match Host header.",
|
||||
)
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
|
||||
match proxy_session_http_route(port, endpoint).await {
|
||||
Ok((status, content_type, body)) => {
|
||||
write_http_response_no_cors(&mut stream, &status, &content_type, &body)
|
||||
.await;
|
||||
}
|
||||
Err(error) => {
|
||||
write_json_error_response_no_cors(
|
||||
&mut stream,
|
||||
error.status,
|
||||
&error.message,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
SessionProxyEndpoint::Stream => {
|
||||
write_json_error_response_no_cors(
|
||||
&mut stream,
|
||||
"400 Bad Request",
|
||||
"Session stream proxy requires a WebSocket upgrade request.",
|
||||
)
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let (status, content_type, body): (&str, &str, Vec<u8>) = if path == "/api/sessions" {
|
||||
(
|
||||
"200 OK",
|
||||
@@ -113,19 +627,10 @@ async fn handle_dashboard_connection(mut stream: tokio::net::TcpStream) {
|
||||
serve_embedded_file(path)
|
||||
};
|
||||
|
||||
let response = format!(
|
||||
"HTTP/1.1 {}\r\nContent-Type: {}\r\nContent-Length: {}\r\nConnection: close\r\n{CORS_HEADERS}\r\n",
|
||||
status,
|
||||
content_type,
|
||||
body.len()
|
||||
);
|
||||
let _ = stream.write_all(response.as_bytes()).await;
|
||||
let _ = stream.write_all(&body).await;
|
||||
write_http_response(&mut stream, status, content_type, &body).await;
|
||||
}
|
||||
|
||||
async fn read_post_body(stream: &mut tokio::net::TcpStream, initial: &[u8], n: usize) -> String {
|
||||
use tokio::io::AsyncReadExt;
|
||||
|
||||
let header_end = initial[..n]
|
||||
.windows(4)
|
||||
.position(|w| w == b"\r\n\r\n")
|
||||
@@ -145,12 +650,12 @@ async fn read_post_body(stream: &mut tokio::net::TcpStream, initial: &[u8], n: u
|
||||
.lines()
|
||||
.find_map(|l| {
|
||||
if l.len() > 16 && l[..16].eq_ignore_ascii_case("content-length: ") {
|
||||
l[16..].trim().parse().ok()
|
||||
l[16..].trim().parse::<usize>().ok()
|
||||
} else {
|
||||
let lower = l.to_lowercase();
|
||||
lower
|
||||
.strip_prefix("content-length:")
|
||||
.and_then(|v| v.trim().parse().ok())
|
||||
.and_then(|v| v.trim().parse::<usize>().ok())
|
||||
}
|
||||
})
|
||||
.unwrap_or(0);
|
||||
@@ -239,11 +744,15 @@ async fn kill_session(body: &str) -> Result<String, String> {
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
// SAFETY: The PID came from the daemon-managed pidfile and is only used
|
||||
// to send standard termination signals to that process.
|
||||
unsafe {
|
||||
libc::kill(pid as i32, libc::SIGTERM);
|
||||
}
|
||||
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
|
||||
// SAFETY: A signal value of 0 performs an existence check on the same pid.
|
||||
if unsafe { libc::kill(pid as i32, 0) } == 0 {
|
||||
// SAFETY: The process still exists after SIGTERM, so escalate to SIGKILL.
|
||||
unsafe {
|
||||
libc::kill(pid as i32, libc::SIGKILL);
|
||||
}
|
||||
@@ -293,3 +802,159 @@ pub(super) async fn spawn_session(body: &str) -> Result<String, String> {
|
||||
Err(format!("Session process exited with {}", status))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_same_origin_ws_request_matching() {
|
||||
let req = "GET /api/session/9222/stream HTTP/1.1\r\nHost: localhost:4848\r\nOrigin: http://localhost:4848\r\nUpgrade: websocket\r\n\r\n";
|
||||
assert!(is_same_origin_ws_request(req));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_same_origin_ws_request_proxied() {
|
||||
let req = "GET /api/session/9222/stream HTTP/1.1\r\nHost: dashboard.agent-browser.localhost\r\nOrigin: https://dashboard.agent-browser.localhost\r\nUpgrade: websocket\r\n\r\n";
|
||||
assert!(is_same_origin_ws_request(req));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_normalize_origin_authority_https_without_port() {
|
||||
assert_eq!(
|
||||
normalize_origin_authority("https://dashboard.agent-browser.localhost"),
|
||||
Some("dashboard.agent-browser.localhost".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_same_origin_ws_request_default_https_port() {
|
||||
let req = "GET /api/session/9222/stream HTTP/1.1\r\nHost: dashboard.agent-browser.localhost:443\r\nOrigin: https://dashboard.agent-browser.localhost\r\nUpgrade: websocket\r\n\r\n";
|
||||
assert!(is_same_origin_ws_request(req));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_same_origin_http_request_matching_origin() {
|
||||
let req = "GET /api/session/9222/tabs HTTP/1.1\r\nHost: localhost:4848\r\nOrigin: http://localhost:4848\r\n\r\n";
|
||||
assert!(is_same_origin_http_request(req));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_same_origin_http_request_matching_referer() {
|
||||
let req = "GET /api/session/9222/tabs HTTP/1.1\r\nHost: dashboard.agent-browser.localhost:443\r\nReferer: https://dashboard.agent-browser.localhost/sessions\r\n\r\n";
|
||||
assert!(is_same_origin_http_request(req));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_same_origin_http_request_rejects_missing_origin_and_referer() {
|
||||
let req = "GET /api/session/9222/tabs HTTP/1.1\r\nHost: localhost:4848\r\n\r\n";
|
||||
assert!(!is_same_origin_http_request(req));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_same_origin_http_request_rejects_cross_origin_referer() {
|
||||
let req = "GET /api/session/9222/tabs HTTP/1.1\r\nHost: localhost:4848\r\nReferer: https://evil.com/path\r\n\r\n";
|
||||
assert!(!is_same_origin_http_request(req));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_same_origin_ws_request_coder() {
|
||||
let req = "GET /api/session/9222/stream HTTP/1.1\r\nHost: workspace.coder.com\r\nOrigin: https://workspace.coder.com\r\nUpgrade: websocket\r\n\r\n";
|
||||
assert!(is_same_origin_ws_request(req));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cross_origin_ws_request_rejected() {
|
||||
let req = "GET /api/session/9222/stream HTTP/1.1\r\nHost: localhost:4848\r\nOrigin: https://evil.com\r\nUpgrade: websocket\r\n\r\n";
|
||||
assert!(!is_same_origin_ws_request(req));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_no_origin_header_allowed() {
|
||||
let req = "GET /api/session/9222/stream HTTP/1.1\r\nHost: localhost:4848\r\nUpgrade: websocket\r\n\r\n";
|
||||
assert!(is_same_origin_ws_request(req));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_session_proxy_route_valid() {
|
||||
assert_eq!(
|
||||
parse_session_proxy_route("/api/session/9222/tabs"),
|
||||
Ok((9222, SessionProxyEndpoint::Tabs))
|
||||
);
|
||||
assert_eq!(
|
||||
parse_session_proxy_route("/api/session/1337/status"),
|
||||
Ok((1337, SessionProxyEndpoint::Status))
|
||||
);
|
||||
assert_eq!(
|
||||
parse_session_proxy_route("/api/session/65535/stream"),
|
||||
Ok((65535, SessionProxyEndpoint::Stream))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_session_proxy_route_invalid() {
|
||||
assert!(parse_session_proxy_route("/api/session/0/tabs").is_err());
|
||||
assert!(parse_session_proxy_route("/api/session/not-a-port/tabs").is_err());
|
||||
assert!(parse_session_proxy_route("/api/session/70000/tabs").is_err());
|
||||
assert!(parse_session_proxy_route("/api/session/9222").is_err());
|
||||
assert!(parse_session_proxy_route("/api/session/9222/unknown").is_err());
|
||||
assert!(parse_session_proxy_route("/api/session/9222/tabs/extra").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_session_proxy_route_path_traversal() {
|
||||
assert!(parse_session_proxy_route("/api/session/9222/tabs/..").is_err());
|
||||
assert!(parse_session_proxy_route("/api/session/9222/tabs/../status").is_err());
|
||||
assert!(parse_session_proxy_route("/api/session/9222/../../etc/passwd").is_err());
|
||||
assert!(parse_session_proxy_route("/api/session/../session/9222/tabs").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_session_proxy_route_double_slashes() {
|
||||
assert!(parse_session_proxy_route("/api/session//9222/tabs").is_err());
|
||||
assert!(parse_session_proxy_route("/api//session/9222/tabs").is_err());
|
||||
assert!(parse_session_proxy_route("//api/session/9222/tabs").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_session_proxy_route_trailing_slash() {
|
||||
assert!(parse_session_proxy_route("/api/session/9222/tabs/").is_err());
|
||||
assert!(parse_session_proxy_route("/api/session/9222/status/").is_err());
|
||||
assert!(parse_session_proxy_route("/api/session/9222/stream/").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_session_proxy_route_encoded_paths() {
|
||||
assert!(parse_session_proxy_route("/api/session/9222/tabs%20extra").is_err());
|
||||
assert!(parse_session_proxy_route("/api/session/%39%32%32%32/tabs").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sessions_json_has_active_port() {
|
||||
let sessions_json = r#"[
|
||||
{"session":"alpha","port":9222,"engine":"chrome"},
|
||||
{"session":"beta","port":9333,"engine":"chrome"}
|
||||
]"#;
|
||||
|
||||
assert_eq!(sessions_json_has_active_port(sessions_json, 9222), Ok(true));
|
||||
assert_eq!(
|
||||
sessions_json_has_active_port(sessions_json, 9444),
|
||||
Ok(false)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sessions_json_has_active_port_invalid_json() {
|
||||
assert!(sessions_json_has_active_port("{", 9222).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_upstream_http_response() {
|
||||
let response = b"HTTP/1.1 200 OK\r\nContent-Type: application/json; charset=utf-8\r\nConnection: close\r\n\r\n{\"ok\":true}";
|
||||
let parsed = parse_upstream_http_response(response).expect("response should parse");
|
||||
|
||||
assert_eq!(parsed.0, "200 OK");
|
||||
assert_eq!(parsed.1, "application/json; charset=utf-8");
|
||||
assert_eq!(parsed.2, b"{\"ok\":true}".to_vec());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2554,6 +2554,11 @@ Running 'agent-browser dashboard' with no subcommand is equivalent to 'dashboard
|
||||
|
||||
The dashboard runs as a standalone background process, independent of
|
||||
browser sessions. All sessions automatically stream to the dashboard.
|
||||
It works from http://localhost:4848 or a proxied/forwarded URL that
|
||||
reaches the dashboard server, such as https://dashboard.agent-browser.localhost
|
||||
or a Coder workspace URL. The browser stays on the dashboard origin;
|
||||
session tabs, status, and stream traffic are proxied internally, so
|
||||
session ports do not need to be exposed.
|
||||
|
||||
Options:
|
||||
--port <n> Port for the dashboard server (default: 4848)
|
||||
|
||||
@@ -381,6 +381,8 @@ agent-browser dashboard start --port <n> # Start on a specific port
|
||||
agent-browser dashboard stop # Stop the dashboard server
|
||||
```
|
||||
|
||||
Open the dashboard through `http://localhost:4848` or a proxied/forwarded dashboard URL such as `https://dashboard.agent-browser.localhost`. The browser stays on the dashboard origin; per-session tabs, status, and stream traffic are proxied internally, so session ports do not need to be exposed.
|
||||
|
||||
## Doctor
|
||||
|
||||
Diagnose your install, auto-clean stale daemon files, and optionally repair common problems.
|
||||
|
||||
@@ -11,9 +11,9 @@ agent-browser dashboard start
|
||||
agent-browser open example.com
|
||||
```
|
||||
|
||||
Then open `http://localhost:4848` in your browser to see the live dashboard.
|
||||
Then open `http://localhost:4848` or a proxied/forwarded dashboard URL such as `https://dashboard.agent-browser.localhost` in your browser to see the live dashboard.
|
||||
|
||||
All sessions automatically stream to the dashboard. No extra flags are needed.
|
||||
All sessions automatically stream to the dashboard. No extra flags are needed. The browser stays on the dashboard origin while the server proxies per-session tabs, status, and stream traffic internally, so session ports do not need to be exposed.
|
||||
|
||||
### Custom stream port
|
||||
|
||||
@@ -122,7 +122,7 @@ The dashboard is a Next.js static export (`output: 'export'`) that produces plai
|
||||
pnpm build:dashboard
|
||||
```
|
||||
|
||||
The dashboard is embedded into the CLI binary at compile time using `rust-embed`. Plain HTTP requests serve the embedded dashboard assets, while WebSocket upgrade requests are handled as before.
|
||||
The dashboard is embedded into the CLI binary at compile time using `rust-embed`. Plain HTTP requests serve the embedded dashboard assets and same-origin API routes. Session-specific tabs, status, and stream WebSocket traffic are proxied through the dashboard server to loopback-only session ports.
|
||||
|
||||
## AI Chat
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useAtomValue, useSetAtom } from "jotai/react";
|
||||
import { ArrowLeft, ArrowRight, Camera, Circle, FileCode, Maximize, Moon, RotateCw, Smartphone, Square, Sun, Wifi, WifiOff } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { execCommand, sessionArgs } from "@/lib/exec";
|
||||
import { getSessionStreamUrl } from "@/lib/dashboard-routes";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import {
|
||||
@@ -550,7 +551,7 @@ export function Viewport() {
|
||||
</span>
|
||||
{browserConnected && (
|
||||
<span className="text-xs text-muted-foreground/60 font-mono">
|
||||
ws://localhost:{streamPort}
|
||||
{getSessionStreamUrl(streamPort)}
|
||||
</span>
|
||||
)}
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* Centralized route building for dashboard API calls.
|
||||
* All routes stay on the current dashboard origin so the UI also works
|
||||
* behind forwarded or reverse-proxied URLs.
|
||||
*/
|
||||
|
||||
/** Build a dashboard API path such as "/api/sessions". */
|
||||
export function getDashboardApiPath(path: string): string {
|
||||
const normalizedPath = path.startsWith("/") ? path : `/${path}`;
|
||||
assertDashboardApiPath(normalizedPath);
|
||||
return normalizedPath;
|
||||
}
|
||||
|
||||
/** Build the same-origin per-session tabs endpoint proxied through the dashboard. */
|
||||
export function getSessionTabsPath(port: number): string {
|
||||
assertValidPort(port);
|
||||
return `/api/session/${port}/tabs`;
|
||||
}
|
||||
|
||||
/** Build the same-origin WebSocket URL for a session stream. */
|
||||
export function getSessionStreamUrl(port: number): string {
|
||||
assertValidPort(port);
|
||||
const streamPath = `/api/session/${port}/stream`;
|
||||
if (typeof window === "undefined") {
|
||||
return streamPath;
|
||||
}
|
||||
|
||||
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||
return `${protocol}//${window.location.host}${streamPath}`;
|
||||
}
|
||||
|
||||
function assertDashboardApiPath(path: string): asserts path is string {
|
||||
if (!path.startsWith("/api/")) {
|
||||
throw new Error(`Assertion failed: Expected dashboard API path, got: ${path}`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertValidPort(port: number): asserts port is number {
|
||||
if (!Number.isInteger(port) || port <= 0 || port > 65535) {
|
||||
throw new Error(`Assertion failed: Invalid session port: ${port}`);
|
||||
}
|
||||
}
|
||||
@@ -5,20 +5,22 @@ import { useCallback, useEffect, useRef } from "react";
|
||||
import { useAtomCallback } from "jotai/utils";
|
||||
import type { SessionInfo } from "@/types";
|
||||
import { type ExecResult, execCommand, killSession, sessionArgs } from "@/lib/exec";
|
||||
import { getDashboardApiPath, getSessionTabsPath } from "@/lib/dashboard-routes";
|
||||
import { tabCacheAtom, engineCacheAtom } from "@/store/tabs";
|
||||
import { streamTabsAtom, streamEngineAtom } from "@/store/stream";
|
||||
|
||||
function getPort(): number {
|
||||
if (typeof window === "undefined") return 9223;
|
||||
if (typeof window === "undefined") return 0;
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const p = params.get("port");
|
||||
return p ? parseInt(p, 10) || 9223 : 9223;
|
||||
const portParam = params.get("port");
|
||||
const port = portParam ? Number.parseInt(portParam, 10) : 0;
|
||||
return Number.isInteger(port) && port > 0 ? port : 0;
|
||||
}
|
||||
|
||||
export const newSessionDialogAtom = atom(false);
|
||||
|
||||
function getSessionsUrl(): string {
|
||||
return "/api/sessions";
|
||||
return getDashboardApiPath("/api/sessions");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -254,9 +256,9 @@ export function useSessionsSync(pollInterval = 5000) {
|
||||
// Poll tabs for all sessions
|
||||
for (const s of data) {
|
||||
try {
|
||||
const tabsResp = await fetch(
|
||||
`http://localhost:${s.port}/api/tabs`,
|
||||
).catch(() => null);
|
||||
const tabsResp = await fetch(getSessionTabsPath(s.port)).catch(
|
||||
() => null,
|
||||
);
|
||||
if (tabsResp?.ok) {
|
||||
const tabs = await tabsResp.json();
|
||||
if (tabs.length > 0) {
|
||||
|
||||
@@ -9,7 +9,7 @@ import type {
|
||||
StreamMessage,
|
||||
TabInfo,
|
||||
} from "@/types";
|
||||
import { activePortAtom } from "@/store/sessions";
|
||||
import { getSessionStreamUrl } from "@/lib/dashboard-routes";
|
||||
import { tabCacheAtom, engineCacheAtom } from "@/store/tabs";
|
||||
|
||||
const MAX_EVENTS = 500;
|
||||
@@ -117,9 +117,10 @@ export function useStreamSync(port: number) {
|
||||
}, [port, setConnected, setBrowserConnected, setScreencasting, setRecording, setVpWidth, setVpHeight, setFrame, setEvents, setConsoleLogs, setTabs, setEngine]);
|
||||
|
||||
const connect = useCallback(() => {
|
||||
if (port <= 0) return;
|
||||
if (wsRef.current?.readyState === WebSocket.OPEN) return;
|
||||
|
||||
const ws = new WebSocket(`ws://localhost:${port}`);
|
||||
const ws = new WebSocket(getSessionStreamUrl(port));
|
||||
wsRef.current = ws;
|
||||
setWsRef(ws);
|
||||
|
||||
|
||||
@@ -49,3 +49,7 @@ installed version.
|
||||
- Accessibility-tree snapshots with element refs for reliable interaction
|
||||
- Sessions, authentication vault, state persistence, video recording
|
||||
- Specialized skills for Electron apps, Slack, exploratory testing, cloud providers
|
||||
|
||||
## Observability Dashboard
|
||||
|
||||
The dashboard runs independently of browser sessions on port 4848 and can also be opened through a proxied or forwarded URL such as `https://dashboard.agent-browser.localhost`. Agents should stay on the dashboard origin: session tabs, status, and stream traffic are proxied internally, so session ports do not need to be exposed.
|
||||
|
||||
Reference in New Issue
Block a user