diff --git a/agent/src/config.rs b/agent/src/config.rs index 2f75ca8..a2a9872 100644 --- a/agent/src/config.rs +++ b/agent/src/config.rs @@ -1,10 +1,51 @@ //! Agent configuration management +//! +//! Supports three configuration sources (in priority order): +//! 1. Embedded config (magic bytes appended to executable) +//! 2. Config file (guruconnect.toml or %ProgramData%\GuruConnect\agent.toml) +//! 3. Environment variables (fallback) -use anyhow::{Context, Result}; +use anyhow::{anyhow, Context, Result}; use serde::{Deserialize, Serialize}; +use std::io::{Read, Seek, SeekFrom}; use std::path::PathBuf; +use tracing::{info, warn}; use uuid::Uuid; +/// Magic marker for embedded configuration (10 bytes) +const MAGIC_MARKER: &[u8] = b"GURUCONFIG"; + +/// Embedded configuration data (appended to executable) +#[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(default)] + pub company: Option, + /// Site/location name + #[serde(default)] + pub site: Option, + /// Tags for categorization + #[serde(default)] + pub tags: Vec, +} + +/// Detected run mode based on filename +#[derive(Debug, Clone, PartialEq)] +pub enum RunMode { + /// Viewer-only installation (filename contains "Viewer") + Viewer, + /// Temporary support session (filename contains 6-digit code) + TempSupport(String), + /// Permanent agent with embedded config + PermanentAgent, + /// Unknown/default mode + Default, +} + /// Agent configuration #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Config { @@ -21,7 +62,19 @@ pub struct Config { /// Optional hostname override pub hostname_override: Option, - /// Support code for one-time support sessions (set via command line) + /// Company/organization name (from embedded config) + #[serde(default)] + pub company: Option, + + /// Site/location name (from embedded config) + #[serde(default)] + pub site: Option, + + /// Tags for categorization (from embedded config) + #[serde(default)] + pub tags: Vec, + + /// Support code for one-time support sessions (set via command line or filename) #[serde(skip)] pub support_code: Option, @@ -105,9 +158,134 @@ impl Default for EncodingConfig { } impl Config { + /// Detect run mode from executable filename + pub fn detect_run_mode() -> RunMode { + let exe_path = match std::env::current_exe() { + Ok(p) => p, + Err(_) => return RunMode::Default, + }; + + let filename = match exe_path.file_stem() { + Some(s) => s.to_string_lossy().to_string(), + None => return RunMode::Default, + }; + + let filename_lower = filename.to_lowercase(); + + // Check for viewer mode + if filename_lower.contains("viewer") { + info!("Detected viewer mode from filename: {}", filename); + return RunMode::Viewer; + } + + // Check for support code in filename (6-digit number) + if let Some(code) = Self::extract_support_code(&filename) { + info!("Detected support code from filename: {}", code); + return RunMode::TempSupport(code); + } + + // Check for embedded config + if Self::has_embedded_config() { + info!("Detected embedded config in executable"); + return RunMode::PermanentAgent; + } + + RunMode::Default + } + + /// Extract 6-digit support code from filename + fn extract_support_code(filename: &str) -> Option { + // Look for patterns like "GuruConnect-123456" or "GuruConnect_123456" + for part in filename.split(|c| c == '-' || c == '_' || c == '.') { + let trimmed = part.trim(); + if trimmed.len() == 6 && trimmed.chars().all(|c| c.is_ascii_digit()) { + return Some(trimmed.to_string()); + } + } + + // Check if last 6 characters are all digits + if filename.len() >= 6 { + let last_six = &filename[filename.len() - 6..]; + if last_six.chars().all(|c| c.is_ascii_digit()) { + return Some(last_six.to_string()); + } + } + + None + } + + /// Check if embedded configuration exists in the executable + pub fn has_embedded_config() -> bool { + Self::read_embedded_config().is_ok() + } + + /// Read embedded configuration from the executable + pub fn read_embedded_config() -> Result { + let exe_path = std::env::current_exe() + .context("Failed to get current executable path")?; + + let mut file = std::fs::File::open(&exe_path) + .context("Failed to open executable for reading")?; + + let file_size = file.metadata()?.len(); + if file_size < (MAGIC_MARKER.len() + 4) as u64 { + return Err(anyhow!("File too small to contain embedded config")); + } + + // Read the last part of the file to find magic marker + // Structure: [PE binary][GURUCONFIG][length:u32][json config] + // We need to search backwards from the end + + // Read last 64KB (should be more than enough for config) + let search_size = std::cmp::min(65536, file_size as usize); + let search_start = file_size - search_size as u64; + + file.seek(SeekFrom::Start(search_start))?; + let mut buffer = vec![0u8; search_size]; + file.read_exact(&mut buffer)?; + + // Find magic marker + let marker_pos = buffer.windows(MAGIC_MARKER.len()) + .rposition(|window| window == MAGIC_MARKER) + .ok_or_else(|| anyhow!("Magic marker not found"))?; + + // Read config length (4 bytes after marker) + let length_start = marker_pos + MAGIC_MARKER.len(); + if length_start + 4 > buffer.len() { + return Err(anyhow!("Invalid embedded config: length field truncated")); + } + + let config_length = u32::from_le_bytes([ + buffer[length_start], + buffer[length_start + 1], + buffer[length_start + 2], + buffer[length_start + 3], + ]) as usize; + + // Read config data + let config_start = length_start + 4; + if config_start + config_length > buffer.len() { + return Err(anyhow!("Invalid embedded config: data truncated")); + } + + let config_bytes = &buffer[config_start..config_start + config_length]; + let config: EmbeddedConfig = serde_json::from_slice(config_bytes) + .context("Failed to parse embedded config JSON")?; + + info!("Loaded embedded config: server={}, company={:?}", + config.server_url, config.company); + + Ok(config) + } + /// Check if an explicit agent configuration file exists /// This returns true only if there's a real config file, not generated defaults pub fn has_agent_config() -> bool { + // Check for embedded config first + if Self::has_embedded_config() { + return true; + } + // Check for config in current directory let local_config = PathBuf::from("guruconnect.toml"); if local_config.exists() { @@ -130,9 +308,30 @@ impl Config { false } - /// Load configuration from file or environment + /// Load configuration from embedded config, file, or environment pub fn load() -> Result { - // Try loading from config file + // Priority 1: Try loading from embedded config + if let Ok(embedded) = Self::read_embedded_config() { + info!("Using embedded configuration"); + let config = Config { + server_url: embedded.server_url, + api_key: embedded.api_key, + agent_id: generate_agent_id(), + hostname_override: None, + company: embedded.company, + site: embedded.site, + tags: embedded.tags, + support_code: None, + capture: CaptureConfig::default(), + encoding: EncodingConfig::default(), + }; + + // Save to file for persistence (so agent_id is preserved) + let _ = config.save(); + return Ok(config); + } + + // Priority 2: Try loading from config file let config_path = Self::config_path(); if config_path.exists() { @@ -154,7 +353,7 @@ impl Config { return Ok(config); } - // Fall back to environment variables + // Priority 3: Fall back to environment variables let server_url = std::env::var("GURUCONNECT_SERVER_URL") .unwrap_or_else(|_| "wss://connect.azcomputerguru.com/ws/agent".to_string()); @@ -169,7 +368,10 @@ impl Config { api_key, agent_id, hostname_override: std::env::var("GURUCONNECT_HOSTNAME").ok(), - support_code: None, // Set via CLI + company: None, + site: None, + tags: Vec::new(), + support_code: None, capture: CaptureConfig::default(), encoding: EncodingConfig::default(), }; diff --git a/agent/src/main.rs b/agent/src/main.rs index 7869e8b..8d980e4 100644 --- a/agent/src/main.rs +++ b/agent/src/main.rs @@ -217,27 +217,59 @@ fn main() -> Result<()> { Ok(()) } None => { - // Legacy mode: if a support code was provided, run as agent + // No subcommand - detect mode from filename or embedded config + // Legacy: if support_code arg provided, use that if let Some(code) = cli.support_code { - run_agent_mode(Some(code)) - } else { - // No args: check what mode to run - if !install::is_protocol_handler_registered() { - // Protocol handler not registered - user likely downloaded from web - // Run installer to set up protocol handler - info!("Protocol handler not registered, running installer"); - run_install(false) - } else if config::Config::has_agent_config() { - // Protocol handler exists AND agent config exists - // This is an agent installation - run as agent - info!("Agent config found, running as agent"); + return run_agent_mode(Some(code)); + } + + // Detect run mode from filename + use config::RunMode; + match config::Config::detect_run_mode() { + RunMode::Viewer => { + // Filename indicates viewer-only (e.g., "GuruConnect-Viewer.exe") + info!("Viewer mode detected from filename"); + if !install::is_protocol_handler_registered() { + info!("Installing protocol handler for viewer"); + run_install(false) + } else { + info!("Viewer already installed, nothing to do"); + show_message_box("GuruConnect Viewer", "GuruConnect viewer is installed.\n\nUse guruconnect:// links to connect to remote sessions."); + Ok(()) + } + } + RunMode::TempSupport(code) => { + // Filename contains support code (e.g., "GuruConnect-123456.exe") + info!("Temp support session detected from filename: {}", code); + run_agent_mode(Some(code)) + } + RunMode::PermanentAgent => { + // Embedded config found - run as permanent agent + info!("Permanent agent mode detected (embedded config)"); + if !install::is_protocol_handler_registered() { + // First run - install then run as agent + info!("First run - installing agent"); + if let Err(e) = install::install(false) { + warn!("Installation failed: {}", e); + } + } run_agent_mode(None) - } else { - // Protocol handler exists but NO agent config - // This is a viewer-only installation - just exit silently - // The protocol handler will launch the viewer when needed - info!("Viewer-only installation, exiting (use 'guruconnect agent' to run as agent)"); - Ok(()) + } + RunMode::Default => { + // No special mode detected - use legacy logic + if !install::is_protocol_handler_registered() { + // Protocol handler not registered - user likely downloaded from web + info!("Protocol handler not registered, running installer"); + run_install(false) + } else if config::Config::has_agent_config() { + // Has agent config - run as agent + info!("Agent config found, running as agent"); + run_agent_mode(None) + } else { + // Viewer-only installation - just exit silently + info!("Viewer-only installation, exiting"); + Ok(()) + } } } } @@ -255,16 +287,22 @@ fn run_agent_mode(support_code: Option) -> Result<()> { info!("Running with standard user privileges"); } - // Also check for support code in filename (legacy compatibility) - let code = support_code.or_else(extract_code_from_filename); - if let Some(ref c) = code { - info!("Support code: {}", c); - } - // Load configuration let mut config = config::Config::load()?; - config.support_code = code; + + // Set support code if provided + if let Some(code) = support_code { + info!("Support code: {}", code); + config.support_code = Some(code); + } + info!("Server: {}", config.server_url); + if let Some(ref company) = config.company { + info!("Company: {}", company); + } + if let Some(ref site) = config.site { + info!("Site: {}", site); + } // Run the agent let rt = tokio::runtime::Runtime::new()?; @@ -330,30 +368,6 @@ fn run_uninstall() -> Result<()> { Ok(()) } -/// Extract a 6-digit support code from the executable's filename -fn extract_code_from_filename() -> Option { - let exe_path = std::env::current_exe().ok()?; - let filename = exe_path.file_stem()?.to_str()?; - - // Look for a 6-digit number in the filename - for part in filename.split(|c| c == '-' || c == '_' || c == '.') { - let trimmed = part.trim(); - if trimmed.len() == 6 && trimmed.chars().all(|c| c.is_ascii_digit()) { - return Some(trimmed.to_string()); - } - } - - // Check if the last 6 characters are digits - if filename.len() >= 6 { - let last_six = &filename[filename.len() - 6..]; - if last_six.chars().all(|c| c.is_ascii_digit()) { - return Some(last_six.to_string()); - } - } - - None -} - /// Show a message box (Windows only) #[cfg(windows)] fn show_message_box(title: &str, message: &str) { diff --git a/server/src/api/downloads.rs b/server/src/api/downloads.rs new file mode 100644 index 0000000..948a290 --- /dev/null +++ b/server/src/api/downloads.rs @@ -0,0 +1,268 @@ +//! 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::{Path, Query, State}, + http::{header, StatusCode}, + response::{IntoResponse, Response}, +}; +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; +use tracing::{info, warn, error}; + +/// 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")); + } +} diff --git a/server/src/api/mod.rs b/server/src/api/mod.rs index 00b297b..08a922e 100644 --- a/server/src/api/mod.rs +++ b/server/src/api/mod.rs @@ -3,6 +3,7 @@ pub mod auth; pub mod users; pub mod releases; +pub mod downloads; use axum::{ extract::{Path, State, Query}, diff --git a/server/src/main.rs b/server/src/main.rs index 5cc3dda..c82a03d 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -233,6 +233,11 @@ async fn main() -> Result<()> { .route("/api/releases/:version", put(api::releases::update_release)) .route("/api/releases/:version", delete(api::releases::delete_release)) + // Agent downloads (no auth - public download links) + .route("/api/download/viewer", get(api::downloads::download_viewer)) + .route("/api/download/support", get(api::downloads::download_support)) + .route("/api/download/agent", get(api::downloads::download_agent)) + // HTML page routes (clean URLs) .route("/login", get(serve_login)) .route("/dashboard", get(serve_dashboard)) diff --git a/server/static/dashboard.html b/server/static/dashboard.html index ec706f6..8942b30 100644 --- a/server/static/dashboard.html +++ b/server/static/dashboard.html @@ -477,20 +477,50 @@
+
-

