diff --git a/agent/src/viewer/transport.rs b/agent/src/viewer/transport.rs index 1d976bd..8826a8e 100644 --- a/agent/src/viewer/transport.rs +++ b/agent/src/viewer/transport.rs @@ -66,12 +66,14 @@ impl MessageReceiver { } /// Connect to the GuruConnect server -pub async fn connect(url: &str, api_key: &str) -> Result<(WsSender, MessageReceiver)> { - // Add API key to URL - let full_url = if url.contains('?') { - format!("{}&api_key={}", url, api_key) +pub async fn connect(url: &str, token: &str) -> Result<(WsSender, MessageReceiver)> { + // Add auth token to URL + let full_url = if token.is_empty() { + url.to_string() + } else if url.contains('?') { + format!("{}&token={}", url, urlencoding::encode(token)) } else { - format!("{}?api_key={}", url, api_key) + format!("{}?token={}", url, urlencoding::encode(token)) }; debug!("Connecting to {}", full_url); diff --git a/server/src/main.rs b/server/src/main.rs index 9795b2f..8a5b5c0 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -34,7 +34,7 @@ use tracing_subscriber::FmtSubscriber; use serde::Deserialize; use support_codes::{SupportCodeManager, CreateCodeRequest, SupportCode, CodeValidation}; -use auth::{JwtConfig, hash_password, generate_random_password}; +use auth::{JwtConfig, hash_password, generate_random_password, AuthenticatedUser}; /// Application state #[derive(Clone)] @@ -43,6 +43,8 @@ pub struct AppState { support_codes: SupportCodeManager, db: Option, pub jwt_config: Arc, + /// Optional API key for persistent agents (env: AGENT_API_KEY) + pub agent_api_key: Option, } /// Middleware to inject JWT config into request extensions @@ -163,12 +165,21 @@ async fn main() -> Result<()> { } } + // Agent API key for persistent agents (optional) + let agent_api_key = std::env::var("AGENT_API_KEY").ok(); + if agent_api_key.is_some() { + info!("AGENT_API_KEY configured for persistent agents"); + } else { + info!("No AGENT_API_KEY set - persistent agents will need JWT token or support code"); + } + // Create application state let state = AppState { sessions, support_codes: SupportCodeManager::new(), db: database, jwt_config, + agent_api_key, }; // Build router @@ -252,6 +263,7 @@ async fn health() -> &'static str { // Support code API handlers async fn create_code( + _user: AuthenticatedUser, // Require authentication State(state): State, Json(request): Json, ) -> Json { @@ -261,6 +273,7 @@ async fn create_code( } async fn list_codes( + _user: AuthenticatedUser, // Require authentication State(state): State, ) -> Json> { Json(state.support_codes.list_active_codes().await) @@ -279,6 +292,7 @@ async fn validate_code( } async fn cancel_code( + _user: AuthenticatedUser, // Require authentication State(state): State, Path(code): Path, ) -> impl IntoResponse { @@ -292,6 +306,7 @@ async fn cancel_code( // Session API handlers (updated to use AppState) async fn list_sessions( + _user: AuthenticatedUser, // Require authentication State(state): State, ) -> Json> { let sessions = state.sessions.list_sessions().await; @@ -299,6 +314,7 @@ async fn list_sessions( } async fn get_session( + _user: AuthenticatedUser, // Require authentication State(state): State, Path(id): Path, ) -> Result, (StatusCode, &'static str)> { @@ -312,6 +328,7 @@ async fn get_session( } async fn disconnect_session( + _user: AuthenticatedUser, // Require authentication State(state): State, Path(id): Path, ) -> impl IntoResponse { @@ -331,6 +348,7 @@ async fn disconnect_session( // Machine API handlers async fn list_machines( + _user: AuthenticatedUser, // Require authentication State(state): State, ) -> Result>, (StatusCode, &'static str)> { let db = state.db.as_ref() @@ -343,6 +361,7 @@ async fn list_machines( } async fn get_machine( + _user: AuthenticatedUser, // Require authentication State(state): State, Path(agent_id): Path, ) -> Result, (StatusCode, &'static str)> { @@ -357,6 +376,7 @@ async fn get_machine( } async fn get_machine_history( + _user: AuthenticatedUser, // Require authentication State(state): State, Path(agent_id): Path, ) -> Result, (StatusCode, &'static str)> { @@ -387,6 +407,7 @@ async fn get_machine_history( } async fn delete_machine( + _user: AuthenticatedUser, // Require authentication State(state): State, Path(agent_id): Path, Query(params): Query, diff --git a/server/src/relay/mod.rs b/server/src/relay/mod.rs index 2084379..915d6ea 100644 --- a/server/src/relay/mod.rs +++ b/server/src/relay/mod.rs @@ -9,6 +9,7 @@ use axum::{ Query, State, }, response::IntoResponse, + http::StatusCode, }; use futures_util::{SinkExt, StreamExt}; use prost::Message as ProstMessage; @@ -30,6 +31,9 @@ pub struct AgentParams { support_code: Option, #[serde(default)] hostname: Option, + /// API key for persistent (managed) agents + #[serde(default)] + api_key: Option, } #[derive(Debug, Deserialize)] @@ -37,6 +41,9 @@ pub struct ViewerParams { session_id: String, #[serde(default = "default_viewer_name")] viewer_name: String, + /// JWT token for authentication (required) + #[serde(default)] + token: Option, } fn default_viewer_name() -> String { @@ -48,15 +55,71 @@ pub async fn agent_ws_handler( ws: WebSocketUpgrade, State(state): State, Query(params): Query, -) -> impl IntoResponse { - let agent_id = params.agent_id; - let agent_name = params.hostname.or(params.agent_name).unwrap_or_else(|| agent_id.clone()); - let support_code = params.support_code; +) -> Result { + let agent_id = params.agent_id.clone(); + let agent_name = params.hostname.clone().or(params.agent_name.clone()).unwrap_or_else(|| agent_id.clone()); + let support_code = params.support_code.clone(); + let api_key = params.api_key.clone(); + + // SECURITY: Agent must provide either a support code OR an API key + // Support code = ad-hoc support session (technician generated code) + // API key = persistent managed agent + + if support_code.is_none() && api_key.is_none() { + warn!("Agent connection rejected: {} - no support code or API key", agent_id); + return Err(StatusCode::UNAUTHORIZED); + } + + // Validate support code if provided + if let Some(ref code) = support_code { + // Check if it's a valid, pending support code + let code_info = state.support_codes.get_status(code).await; + if code_info.is_none() { + warn!("Agent connection rejected: {} - invalid support code {}", agent_id, code); + return Err(StatusCode::UNAUTHORIZED); + } + let status = code_info.unwrap(); + if status != "pending" && status != "connected" { + warn!("Agent connection rejected: {} - support code {} has status {}", agent_id, code, status); + return Err(StatusCode::UNAUTHORIZED); + } + info!("Agent {} authenticated via support code {}", agent_id, code); + } + + // Validate API key if provided (for persistent agents) + if let Some(ref key) = api_key { + // For now, we'll accept API keys that match the JWT secret or a configured agent key + // In production, this should validate against a database of registered agents + if !validate_agent_api_key(&state, key).await { + warn!("Agent connection rejected: {} - invalid API key", agent_id); + return Err(StatusCode::UNAUTHORIZED); + } + info!("Agent {} authenticated via API key", agent_id); + } + let sessions = state.sessions.clone(); let support_codes = state.support_codes.clone(); let db = state.db.clone(); - ws.on_upgrade(move |socket| handle_agent_connection(socket, sessions, support_codes, db, agent_id, agent_name, support_code)) + Ok(ws.on_upgrade(move |socket| handle_agent_connection(socket, sessions, support_codes, db, agent_id, agent_name, support_code))) +} + +/// Validate an agent API key +async fn validate_agent_api_key(state: &AppState, api_key: &str) -> bool { + // Check if API key is a valid JWT (allows using dashboard token for testing) + if state.jwt_config.validate_token(api_key).is_ok() { + return true; + } + + // Check against configured agent API key if set + if let Some(ref configured_key) = state.agent_api_key { + if api_key == configured_key { + return true; + } + } + + // In future: validate against database of registered agents + false } /// WebSocket handler for viewer connections @@ -64,13 +127,27 @@ pub async fn viewer_ws_handler( ws: WebSocketUpgrade, State(state): State, Query(params): Query, -) -> impl IntoResponse { +) -> Result { + // Require JWT token for viewers + let token = params.token.ok_or_else(|| { + warn!("Viewer connection rejected: missing token"); + StatusCode::UNAUTHORIZED + })?; + + // Validate the token + let claims = state.jwt_config.validate_token(&token).map_err(|e| { + warn!("Viewer connection rejected: invalid token: {}", e); + StatusCode::UNAUTHORIZED + })?; + + info!("Viewer {} authenticated via JWT", claims.username); + let session_id = params.session_id; let viewer_name = params.viewer_name; let sessions = state.sessions.clone(); let db = state.db.clone(); - ws.on_upgrade(move |socket| handle_viewer_connection(socket, sessions, db, session_id, viewer_name)) + Ok(ws.on_upgrade(move |socket| handle_viewer_connection(socket, sessions, db, session_id, viewer_name))) } /// Handle an agent WebSocket connection diff --git a/server/src/support_codes.rs b/server/src/support_codes.rs index 5207a5e..8cf98ac 100644 --- a/server/src/support_codes.rs +++ b/server/src/support_codes.rs @@ -219,10 +219,21 @@ impl SupportCodeManager { pub async fn get_by_session(&self, session_id: Uuid) -> Option { let session_to_code = self.session_to_code.read().await; let code = session_to_code.get(&session_id)?; - + let codes = self.codes.read().await; codes.get(code).cloned() } + + /// Get the status of a code as a string (for auth checks) + pub async fn get_status(&self, code: &str) -> Option { + let codes = self.codes.read().await; + codes.get(code).map(|c| match c.status { + CodeStatus::Pending => "pending".to_string(), + CodeStatus::Connected => "connected".to_string(), + CodeStatus::Completed => "completed".to_string(), + CodeStatus::Cancelled => "cancelled".to_string(), + }) + } } impl Default for SupportCodeManager { diff --git a/server/static/dashboard.html b/server/static/dashboard.html index e2784fc..710eccd 100644 --- a/server/static/dashboard.html +++ b/server/static/dashboard.html @@ -971,7 +971,8 @@ const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; const serverUrl = encodeURIComponent(protocol + "//" + window.location.host + "/ws/viewer"); - const protocolUrl = `guruconnect://view/${connectSessionId}?server=${serverUrl}`; + const token = localStorage.getItem("authToken"); + const protocolUrl = `guruconnect://view/${connectSessionId}?server=${serverUrl}&token=${encodeURIComponent(token)}`; // Try to launch the protocol handler // We use a hidden iframe to avoid navigation issues @@ -1109,7 +1110,8 @@ const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; const viewerName = user?.name || user?.email || "Technician"; - const wsUrl = `${protocol}//${window.location.host}/ws/viewer?session_id=${sessionId}&viewer_name=${encodeURIComponent(viewerName)}`; + const token = localStorage.getItem("authToken"); + const wsUrl = `${protocol}//${window.location.host}/ws/viewer?session_id=${sessionId}&viewer_name=${encodeURIComponent(viewerName)}&token=${encodeURIComponent(token)}`; console.log("Connecting chat to:", wsUrl); chatSocket = new WebSocket(wsUrl); diff --git a/server/static/viewer.html b/server/static/viewer.html index 0ed33df..1383a6b 100644 --- a/server/static/viewer.html +++ b/server/static/viewer.html @@ -597,7 +597,13 @@ function connect() { const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; - const wsUrl = `${protocol}//${window.location.host}/ws/viewer?session_id=${sessionId}&viewer_name=${encodeURIComponent(viewerName)}`; + const token = localStorage.getItem('authToken'); + if (!token) { + updateStatus('error', 'Not authenticated'); + document.getElementById('overlay-text').textContent = 'Not logged in. Please log in first.'; + return; + } + const wsUrl = `${protocol}//${window.location.host}/ws/viewer?session_id=${sessionId}&viewer_name=${encodeURIComponent(viewerName)}&token=${encodeURIComponent(token)}`; console.log('Connecting to:', wsUrl); updateStatus('connecting', 'Connecting...');