diff --git a/cli/src/native/browser.rs b/cli/src/native/browser.rs index 18fdae1..8adf792 100644 --- a/cli/src/native/browser.rs +++ b/cli/src/native/browser.rs @@ -1285,15 +1285,17 @@ async fn resolve_cdp_url(input: &str) -> Result { } if input.starts_with("http://") || input.starts_with("https://") { - // Parse out the port and discover let parsed = url::Url::parse(input).map_err(|e| format!("Invalid CDP URL: {}", e))?; + let host = parsed + .host_str() + .ok_or_else(|| format!("No host in CDP URL: {}", input))?; let port = parsed.port().unwrap_or(9222); - return discover_cdp_url(port).await; + return discover_cdp_url(host, port).await; } // Try as numeric port if let Ok(port) = input.parse::() { - return discover_cdp_url(port).await; + return discover_cdp_url("127.0.0.1", port).await; } Err(format!( diff --git a/cli/src/native/cdp/chrome.rs b/cli/src/native/cdp/chrome.rs index 21cc8c1..5f3ec40 100644 --- a/cli/src/native/cdp/chrome.rs +++ b/cli/src/native/cdp/chrome.rs @@ -450,7 +450,7 @@ pub async fn auto_connect_cdp() -> Result { for dir in &user_data_dirs { if let Some((port, ws_path)) = read_devtools_active_port(dir) { // Try HTTP endpoint first (pre-M144) - if let Ok(ws_url) = discover_cdp_url(port).await { + if let Ok(ws_url) = discover_cdp_url("127.0.0.1", port).await { return Ok(ws_url); } // M144+: direct WebSocket @@ -461,7 +461,7 @@ pub async fn auto_connect_cdp() -> Result { // Fallback: probe common ports for port in [9222u16, 9229] { - if let Ok(ws_url) = discover_cdp_url(port).await { + if let Ok(ws_url) = discover_cdp_url("127.0.0.1", port).await { return Ok(ws_url); } } diff --git a/cli/src/native/cdp/discovery.rs b/cli/src/native/cdp/discovery.rs index f8fac20..c8dda3b 100644 --- a/cli/src/native/cdp/discovery.rs +++ b/cli/src/native/cdp/discovery.rs @@ -2,26 +2,70 @@ use std::time::Duration; use super::types::BrowserVersionInfo; -pub async fn discover_cdp_url(port: u16) -> Result { - discover_cdp_url_with_request_timeout(port, Duration::from_secs(2)).await +/// Default timeout for CDP discovery HTTP requests. +const DEFAULT_DISCOVERY_TIMEOUT: Duration = Duration::from_secs(2); + +/// Discover the CDP WebSocket URL by querying `/json/version` at the given host and port. +/// The returned `webSocketDebuggerUrl` has its host/port rewritten to match +/// the requested target, since Chrome always reports `127.0.0.1` regardless +/// of the interface it was reached through. +pub async fn discover_cdp_url(host: &str, port: u16) -> Result { + discover_cdp_url_with_timeout(host, port, DEFAULT_DISCOVERY_TIMEOUT).await } -pub async fn discover_cdp_url_with_request_timeout( +/// Like [`discover_cdp_url`] but with a custom request timeout. +pub async fn discover_cdp_url_with_timeout( + host: &str, port: u16, - request_timeout: Duration, + timeout: Duration, ) -> Result { - let url = format!("http://127.0.0.1:{}/json/version", port); + let info = fetch_cdp_info(host, port, timeout).await?; + let ws_url = info.web_socket_debugger_url.ok_or_else(|| { + format!( + "No webSocketDebuggerUrl in /json/version at {}:{}", + host, port + ) + })?; + Ok(rewrite_ws_host(&ws_url, host, port)) +} - let body = tokio::time::timeout(request_timeout, async { reqwest_get_string(&url).await }) +/// Bracket an IPv6 address for use in URLs. No-op for IPv4 or already-bracketed addresses. +fn bracket_ipv6(host: &str) -> String { + if host.contains(':') && !host.starts_with('[') { + format!("[{}]", host) + } else { + host.to_string() + } +} + +/// Fetch `/json/version` from the given host:port and parse the response. +async fn fetch_cdp_info( + host: &str, + port: u16, + timeout: Duration, +) -> Result { + let url = format!("http://{}:{}/json/version", bracket_ipv6(host), port); + + let body = tokio::time::timeout(timeout, reqwest_get_string(&url)) .await - .map_err(|_| format!("Timeout connecting to CDP on port {}", port))? - .map_err(|e| format!("Failed to connect to CDP on port {}: {}", port, e))?; + .map_err(|_| format!("Timeout connecting to CDP at {}:{}", host, port))? + .map_err(|e| format!("Failed to connect to CDP at {}:{}: {}", host, port, e))?; - let info: BrowserVersionInfo = serde_json::from_str(&body) - .map_err(|e| format!("Invalid /json/version response: {}", e))?; + serde_json::from_str(&body).map_err(|e| format!("Invalid /json/version response: {}", e)) +} - info.web_socket_debugger_url - .ok_or_else(|| format!("No webSocketDebuggerUrl in /json/version on port {}", port)) +/// Rewrite the host and port in a WebSocket URL to match the target we +/// actually connected to. Chrome's `/json/version` always returns +/// `ws://127.0.0.1:/...` which is unreachable when the +/// browser is on a remote machine or behind a port-forward. +fn rewrite_ws_host(ws_url: &str, host: &str, port: u16) -> String { + if let Ok(mut parsed) = url::Url::parse(ws_url) { + let _ = parsed.set_host(Some(&bracket_ipv6(host))); + let _ = parsed.set_port(Some(port)); + parsed.to_string() + } else { + ws_url.to_string() + } } async fn reqwest_get_string(url: &str) -> Result { @@ -57,8 +101,8 @@ mod tests { let (port, server) = spawn_json_server(r#"{"webSocketDebuggerUrl":"ws://127.0.0.1:1234/"}"#).await; - let ws_url = discover_cdp_url(port).await.unwrap(); - assert_eq!(ws_url, "ws://127.0.0.1:1234/"); + let ws_url = discover_cdp_url("127.0.0.1", port).await.unwrap(); + assert_eq!(ws_url, format!("ws://127.0.0.1:{}/", port)); server.await.unwrap(); } @@ -66,8 +110,22 @@ mod tests { async fn invalid_json_returns_parse_error() { let (port, server) = spawn_json_server("not-json").await; - let err = discover_cdp_url(port).await.unwrap_err(); + let err = discover_cdp_url("127.0.0.1", port).await.unwrap_err(); assert!(err.contains("Invalid /json/version response")); server.await.unwrap(); } + + #[test] + fn rewrite_ws_host_replaces_host_and_port() { + let original = "ws://127.0.0.1:9222/devtools/browser/abc"; + let rewritten = rewrite_ws_host(original, "10.211.55.12", 9223); + assert_eq!(rewritten, "ws://10.211.55.12:9223/devtools/browser/abc"); + } + + #[test] + fn rewrite_ws_host_handles_ipv6() { + let original = "ws://127.0.0.1:9222/devtools/browser/abc"; + let rewritten = rewrite_ws_host(original, "::1", 9222); + assert_eq!(rewritten, "ws://[::1]:9222/devtools/browser/abc"); + } } diff --git a/cli/src/native/cdp/lightpanda.rs b/cli/src/native/cdp/lightpanda.rs index e43b4de..787cc7a 100644 --- a/cli/src/native/cdp/lightpanda.rs +++ b/cli/src/native/cdp/lightpanda.rs @@ -6,7 +6,7 @@ use std::process::{Child, Command, Stdio}; use std::sync::{Arc, Mutex}; use std::time::Duration; -use super::discovery::discover_cdp_url_with_request_timeout; +use super::discovery::discover_cdp_url_with_timeout; const LIGHTPANDA_STARTUP_TIMEOUT: Duration = Duration::from_secs(10); const LIGHTPANDA_POLL_INTERVAL: Duration = Duration::from_millis(100); @@ -257,7 +257,7 @@ async fn wait_for_lightpanda_ready( )); } - match discover_cdp_url_with_request_timeout(port, LIGHTPANDA_DISCOVERY_TIMEOUT).await { + match discover_cdp_url_with_timeout("127.0.0.1", port, LIGHTPANDA_DISCOVERY_TIMEOUT).await { Ok(ws_url) => return Ok(ws_url), Err(err) => last_probe_error = Some(err), } @@ -365,7 +365,7 @@ mod tests { .await .unwrap(); - assert_eq!(ws_url, "ws://127.0.0.1:9222/"); + assert_eq!(ws_url, format!("ws://127.0.0.1:{}/", port)); let _ = child.kill(); let _ = child.wait(); }