//! WebSocket connector for communicating with socktop agents. // WebSocket state constants #[cfg(feature = "wasm")] #[allow(dead_code)] const WEBSOCKET_CONNECTING: u16 = 0; #[cfg(feature = "wasm")] #[allow(dead_code)] const WEBSOCKET_OPEN: u16 = 1; #[cfg(feature = "wasm")] #[allow(dead_code)] const WEBSOCKET_CLOSING: u16 = 2; #[cfg(feature = "wasm")] #[allow(dead_code)] const WEBSOCKET_CLOSED: u16 = 3; // Gzip magic header constants const GZIP_MAGIC_1: u8 = 0x1f; const GZIP_MAGIC_2: u8 = 0x8b; // Shared imports for both networking and WASM #[cfg(any(feature = "networking", feature = "wasm"))] use flate2::read::GzDecoder; #[cfg(any(feature = "networking", feature = "wasm"))] use std::io::Read; #[cfg(any(feature = "networking", feature = "wasm"))] use prost::Message as ProstMessage; #[cfg(feature = "networking")] use futures_util::{SinkExt, StreamExt}; #[cfg(feature = "networking")] use std::io::BufReader; #[cfg(feature = "networking")] use tokio::net::TcpStream; #[cfg(feature = "networking")] use tokio_tungstenite::{ MaybeTlsStream, WebSocketStream, connect_async, tungstenite::Message, tungstenite::client::IntoClientRequest, }; #[cfg(feature = "networking")] use url::Url; #[cfg(feature = "wasm")] use web_sys::WebSocket; #[cfg(all(feature = "wasm", not(feature = "networking")))] use pb::Processes; #[cfg(all(feature = "wasm", not(feature = "networking")))] use wasm_bindgen::{JsCast, JsValue, closure::Closure}; #[cfg(feature = "tls")] use rustls::client::danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier}; #[cfg(feature = "tls")] use rustls::pki_types::{CertificateDer, ServerName, UnixTime}; #[cfg(feature = "tls")] use rustls::{ClientConfig, RootCertStore}; #[cfg(feature = "tls")] use rustls::{DigitallySignedStruct, SignatureScheme}; #[cfg(feature = "tls")] use rustls_pemfile::Item; #[cfg(feature = "tls")] use std::{fs::File, sync::Arc}; #[cfg(feature = "tls")] use tokio_tungstenite::{Connector, connect_async_tls_with_config}; use crate::error::{ConnectorError, Result}; use crate::types::{AgentRequest, AgentResponse}; #[cfg(any(feature = "networking", feature = "wasm"))] use crate::types::{DiskInfo, Metrics, ProcessInfo, ProcessesPayload, ProcessMetricsResponse, JournalResponse}; #[cfg(feature = "tls")] fn ensure_crypto_provider() { use std::sync::Once; static INIT: Once = Once::new(); INIT.call_once(|| { let _ = rustls::crypto::ring::default_provider().install_default(); }); } #[cfg(any(feature = "networking", feature = "wasm"))] mod pb { // generated by build.rs include!(concat!(env!("OUT_DIR"), "/socktop.rs")); } #[cfg(feature = "networking")] pub type WsStream = WebSocketStream>; /// Configuration for connecting to a socktop agent #[derive(Debug, Clone)] pub struct ConnectorConfig { pub url: String, pub tls_ca_path: Option, pub verify_hostname: bool, pub ws_protocols: Option>, pub ws_version: Option, } impl ConnectorConfig { pub fn new(url: impl Into) -> Self { Self { url: url.into(), tls_ca_path: None, verify_hostname: false, ws_protocols: None, ws_version: None, } } pub fn with_tls_ca(mut self, ca_path: impl Into) -> Self { self.tls_ca_path = Some(ca_path.into()); self } pub fn with_hostname_verification(mut self, verify: bool) -> Self { self.verify_hostname = verify; self } /// Set WebSocket sub-protocols to negotiate pub fn with_protocols(mut self, protocols: Vec) -> Self { self.ws_protocols = Some(protocols); self } /// Set WebSocket protocol version (default is "13") pub fn with_version(mut self, version: impl Into) -> Self { self.ws_version = Some(version.into()); self } } /// A WebSocket connector for communicating with socktop agents. /// When the `networking` feature is disabled, the connector struct is available /// for type compatibility but networking methods will return errors. pub struct SocktopConnector { config: ConnectorConfig, #[cfg(feature = "networking")] stream: Option, #[cfg(feature = "wasm")] #[allow(dead_code)] // Used in WASM builds websocket: Option, } impl SocktopConnector { /// Create a new connector with the given configuration pub fn new(config: ConnectorConfig) -> Self { Self { config, #[cfg(feature = "networking")] stream: None, #[cfg(feature = "wasm")] websocket: None, } } } #[cfg(feature = "networking")] impl SocktopConnector { /// Connect to the agent pub async fn connect(&mut self) -> Result<()> { let stream = connect_to_agent(&self.config).await?; self.stream = Some(stream); Ok(()) } /// Send a request to the agent and get the response pub async fn request(&mut self, request: AgentRequest) -> Result { let stream = self.stream.as_mut().ok_or(ConnectorError::NotConnected)?; match request { AgentRequest::Metrics => { let metrics = request_metrics(stream) .await .ok_or_else(|| ConnectorError::invalid_response("Failed to get metrics"))?; Ok(AgentResponse::Metrics(metrics)) } AgentRequest::Disks => { let disks = request_disks(stream) .await .ok_or_else(|| ConnectorError::invalid_response("Failed to get disks"))?; Ok(AgentResponse::Disks(disks)) } AgentRequest::Processes => { let processes = request_processes(stream) .await .ok_or_else(|| ConnectorError::invalid_response("Failed to get processes"))?; Ok(AgentResponse::Processes(processes)) } AgentRequest::ProcessMetrics { pid } => { let process_metrics = request_process_metrics(stream, pid) .await .ok_or_else(|| ConnectorError::invalid_response("Failed to get process metrics"))?; Ok(AgentResponse::ProcessMetrics(process_metrics)) } AgentRequest::JournalEntries { pid } => { let journal_entries = request_journal_entries(stream, pid) .await .ok_or_else(|| ConnectorError::invalid_response("Failed to get journal entries"))?; Ok(AgentResponse::JournalEntries(journal_entries)) } } } /// Check if the connector is connected pub fn is_connected(&self) -> bool { self.stream.is_some() } /// Disconnect from the agent pub async fn disconnect(&mut self) -> Result<()> { if let Some(mut stream) = self.stream.take() { let _ = stream.close(None).await; } Ok(()) } } // Connect to the agent and return the WS stream #[cfg(feature = "networking")] async fn connect_to_agent(config: &ConnectorConfig) -> Result { #[cfg(feature = "tls")] ensure_crypto_provider(); let mut u = Url::parse(&config.url)?; if let Some(ca_path) = &config.tls_ca_path { if u.scheme() == "ws" { let _ = u.set_scheme("wss"); } return connect_with_ca_and_config(u.as_str(), ca_path, config).await; } // No TLS - hostname verification is not applicable connect_without_ca_and_config(u.as_str(), config).await } #[cfg(feature = "networking")] async fn connect_without_ca_and_config(url: &str, config: &ConnectorConfig) -> Result { let mut req = url.into_client_request()?; // Apply WebSocket protocol configuration if let Some(version) = &config.ws_version { req.headers_mut().insert( "Sec-WebSocket-Version", version .parse() .map_err(|_| ConnectorError::protocol_error("Invalid WebSocket version"))?, ); } if let Some(protocols) = &config.ws_protocols { let protocols_str = protocols.join(", "); req.headers_mut().insert( "Sec-WebSocket-Protocol", protocols_str .parse() .map_err(|_| ConnectorError::protocol_error("Invalid WebSocket protocols"))?, ); } let (ws, _) = connect_async(req).await?; Ok(ws) } #[cfg(feature = "tls")] #[cfg(feature = "networking")] async fn connect_with_ca_and_config( url: &str, ca_path: &str, config: &ConnectorConfig, ) -> Result { // Initialize the crypto provider for rustls let _ = rustls::crypto::ring::default_provider().install_default(); let mut root = RootCertStore::empty(); let mut reader = BufReader::new(File::open(ca_path)?); let mut der_certs = Vec::new(); while let Ok(Some(item)) = rustls_pemfile::read_one(&mut reader) { if let Item::X509Certificate(der) = item { der_certs.push(der); } } root.add_parsable_certificates(der_certs); let mut cfg = ClientConfig::builder() .with_root_certificates(root) .with_no_client_auth(); let mut req = url.into_client_request()?; // Apply WebSocket protocol configuration if let Some(version) = &config.ws_version { req.headers_mut().insert( "Sec-WebSocket-Version", version .parse() .map_err(|_| ConnectorError::protocol_error("Invalid WebSocket version"))?, ); } if let Some(protocols) = &config.ws_protocols { let protocols_str = protocols.join(", "); req.headers_mut().insert( "Sec-WebSocket-Protocol", protocols_str .parse() .map_err(|_| ConnectorError::protocol_error("Invalid WebSocket protocols"))?, ); } if !config.verify_hostname { #[derive(Debug)] struct NoVerify; impl ServerCertVerifier for NoVerify { fn verify_server_cert( &self, _end_entity: &CertificateDer<'_>, _intermediates: &[CertificateDer<'_>], _server_name: &ServerName, _ocsp_response: &[u8], _now: UnixTime, ) -> std::result::Result { Ok(ServerCertVerified::assertion()) } fn verify_tls12_signature( &self, _message: &[u8], _cert: &CertificateDer<'_>, _dss: &DigitallySignedStruct, ) -> std::result::Result { Ok(HandshakeSignatureValid::assertion()) } fn verify_tls13_signature( &self, _message: &[u8], _cert: &CertificateDer<'_>, _dss: &DigitallySignedStruct, ) -> std::result::Result { Ok(HandshakeSignatureValid::assertion()) } fn supported_verify_schemes(&self) -> Vec { vec![ SignatureScheme::ECDSA_NISTP256_SHA256, SignatureScheme::ED25519, SignatureScheme::RSA_PSS_SHA256, ] } } cfg.dangerous().set_certificate_verifier(Arc::new(NoVerify)); // Note: hostname verification disabled (default). Set SOCKTOP_VERIFY_NAME=1 to enable strict SAN checking. } let cfg = Arc::new(cfg); let (ws, _) = connect_async_tls_with_config( req, None, config.verify_hostname, Some(Connector::Rustls(cfg)), ) .await?; Ok(ws) } #[cfg(not(feature = "tls"))] #[cfg(feature = "networking")] async fn connect_with_ca_and_config( _url: &str, _ca_path: &str, _config: &ConnectorConfig, ) -> Result { Err(ConnectorError::tls_error( "TLS support not compiled in", std::io::Error::new(std::io::ErrorKind::Unsupported, "TLS not available"), )) } // Send a "get_metrics" request and await a single JSON reply #[cfg(feature = "networking")] async fn request_metrics(ws: &mut WsStream) -> Option { if ws.send(Message::Text("get_metrics".into())).await.is_err() { return None; } match ws.next().await { Some(Ok(Message::Binary(b))) => { gunzip_to_string(&b).ok().and_then(|s| serde_json::from_str::(&s).ok()) } Some(Ok(Message::Text(json))) => serde_json::from_str::(&json).ok(), _ => None, } } // Send a "get_disks" request and await a JSON Vec #[cfg(feature = "networking")] async fn request_disks(ws: &mut WsStream) -> Option> { if ws.send(Message::Text("get_disks".into())).await.is_err() { return None; } match ws.next().await { Some(Ok(Message::Binary(b))) => { gunzip_to_string(&b).ok().and_then(|s| serde_json::from_str::>(&s).ok()) } Some(Ok(Message::Text(json))) => serde_json::from_str::>(&json).ok(), _ => None, } } // Send a "get_processes" request and await a ProcessesPayload decoded from protobuf (binary, may be gzipped) #[cfg(feature = "networking")] async fn request_processes(ws: &mut WsStream) -> Option { if ws .send(Message::Text("get_processes".into())) .await .is_err() { return None; } match ws.next().await { Some(Ok(Message::Binary(b))) => { let gz = is_gzip(&b); let data = if gz { gunzip_to_vec(&b).ok()? } else { b }; match pb::Processes::decode(data.as_slice()) { Ok(pb) => { let rows: Vec = pb .rows .into_iter() .map(|p: pb::Process| ProcessInfo { pid: p.pid, name: p.name, cpu_usage: p.cpu_usage, mem_bytes: p.mem_bytes, }) .collect(); Some(ProcessesPayload { process_count: pb.process_count as usize, top_processes: rows, }) } Err(e) => { if std::env::var("SOCKTOP_DEBUG").ok().as_deref() == Some("1") { eprintln!("protobuf decode failed: {e}"); } // Fallback: maybe it's JSON (bytes already decompressed if gz) match String::from_utf8(data) { Ok(s) => serde_json::from_str::(&s).ok(), Err(_) => None, } } } } Some(Ok(Message::Text(json))) => serde_json::from_str::(&json).ok(), _ => None, } } // Send a "get_process_metrics:{pid}" request and await a JSON ProcessMetricsResponse #[cfg(feature = "networking")] async fn request_process_metrics(ws: &mut WsStream, pid: u32) -> Option { let request = format!("get_process_metrics:{}", pid); if ws.send(Message::Text(request)).await.is_err() { return None; } match ws.next().await { Some(Ok(Message::Binary(b))) => { gunzip_to_string(&b).ok().and_then(|s| serde_json::from_str::(&s).ok()) } Some(Ok(Message::Text(json))) => serde_json::from_str::(&json).ok(), _ => None, } } // Send a "get_journal_entries:{pid}" request and await a JSON JournalResponse #[cfg(feature = "networking")] async fn request_journal_entries(ws: &mut WsStream, pid: u32) -> Option { let request = format!("get_journal_entries:{}", pid); if ws.send(Message::Text(request)).await.is_err() { return None; } match ws.next().await { Some(Ok(Message::Binary(b))) => { gunzip_to_string(&b).ok().and_then(|s| serde_json::from_str::(&s).ok()) } Some(Ok(Message::Text(json))) => serde_json::from_str::(&json).ok(), _ => None, } } // Decompress a gzip-compressed binary frame into a String. /// Unified gzip decompression to string for both networking and WASM #[cfg(any(feature = "networking", feature = "wasm"))] fn gunzip_to_string(bytes: &[u8]) -> Result { let mut decoder = GzDecoder::new(bytes); let mut decompressed = String::new(); decoder.read_to_string(&mut decompressed).map_err(|e| { ConnectorError::protocol_error(format!("Gzip decompression failed: {e}")) })?; Ok(decompressed) } /// Unified gzip decompression to bytes for both networking and WASM #[cfg(any(feature = "networking", feature = "wasm"))] fn gunzip_to_vec(bytes: &[u8]) -> Result> { let mut decoder = GzDecoder::new(bytes); let mut decompressed = Vec::new(); decoder.read_to_end(&mut decompressed).map_err(|e| { ConnectorError::protocol_error(format!("Gzip decompression failed: {e}")) })?; Ok(decompressed) } /// Unified gzip detection for both networking and WASM #[cfg(any(feature = "networking", feature = "wasm"))] fn is_gzip(bytes: &[u8]) -> bool { bytes.len() >= 2 && bytes[0] == GZIP_MAGIC_1 && bytes[1] == GZIP_MAGIC_2 } /// Convenience function to create a connector and connect in one step. /// /// This function is for non-TLS WebSocket connections (`ws://`). Since there's no /// certificate involved, hostname verification is not applicable. /// /// For TLS connections with certificate pinning, use `connect_to_socktop_agent_with_tls()`. #[cfg(feature = "networking")] pub async fn connect_to_socktop_agent(url: impl Into) -> Result { let config = ConnectorConfig::new(url); let mut connector = SocktopConnector::new(config); connector.connect().await?; Ok(connector) } /// Convenience function to create a connector with TLS and connect in one step. /// /// This function enables TLS with certificate pinning using the provided CA certificate. /// The `verify_hostname` parameter controls whether the server's hostname is verified /// against the certificate (recommended for production, can be disabled for testing). #[cfg(feature = "tls")] #[cfg(feature = "networking")] #[cfg_attr(docsrs, doc(cfg(feature = "tls")))] pub async fn connect_to_socktop_agent_with_tls( url: impl Into, ca_path: impl Into, verify_hostname: bool, ) -> Result { let config = ConnectorConfig::new(url) .with_tls_ca(ca_path) .with_hostname_verification(verify_hostname); let mut connector = SocktopConnector::new(config); connector.connect().await?; Ok(connector) } /// Convenience function to create a connector with custom WebSocket protocol configuration. /// /// This function allows you to specify WebSocket protocol version and sub-protocols. /// Most users should use the simpler `connect_to_socktop_agent()` function instead. /// /// # Example /// ```no_run /// use socktop_connector::connect_to_socktop_agent_with_config; /// /// # #[tokio::main] /// # async fn main() -> Result<(), Box> { /// let connector = connect_to_socktop_agent_with_config( /// "ws://localhost:3000/ws", /// Some(vec!["socktop".to_string()]), // WebSocket sub-protocols /// Some("13".to_string()), // WebSocket version (13 is standard) /// ).await?; /// # Ok(()) /// # } /// ``` #[cfg(feature = "networking")] pub async fn connect_to_socktop_agent_with_config( url: impl Into, protocols: Option>, version: Option, ) -> Result { let mut config = ConnectorConfig::new(url); if let Some(protocols) = protocols { config = config.with_protocols(protocols); } if let Some(version) = version { config = config.with_version(version); } let mut connector = SocktopConnector::new(config); connector.connect().await?; Ok(connector) } // WASM WebSocket implementation #[cfg(all(feature = "wasm", not(feature = "networking")))] impl SocktopConnector { /// Connect to the agent using WASM WebSocket pub async fn connect(&mut self) -> Result<()> { let websocket = WebSocket::new(&self.config.url).map_err(|e| { ConnectorError::protocol_error(format!("Failed to create WebSocket: {e:?}")) })?; // Set binary type for proper message handling websocket.set_binary_type(web_sys::BinaryType::Arraybuffer); // Wait for connection to be ready with proper async delays let start_time = js_sys::Date::now(); let timeout_ms = 10000.0; // 10 second timeout (increased from 5) // Poll connection status until ready or timeout loop { let ready_state = websocket.ready_state(); if ready_state == WEBSOCKET_OPEN { // OPEN - connection is ready break; } else if ready_state == WEBSOCKET_CLOSED { // CLOSED return Err(ConnectorError::protocol_error( "WebSocket connection closed", )); } else if ready_state == WEBSOCKET_CLOSING { // CLOSING return Err(ConnectorError::protocol_error("WebSocket is closing")); } // Check timeout let now = js_sys::Date::now(); if now - start_time > timeout_ms { return Err(ConnectorError::protocol_error( "WebSocket connection timeout", )); } // Proper async delay using setTimeout Promise let promise = js_sys::Promise::new(&mut |resolve, _| { let closure = Closure::once(move || resolve.call0(&JsValue::UNDEFINED)); web_sys::window() .unwrap() .set_timeout_with_callback_and_timeout_and_arguments_0( closure.as_ref().unchecked_ref(), 100, // 100ms delay between polls ) .unwrap(); closure.forget(); }); let _ = wasm_bindgen_futures::JsFuture::from(promise).await; } self.websocket = Some(websocket); Ok(()) } /// Send a request to the agent and get the response pub async fn request(&mut self, request: AgentRequest) -> Result { let ws = self .websocket .as_ref() .ok_or(ConnectorError::NotConnected)?; // Use the legacy string format that the agent expects let request_string = request.to_legacy_string(); // Send request ws.send_with_str(&request_string).map_err(|e| { ConnectorError::protocol_error(format!("Failed to send message: {e:?}")) })?; // Wait for response using JavaScript Promise let (response, binary_data) = self.wait_for_response_with_binary().await?; // Parse the response based on the request type match request { AgentRequest::Metrics => { // Check if this is binary data (protobuf from agent) if response.starts_with("BINARY_DATA:") { // Extract the byte count let byte_count: usize = response .strip_prefix("BINARY_DATA:") .unwrap_or("0") .parse() .unwrap_or(0); // For now, return a placeholder metrics response indicating binary data received // TODO: Implement proper protobuf decoding for binary data let placeholder_metrics = Metrics { cpu_total: 0.0, cpu_per_core: vec![0.0], mem_total: 0, mem_used: 0, swap_total: 0, swap_used: 0, hostname: format!("Binary protobuf data ({byte_count} bytes)"), cpu_temp_c: None, disks: vec![], networks: vec![], top_processes: vec![], gpus: None, process_count: None, }; Ok(AgentResponse::Metrics(placeholder_metrics)) } else { // Try to parse as JSON (fallback) let metrics: Metrics = serde_json::from_str(&response).map_err(|e| { ConnectorError::serialization_error(format!( "Failed to parse metrics: {e}" )) })?; Ok(AgentResponse::Metrics(metrics)) } } AgentRequest::Disks => { let disks: Vec = serde_json::from_str(&response).map_err(|e| { ConnectorError::serialization_error(format!("Failed to parse disks: {e}")) })?; Ok(AgentResponse::Disks(disks)) } AgentRequest::Processes => { log_debug(&format!( "🔍 Processing process request - response: {}", if response.len() > 100 { format!("{}...", &response[..100]) } else { response.clone() } )); log_debug(&format!( "🔍 Binary data available: {}", binary_data.is_some() )); if let Some(ref data) = binary_data { log_debug(&format!("🔍 Binary data size: {} bytes", data.len())); // Check if it's gzipped data and decompress it first if is_gzip(data) { log_debug("🔍 Process data is gzipped, decompressing..."); match gunzip_to_vec(data) { Ok(decompressed_bytes) => { log_debug(&format!( "🔍 Successfully decompressed {} bytes, now decoding protobuf...", decompressed_bytes.len() )); // Now decode the decompressed bytes as protobuf match ::decode( decompressed_bytes.as_slice(), ) { Ok(protobuf_processes) => { log_debug(&format!( "✅ Successfully decoded {} processes from gzipped protobuf", protobuf_processes.rows.len() )); // Convert protobuf processes to ProcessInfo structs let processes: Vec = protobuf_processes .rows .into_iter() .map(|p| ProcessInfo { pid: p.pid, name: p.name, cpu_usage: p.cpu_usage, mem_bytes: p.mem_bytes, }) .collect(); let processes_payload = ProcessesPayload { top_processes: processes, process_count: protobuf_processes.process_count as usize, }; return Ok(AgentResponse::Processes(processes_payload)); } Err(e) => { log_debug(&format!( "❌ Failed to decode decompressed protobuf: {e}" )); } } } Err(e) => { log_debug(&format!( "❌ Failed to decompress gzipped process data: {e}" )); } } } } // Check if this is binary data (protobuf from agent) if response.starts_with("BINARY_DATA:") { // Extract the binary data size and decode protobuf let byte_count_str = response.strip_prefix("BINARY_DATA:").unwrap_or("0"); let _byte_count: usize = byte_count_str.parse().unwrap_or(0); // Check if we have the actual binary data if let Some(binary_bytes) = binary_data { log_debug(&format!( "🔧 Decoding {} bytes of protobuf process data", binary_bytes.len() )); // Try to decode the protobuf data using the prost Message trait match ::decode(&binary_bytes[..]) { Ok(protobuf_processes) => { log_debug(&format!( "✅ Successfully decoded {} processes from protobuf", protobuf_processes.rows.len() )); // Convert protobuf processes to ProcessInfo structs let processes: Vec = protobuf_processes .rows .into_iter() .map(|p| ProcessInfo { pid: p.pid, name: p.name, cpu_usage: p.cpu_usage, mem_bytes: p.mem_bytes, }) .collect(); let processes_payload = ProcessesPayload { top_processes: processes, process_count: protobuf_processes.process_count as usize, }; Ok(AgentResponse::Processes(processes_payload)) } Err(e) => { log_debug(&format!("❌ Failed to decode protobuf: {e}")); // Fallback to empty processes let processes = ProcessesPayload { top_processes: vec![], process_count: 0, }; Ok(AgentResponse::Processes(processes)) } } } else { log_debug( "❌ Binary data indicator received but no actual binary data preserved", ); let processes = ProcessesPayload { top_processes: vec![], process_count: 0, }; Ok(AgentResponse::Processes(processes)) } } else { // Try to parse as JSON (fallback) let processes: ProcessesPayload = serde_json::from_str(&response).map_err(|e| { ConnectorError::serialization_error(format!( "Failed to parse processes: {e}" )) })?; Ok(AgentResponse::Processes(processes)) } } AgentRequest::ProcessMetrics { pid: _ } => { // Parse JSON response for process metrics let process_metrics: ProcessMetricsResponse = serde_json::from_str(&response).map_err(|e| { ConnectorError::serialization_error(format!("Failed to parse process metrics: {e}")) })?; Ok(AgentResponse::ProcessMetrics(process_metrics)) } AgentRequest::JournalEntries { pid: _ } => { // Parse JSON response for journal entries let journal_entries: JournalResponse = serde_json::from_str(&response).map_err(|e| { ConnectorError::serialization_error(format!("Failed to parse journal entries: {e}")) })?; Ok(AgentResponse::JournalEntries(journal_entries)) } } } async fn wait_for_response_with_binary(&self) -> Result<(String, Option>)> { let ws = self .websocket .as_ref() .ok_or(ConnectorError::NotConnected)?; let start_time = js_sys::Date::now(); let timeout_ms = 10000.0; // 10 second timeout // Store the response in a shared location let response_cell = std::rc::Rc::new(std::cell::RefCell::new(None::)); let binary_data_cell = std::rc::Rc::new(std::cell::RefCell::new(None::>)); let error_cell = std::rc::Rc::new(std::cell::RefCell::new(None::)); // Use a unique request ID to avoid message collision let _request_id = js_sys::Math::random(); let response_received = std::rc::Rc::new(std::cell::RefCell::new(false)); // Set up the message handler that only processes if we haven't gotten a response yet { let response_cell = response_cell.clone(); let binary_data_cell = binary_data_cell.clone(); let response_received = response_received.clone(); let onmessage_callback = Closure::wrap(Box::new(move |e: web_sys::MessageEvent| { // Only process if we haven't already received a response for this request if !*response_received.borrow() { // Handle text messages (JSON responses for metrics/disks) if let Ok(data) = e.data().dyn_into::() { let message = data.as_string().unwrap_or_default(); if !message.is_empty() { // Debug: Log what we received (truncated) let preview = if message.len() > 100 { format!("{}...", &message[..100]) } else { message.clone() }; log_debug(&format!("🔍 Received text: {preview}")); *response_cell.borrow_mut() = Some(message); *response_received.borrow_mut() = true; } } // Handle binary messages (could be JSON as text bytes or actual protobuf) else if let Ok(array_buffer) = e.data().dyn_into::() { let uint8_array = js_sys::Uint8Array::new(&array_buffer); let length = uint8_array.length() as usize; let mut bytes = vec![0u8; length]; uint8_array.copy_to(&mut bytes); log_debug(&format!("🔍 Received binary data: {length} bytes")); // Debug: Log the first few bytes to see what we're dealing with let first_bytes = if bytes.len() >= 4 { format!( "0x{:02x} 0x{:02x} 0x{:02x} 0x{:02x}", bytes[0], bytes[1], bytes[2], bytes[3] ) } else { format!("Only {} bytes available", bytes.len()) }; log_debug(&format!("🔍 First bytes: {first_bytes}")); // Try to decode as UTF-8 text first (in case it's JSON sent as binary) match String::from_utf8(bytes.clone()) { Ok(text) => { // If it decodes to valid UTF-8, check if it looks like JSON let trimmed = text.trim(); if (trimmed.starts_with('{') && trimmed.ends_with('}')) || (trimmed.starts_with('[') && trimmed.ends_with(']')) { log_debug(&format!( "🔍 Binary data is actually JSON text: {}", if text.len() > 100 { format!("{}...", &text[..100]) } else { text.clone() } )); *response_cell.borrow_mut() = Some(text); *response_received.borrow_mut() = true; } else { log_debug(&format!( "🔍 Binary data is UTF-8 text but not JSON: {}", if text.len() > 100 { format!("{}...", &text[..100]) } else { text.clone() } )); *response_cell.borrow_mut() = Some(text); *response_received.borrow_mut() = true; } } Err(_) => { // If it's not valid UTF-8, check if it's gzipped data if is_gzip(&bytes) { log_debug(&format!( "🔍 Binary data appears to be gzipped ({length} bytes)" )); // Try to decompress using unified gzip decompression match gunzip_to_string(&bytes) { Ok(decompressed_text) => { log_debug(&format!( "🔍 Gzipped data decompressed to text: {}", if decompressed_text.len() > 100 { format!("{}...", &decompressed_text[..100]) } else { decompressed_text.clone() } )); *response_cell.borrow_mut() = Some(decompressed_text); *response_received.borrow_mut() = true; } Err(e) => { log_debug(&format!( "🔍 Failed to decompress gzip: {e}" )); // Fallback: treat as actual binary protobuf data *binary_data_cell.borrow_mut() = Some(bytes.clone()); *response_cell.borrow_mut() = Some(format!("BINARY_DATA:{length}")); *response_received.borrow_mut() = true; } } } else { // If it's not valid UTF-8 and not gzipped, it's likely actual binary protobuf data log_debug(&format!( "🔍 Binary data is actual protobuf ({length} bytes)" )); *binary_data_cell.borrow_mut() = Some(bytes); *response_cell.borrow_mut() = Some(format!("BINARY_DATA:{length}")); *response_received.borrow_mut() = true; } } } } else { // Log what type of data we got log_debug(&format!("🔍 Received unknown data type: {:?}", e.data())); } } }) as Box); ws.set_onmessage(Some(onmessage_callback.as_ref().unchecked_ref())); onmessage_callback.forget(); } // Set up the error handler { let error_cell = error_cell.clone(); let response_received = response_received.clone(); let onerror_callback = Closure::wrap(Box::new(move |_e: web_sys::ErrorEvent| { if !*response_received.borrow() { *error_cell.borrow_mut() = Some("WebSocket error occurred".to_string()); *response_received.borrow_mut() = true; } }) as Box); ws.set_onerror(Some(onerror_callback.as_ref().unchecked_ref())); onerror_callback.forget(); } // Poll for response with proper async delays loop { // Check for response if *response_received.borrow() { if let Some(response) = response_cell.borrow().as_ref() { let binary_data = binary_data_cell.borrow().clone(); return Ok((response.clone(), binary_data)); } if let Some(error) = error_cell.borrow().as_ref() { return Err(ConnectorError::protocol_error(error)); } } // Check timeout let now = js_sys::Date::now(); if now - start_time > timeout_ms { *response_received.borrow_mut() = true; // Mark as done to prevent future processing return Err(ConnectorError::protocol_error("WebSocket response timeout")); } // Wait 50ms before checking again let promise = js_sys::Promise::new(&mut |resolve, _| { let closure = Closure::once(move || resolve.call0(&JsValue::UNDEFINED)); web_sys::window() .unwrap() .set_timeout_with_callback_and_timeout_and_arguments_0( closure.as_ref().unchecked_ref(), 50, ) .unwrap(); closure.forget(); }); let _ = wasm_bindgen_futures::JsFuture::from(promise).await; } } /// Check if the connector is connected pub fn is_connected(&self) -> bool { self.websocket .as_ref() .is_some_and(|ws| ws.ready_state() == WEBSOCKET_OPEN) } /// Disconnect from the agent pub async fn disconnect(&mut self) -> Result<()> { if let Some(ws) = self.websocket.take() { let _ = ws.close(); } Ok(()) } /// Request metrics from the agent pub async fn get_metrics(&mut self) -> Result { match self.request(AgentRequest::Metrics).await? { AgentResponse::Metrics(metrics) => Ok(metrics), _ => Err(ConnectorError::protocol_error( "Unexpected response type for metrics", )), } } /// Request disk information from the agent pub async fn get_disks(&mut self) -> Result> { match self.request(AgentRequest::Disks).await? { AgentResponse::Disks(disks) => Ok(disks), _ => Err(ConnectorError::protocol_error( "Unexpected response type for disks", )), } } /// Request process information from the agent pub async fn get_processes(&mut self) -> Result { match self.request(AgentRequest::Processes).await? { AgentResponse::Processes(processes) => Ok(processes), _ => Err(ConnectorError::protocol_error( "Unexpected response type for processes", )), } } } // Helper function for logging that works in WASI environments /// Unified debug logging for both networking and WASM modes #[cfg(any(feature = "networking", feature = "wasm"))] #[allow(dead_code)] fn log_debug(message: &str) { #[cfg(feature = "networking")] if std::env::var("SOCKTOP_DEBUG").ok().as_deref() == Some("1") { eprintln!("{message}"); } #[cfg(all(feature = "wasm", not(feature = "networking")))] eprintln!("{message}"); } // Stub implementations when neither networking nor wasm is enabled #[cfg(not(any(feature = "networking", feature = "wasm")))] impl SocktopConnector { /// Connect to the socktop agent endpoint. /// /// Note: Networking functionality is disabled. Enable the "networking" feature to use this function. pub async fn connect(&mut self) -> Result<()> { Err(ConnectorError::protocol_error( "Networking functionality disabled. Enable the 'networking' feature to connect to agents.", )) } /// Send a request to the agent and await a response. /// /// Note: Networking functionality is disabled. Enable the "networking" feature to use this function. pub async fn request(&mut self, _request: AgentRequest) -> Result { Err(ConnectorError::protocol_error( "Networking functionality disabled. Enable the 'networking' feature to send requests.", )) } /// Close the connection to the agent. /// /// Note: Networking functionality is disabled. This is a no-op when networking is disabled. pub async fn disconnect(&mut self) -> Result<()> { Ok(()) // No-op when networking is disabled } }