//! Download endpoints for generating configured agent binaries //! //! Provides endpoints for: //! - Viewer-only downloads //! - Temp support session downloads (with embedded code) //! - Permanent agent downloads (with embedded config) use axum::{ body::Body, extract::Query, http::{header, StatusCode}, response::{IntoResponse, Response}, }; use serde::{Deserialize, Serialize}; use std::path::PathBuf; use tracing::{error, info}; /// Magic marker for embedded configuration (must match agent) const MAGIC_MARKER: &[u8] = b"GURUCONFIG"; /// Embedded configuration data structure #[derive(Debug, Clone, Serialize, Deserialize)] pub struct EmbeddedConfig { /// Server WebSocket URL pub server_url: String, /// API key for authentication pub api_key: String, /// Company/organization name #[serde(skip_serializing_if = "Option::is_none")] pub company: Option, /// Site/location name #[serde(skip_serializing_if = "Option::is_none")] pub site: Option, /// Tags for categorization #[serde(default, skip_serializing_if = "Vec::is_empty")] pub tags: Vec, } /// Query parameters for agent download #[derive(Debug, Deserialize)] pub struct AgentDownloadParams { /// Company/organization name pub company: Option, /// Site/location name pub site: Option, /// Comma-separated tags pub tags: Option, /// API key (optional, will use default if not provided) pub api_key: Option, } /// Query parameters for support session download #[derive(Debug, Deserialize)] pub struct SupportDownloadParams { /// 6-digit support code pub code: String, } /// Get path to base agent binary fn get_base_binary_path() -> PathBuf { // Check for static/downloads/guruconnect.exe relative to working dir let static_path = PathBuf::from("static/downloads/guruconnect.exe"); if static_path.exists() { return static_path; } // Also check without static prefix (in case running from server dir) let downloads_path = PathBuf::from("downloads/guruconnect.exe"); if downloads_path.exists() { return downloads_path; } // Fallback to static path static_path } /// Download viewer-only binary (no embedded config, "Viewer" in filename) pub async fn download_viewer() -> impl IntoResponse { let binary_path = get_base_binary_path(); match std::fs::read(&binary_path) { Ok(binary_data) => { info!("Serving viewer download ({} bytes)", binary_data.len()); Response::builder() .status(StatusCode::OK) .header(header::CONTENT_TYPE, "application/octet-stream") .header( header::CONTENT_DISPOSITION, "attachment; filename=\"GuruConnect-Viewer.exe\"", ) .header(header::CONTENT_LENGTH, binary_data.len()) .body(Body::from(binary_data)) .unwrap() } Err(e) => { error!("Failed to read base binary from {:?}: {}", binary_path, e); Response::builder() .status(StatusCode::NOT_FOUND) .body(Body::from("Agent binary not found")) .unwrap() } } } /// Download support session binary (code embedded in filename) pub async fn download_support(Query(params): Query) -> impl IntoResponse { // Validate support code (must be 6 digits) let code = params.code.trim(); if code.len() != 6 || !code.chars().all(|c| c.is_ascii_digit()) { return Response::builder() .status(StatusCode::BAD_REQUEST) .body(Body::from("Invalid support code: must be 6 digits")) .unwrap(); } let binary_path = get_base_binary_path(); match std::fs::read(&binary_path) { Ok(binary_data) => { info!( "Serving support session download for code {} ({} bytes)", code, binary_data.len() ); // Filename includes the support code let filename = format!("GuruConnect-{}.exe", code); Response::builder() .status(StatusCode::OK) .header(header::CONTENT_TYPE, "application/octet-stream") .header( header::CONTENT_DISPOSITION, format!("attachment; filename=\"{}\"", filename), ) .header(header::CONTENT_LENGTH, binary_data.len()) .body(Body::from(binary_data)) .unwrap() } Err(e) => { error!("Failed to read base binary: {}", e); Response::builder() .status(StatusCode::NOT_FOUND) .body(Body::from("Agent binary not found")) .unwrap() } } } /// Download permanent agent binary with embedded configuration pub async fn download_agent(Query(params): Query) -> impl IntoResponse { let binary_path = get_base_binary_path(); // Read base binary let mut binary_data = match std::fs::read(&binary_path) { Ok(data) => data, Err(e) => { error!("Failed to read base binary: {}", e); return Response::builder() .status(StatusCode::NOT_FOUND) .body(Body::from("Agent binary not found")) .unwrap(); } }; // Build embedded config let config = EmbeddedConfig { server_url: "wss://connect.azcomputerguru.com/ws/agent".to_string(), api_key: params .api_key .unwrap_or_else(|| "managed-agent".to_string()), company: params.company.clone(), site: params.site.clone(), tags: params .tags .as_ref() .map(|t| t.split(',').map(|s| s.trim().to_string()).collect()) .unwrap_or_default(), }; // Serialize config to JSON let config_json = match serde_json::to_vec(&config) { Ok(json) => json, Err(e) => { error!("Failed to serialize config: {}", e); return Response::builder() .status(StatusCode::INTERNAL_SERVER_ERROR) .body(Body::from("Failed to generate config")) .unwrap(); } }; // Append magic marker + length + config to binary // Structure: [PE binary][GURUCONFIG][length:u32 LE][json config] binary_data.extend_from_slice(MAGIC_MARKER); binary_data.extend_from_slice(&(config_json.len() as u32).to_le_bytes()); binary_data.extend_from_slice(&config_json); info!( "Serving permanent agent download: company={:?}, site={:?}, tags={:?} ({} bytes)", config.company, config.site, config.tags, binary_data.len() ); // Generate filename based on company/site let filename = match (¶ms.company, ¶ms.site) { (Some(company), Some(site)) => { format!( "GuruConnect-{}-{}-Setup.exe", sanitize_filename(company), sanitize_filename(site) ) } (Some(company), None) => { format!("GuruConnect-{}-Setup.exe", sanitize_filename(company)) } _ => "GuruConnect-Setup.exe".to_string(), }; Response::builder() .status(StatusCode::OK) .header(header::CONTENT_TYPE, "application/octet-stream") .header( header::CONTENT_DISPOSITION, format!("attachment; filename=\"{}\"", filename), ) .header(header::CONTENT_LENGTH, binary_data.len()) .body(Body::from(binary_data)) .unwrap() } /// Sanitize a string for use in a filename fn sanitize_filename(s: &str) -> String { s.chars() .map(|c| { if c.is_alphanumeric() || c == '-' || c == '_' { c } else if c == ' ' { '-' } else { '_' } }) .collect::() .chars() .take(32) // Limit length .collect() } #[cfg(test)] mod tests { use super::*; #[test] fn test_sanitize_filename() { assert_eq!(sanitize_filename("Acme Corp"), "Acme-Corp"); assert_eq!(sanitize_filename("My Company!"), "My-Company_"); assert_eq!(sanitize_filename("Test/Site"), "Test_Site"); } #[test] fn test_embedded_config_serialization() { let config = EmbeddedConfig { server_url: "wss://example.com/ws".to_string(), api_key: "test-key".to_string(), company: Some("Test Corp".to_string()), site: None, tags: vec!["windows".to_string()], }; let json = serde_json::to_string(&config).unwrap(); assert!(json.contains("Test Corp")); assert!(json.contains("windows")); } }