Files
guru-connect/agent/src/transport/websocket.rs
Mike Swanson b3e8f32734
All checks were successful
Build and Test / Build Agent (Windows) (push) Successful in 7m4s
Build and Test / Build Server (Linux) (push) Successful in 9m41s
Build and Test / Security Audit (push) Successful in 4m11s
Build and Test / Build Summary (push) Successful in 10s
feat(agent): derive + report deterministic machine_uid (SPEC-004 Task 1)
Agent now derives a recomputable, opaque machine_uid (Windows: SHA-256 of the OS
MachineGuid at HKLM\SOFTWARE\Microsoft\Cryptography\MachineGuid -> muid_<hex>;
non-Windows / registry-failure: persisted random UUID, warn-logged). Raw GUID
never exposed; OnceLock-cached. Reported ALONGSIDE agent_id (unchanged) on
AgentStatus (new additive proto field 12) and in the connect handshake query.
This is the stable identity that fixes config-loss duplicate registrations
(DESKTOP-I66IM5Q x9); server-side dedup keying that consumes it is SPEC-004
Task 2. Non-breaking, isolated. 5 unit tests; cargo fmt/clippy(-D warnings)/test
green on GURU-5070.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 21:23:11 -07:00

216 lines
6.7 KiB
Rust

//! WebSocket client transport
//!
//! Handles WebSocket connection to the GuruConnect server with:
//! - TLS encryption
//! - Automatic reconnection
//! - Protobuf message serialization
use crate::proto::Message;
use anyhow::{Context, Result};
use bytes::Bytes;
use futures_util::{SinkExt, StreamExt};
use prost::Message as ProstMessage;
use std::collections::VecDeque;
use std::sync::Arc;
use tokio::net::TcpStream;
use tokio::sync::Mutex;
use tokio_tungstenite::{
connect_async, tungstenite::protocol::Message as WsMessage, MaybeTlsStream, WebSocketStream,
};
type WsStream = WebSocketStream<MaybeTlsStream<TcpStream>>;
/// WebSocket transport for server communication
pub struct WebSocketTransport {
stream: Arc<Mutex<WsStream>>,
incoming: VecDeque<Message>,
connected: bool,
}
impl WebSocketTransport {
/// Connect to the server
pub async fn connect(
url: &str,
agent_id: &str,
api_key: &str,
hostname: Option<&str>,
support_code: Option<&str>,
machine_uid: Option<&str>,
) -> Result<Self> {
// Build query parameters. agent_id + api_key are kept exactly as-is;
// machine_uid is appended ALONGSIDE them (v2 stable-identity Task 1) so
// the server sees the deterministic identity at connect time. It does not
// change registration keying (a separate server-side task).
let mut params = format!("agent_id={}&api_key={}", agent_id, api_key);
if let Some(hostname) = hostname {
params.push_str(&format!("&hostname={}", urlencoding::encode(hostname)));
}
if let Some(machine_uid) = machine_uid {
params.push_str(&format!(
"&machine_uid={}",
urlencoding::encode(machine_uid)
));
}
if let Some(code) = support_code {
params.push_str(&format!("&support_code={}", code));
}
// Append parameters to URL
let url_with_params = if url.contains('?') {
format!("{}&{}", url, params)
} else {
format!("{}?{}", url, params)
};
tracing::info!("Connecting to {} as agent {}", url, agent_id);
if let Some(code) = support_code {
tracing::info!("Using support code: {}", code);
}
let (ws_stream, response) = connect_async(&url_with_params)
.await
.context("Failed to connect to WebSocket server")?;
tracing::info!("Connected, status: {}", response.status());
Ok(Self {
stream: Arc::new(Mutex::new(ws_stream)),
incoming: VecDeque::new(),
connected: true,
})
}
/// Send a protobuf message
pub async fn send(&mut self, msg: Message) -> Result<()> {
let mut stream = self.stream.lock().await;
// Serialize to protobuf binary
let mut buf = Vec::with_capacity(msg.encoded_len());
msg.encode(&mut buf)?;
// Send as binary WebSocket message
stream
.send(WsMessage::Binary(buf))
.await
.context("Failed to send message")?;
Ok(())
}
/// Try to receive a message (non-blocking)
pub fn try_recv(&mut self) -> Result<Option<Message>> {
// Return buffered message if available
if let Some(msg) = self.incoming.pop_front() {
return Ok(Some(msg));
}
// Try to receive more messages
let stream = self.stream.clone();
let result = tokio::task::block_in_place(|| {
tokio::runtime::Handle::current().block_on(async {
let mut stream = stream.lock().await;
// Use try_next for non-blocking receive
match tokio::time::timeout(std::time::Duration::from_millis(1), stream.next()).await
{
Ok(Some(Ok(ws_msg))) => Ok(Some(ws_msg)),
Ok(Some(Err(e))) => Err(anyhow::anyhow!("WebSocket error: {}", e)),
Ok(None) => {
// Connection closed
Ok(None)
}
Err(_) => {
// Timeout - no message available
Ok(None)
}
}
})
});
match result? {
Some(ws_msg) => {
if let Some(msg) = self.parse_message(ws_msg)? {
Ok(Some(msg))
} else {
Ok(None)
}
}
None => Ok(None),
}
}
/// Receive a message (blocking)
#[allow(dead_code)]
pub async fn recv(&mut self) -> Result<Option<Message>> {
// Return buffered message if available
if let Some(msg) = self.incoming.pop_front() {
return Ok(Some(msg));
}
let result = {
let mut stream = self.stream.lock().await;
stream.next().await
};
match result {
Some(Ok(ws_msg)) => self.parse_message(ws_msg),
Some(Err(e)) => {
self.connected = false;
Err(anyhow::anyhow!("WebSocket error: {}", e))
}
None => {
self.connected = false;
Ok(None)
}
}
}
/// Parse a WebSocket message into a protobuf message
fn parse_message(&mut self, ws_msg: WsMessage) -> Result<Option<Message>> {
match ws_msg {
WsMessage::Binary(data) => {
let msg = Message::decode(Bytes::from(data))
.context("Failed to decode protobuf message")?;
Ok(Some(msg))
}
WsMessage::Ping(_data) => {
// Pong is sent automatically by tungstenite
tracing::trace!("Received ping");
Ok(None)
}
WsMessage::Pong(_) => {
tracing::trace!("Received pong");
Ok(None)
}
WsMessage::Close(frame) => {
tracing::info!("Connection closed: {:?}", frame);
self.connected = false;
Ok(None)
}
WsMessage::Text(text) => {
// We expect binary protobuf, but log text messages
tracing::warn!("Received unexpected text message: {}", text);
Ok(None)
}
_ => Ok(None),
}
}
/// Check if connected
pub fn is_connected(&self) -> bool {
self.connected
}
/// Close the connection
#[allow(dead_code)]
pub async fn close(&mut self) -> Result<()> {
let mut stream = self.stream.lock().await;
stream.close(None).await?;
self.connected = false;
Ok(())
}
}