Installer Builder

+

Quick Downloads

+

Download viewer or create temp support sessions

+
+
+
+
+

Viewer Only

+

+ Installs the protocol handler for connecting to remote sessions. No agent functionality. +

+ Download Viewer +
+
+

Temp Support Session

+

+ Generate a support code first, then create a download link with that code embedded. +

+
+ + +
+ +
+
+
+ + +
+
+
+

Permanent Agent Builder

Create customized agent installers for unattended access

- - -
-
- +
@@ -498,29 +528,19 @@
- - + +
- - -
-
- - + +
-
- - - +
+

- Agent builds will be available once the agent is compiled. + The downloaded agent will have company/site/tags embedded. It will auto-register when run.

@@ -1371,6 +1391,46 @@ div.textContent = text; return div.innerHTML; } + + // ========== Download Functions ========== + + function downloadSupportAgent() { + const code = document.getElementById("supportCodeInput").value.trim(); + if (!code || code.length !== 6 || !/^\d{6}$/.test(code)) { + alert("Please enter a valid 6-digit support code."); + return; + } + + const downloadUrl = "/api/download/support?code=" + code; + const anchor = document.getElementById("supportDownloadAnchor"); + anchor.href = downloadUrl; + anchor.textContent = "Download GuruConnect-" + code + ".exe"; + document.getElementById("supportDownloadLink").style.display = "block"; + + // Automatically start download + window.location.href = downloadUrl; + } + + function buildPermanentAgent() { + const company = document.getElementById("buildCompany").value.trim(); + const site = document.getElementById("buildSite").value.trim(); + const tags = document.getElementById("buildTags").value.trim(); + const apiKey = document.getElementById("buildApiKey").value.trim(); + + if (!company) { + alert("Company name is required for permanent agent builds."); + return; + } + + // Build URL with query parameters + let url = "/api/download/agent?company=" + encodeURIComponent(company); + if (site) url += "&site=" + encodeURIComponent(site); + if (tags) url += "&tags=" + encodeURIComponent(tags); + if (apiKey) url += "&api_key=" + encodeURIComponent(apiKey); + + // Start download + window.location.href = url; + } diff --git a/server/static/downloads/guruconnect.exe b/server/static/downloads/guruconnect.exe new file mode 100644 index 0000000..e06c07b Binary files /dev/null and b/server/static/downloads/guruconnect.exe differ