Add magic bytes deployment system for agent modes
- Agent config: Added EmbeddedConfig struct and RunMode enum for filename-based mode detection (Viewer, TempSupport, PermanentAgent) - Agent main: Updated to detect run mode from filename or embedded config - Server: Added /api/download/* endpoints for generating configured binaries - /api/download/viewer - Downloads GuruConnect-Viewer.exe - /api/download/support?code=123456 - Downloads GuruConnect-123456.exe - /api/download/agent?company=X&site=Y - Downloads with embedded config - Dashboard: Updated Build tab with Quick Downloads and Permanent Agent Builder - Included base agent binary in static/downloads 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,10 +1,51 @@
|
|||||||
//! Agent configuration management
|
//! 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 serde::{Deserialize, Serialize};
|
||||||
|
use std::io::{Read, Seek, SeekFrom};
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
use tracing::{info, warn};
|
||||||
use uuid::Uuid;
|
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<String>,
|
||||||
|
/// Site/location name
|
||||||
|
#[serde(default)]
|
||||||
|
pub site: Option<String>,
|
||||||
|
/// Tags for categorization
|
||||||
|
#[serde(default)]
|
||||||
|
pub tags: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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
|
/// Agent configuration
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct Config {
|
pub struct Config {
|
||||||
@@ -21,7 +62,19 @@ pub struct Config {
|
|||||||
/// Optional hostname override
|
/// Optional hostname override
|
||||||
pub hostname_override: Option<String>,
|
pub hostname_override: Option<String>,
|
||||||
|
|
||||||
/// Support code for one-time support sessions (set via command line)
|
/// Company/organization name (from embedded config)
|
||||||
|
#[serde(default)]
|
||||||
|
pub company: Option<String>,
|
||||||
|
|
||||||
|
/// Site/location name (from embedded config)
|
||||||
|
#[serde(default)]
|
||||||
|
pub site: Option<String>,
|
||||||
|
|
||||||
|
/// Tags for categorization (from embedded config)
|
||||||
|
#[serde(default)]
|
||||||
|
pub tags: Vec<String>,
|
||||||
|
|
||||||
|
/// Support code for one-time support sessions (set via command line or filename)
|
||||||
#[serde(skip)]
|
#[serde(skip)]
|
||||||
pub support_code: Option<String>,
|
pub support_code: Option<String>,
|
||||||
|
|
||||||
@@ -105,9 +158,134 @@ impl Default for EncodingConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Config {
|
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<String> {
|
||||||
|
// 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<EmbeddedConfig> {
|
||||||
|
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
|
/// Check if an explicit agent configuration file exists
|
||||||
/// This returns true only if there's a real config file, not generated defaults
|
/// This returns true only if there's a real config file, not generated defaults
|
||||||
pub fn has_agent_config() -> bool {
|
pub fn has_agent_config() -> bool {
|
||||||
|
// Check for embedded config first
|
||||||
|
if Self::has_embedded_config() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
// Check for config in current directory
|
// Check for config in current directory
|
||||||
let local_config = PathBuf::from("guruconnect.toml");
|
let local_config = PathBuf::from("guruconnect.toml");
|
||||||
if local_config.exists() {
|
if local_config.exists() {
|
||||||
@@ -130,9 +308,30 @@ impl Config {
|
|||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Load configuration from file or environment
|
/// Load configuration from embedded config, file, or environment
|
||||||
pub fn load() -> Result<Self> {
|
pub fn load() -> Result<Self> {
|
||||||
// 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();
|
let config_path = Self::config_path();
|
||||||
|
|
||||||
if config_path.exists() {
|
if config_path.exists() {
|
||||||
@@ -154,7 +353,7 @@ impl Config {
|
|||||||
return Ok(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")
|
let server_url = std::env::var("GURUCONNECT_SERVER_URL")
|
||||||
.unwrap_or_else(|_| "wss://connect.azcomputerguru.com/ws/agent".to_string());
|
.unwrap_or_else(|_| "wss://connect.azcomputerguru.com/ws/agent".to_string());
|
||||||
|
|
||||||
@@ -169,7 +368,10 @@ impl Config {
|
|||||||
api_key,
|
api_key,
|
||||||
agent_id,
|
agent_id,
|
||||||
hostname_override: std::env::var("GURUCONNECT_HOSTNAME").ok(),
|
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(),
|
capture: CaptureConfig::default(),
|
||||||
encoding: EncodingConfig::default(),
|
encoding: EncodingConfig::default(),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -217,27 +217,59 @@ fn main() -> Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
None => {
|
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 {
|
if let Some(code) = cli.support_code {
|
||||||
run_agent_mode(Some(code))
|
return run_agent_mode(Some(code));
|
||||||
} else {
|
}
|
||||||
// No args: check what mode to run
|
|
||||||
if !install::is_protocol_handler_registered() {
|
// Detect run mode from filename
|
||||||
// Protocol handler not registered - user likely downloaded from web
|
use config::RunMode;
|
||||||
// Run installer to set up protocol handler
|
match config::Config::detect_run_mode() {
|
||||||
info!("Protocol handler not registered, running installer");
|
RunMode::Viewer => {
|
||||||
run_install(false)
|
// Filename indicates viewer-only (e.g., "GuruConnect-Viewer.exe")
|
||||||
} else if config::Config::has_agent_config() {
|
info!("Viewer mode detected from filename");
|
||||||
// Protocol handler exists AND agent config exists
|
if !install::is_protocol_handler_registered() {
|
||||||
// This is an agent installation - run as agent
|
info!("Installing protocol handler for viewer");
|
||||||
info!("Agent config found, running as agent");
|
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)
|
run_agent_mode(None)
|
||||||
} else {
|
}
|
||||||
// Protocol handler exists but NO agent config
|
RunMode::Default => {
|
||||||
// This is a viewer-only installation - just exit silently
|
// No special mode detected - use legacy logic
|
||||||
// The protocol handler will launch the viewer when needed
|
if !install::is_protocol_handler_registered() {
|
||||||
info!("Viewer-only installation, exiting (use 'guruconnect agent' to run as agent)");
|
// Protocol handler not registered - user likely downloaded from web
|
||||||
Ok(())
|
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<String>) -> Result<()> {
|
|||||||
info!("Running with standard user privileges");
|
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
|
// Load configuration
|
||||||
let mut config = config::Config::load()?;
|
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);
|
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
|
// Run the agent
|
||||||
let rt = tokio::runtime::Runtime::new()?;
|
let rt = tokio::runtime::Runtime::new()?;
|
||||||
@@ -330,30 +368,6 @@ fn run_uninstall() -> Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Extract a 6-digit support code from the executable's filename
|
|
||||||
fn extract_code_from_filename() -> Option<String> {
|
|
||||||
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)
|
/// Show a message box (Windows only)
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
fn show_message_box(title: &str, message: &str) {
|
fn show_message_box(title: &str, message: &str) {
|
||||||
|
|||||||
268
server/src/api/downloads.rs
Normal file
268
server/src/api/downloads.rs
Normal file
@@ -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<String>,
|
||||||
|
/// Site/location name
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub site: Option<String>,
|
||||||
|
/// Tags for categorization
|
||||||
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
|
pub tags: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Query parameters for agent download
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct AgentDownloadParams {
|
||||||
|
/// Company/organization name
|
||||||
|
pub company: Option<String>,
|
||||||
|
/// Site/location name
|
||||||
|
pub site: Option<String>,
|
||||||
|
/// Comma-separated tags
|
||||||
|
pub tags: Option<String>,
|
||||||
|
/// API key (optional, will use default if not provided)
|
||||||
|
pub api_key: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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<SupportDownloadParams>,
|
||||||
|
) -> 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<AgentDownloadParams>,
|
||||||
|
) -> 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::<String>()
|
||||||
|
.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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
pub mod auth;
|
pub mod auth;
|
||||||
pub mod users;
|
pub mod users;
|
||||||
pub mod releases;
|
pub mod releases;
|
||||||
|
pub mod downloads;
|
||||||
|
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::{Path, State, Query},
|
extract::{Path, State, Query},
|
||||||
|
|||||||
@@ -233,6 +233,11 @@ async fn main() -> Result<()> {
|
|||||||
.route("/api/releases/:version", put(api::releases::update_release))
|
.route("/api/releases/:version", put(api::releases::update_release))
|
||||||
.route("/api/releases/:version", delete(api::releases::delete_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)
|
// HTML page routes (clean URLs)
|
||||||
.route("/login", get(serve_login))
|
.route("/login", get(serve_login))
|
||||||
.route("/dashboard", get(serve_dashboard))
|
.route("/dashboard", get(serve_dashboard))
|
||||||
|
|||||||
@@ -477,20 +477,50 @@
|
|||||||
|
|
||||||
<!-- Build Tab -->
|
<!-- Build Tab -->
|
||||||
<div class="tab-panel" id="build-panel">
|
<div class="tab-panel" id="build-panel">
|
||||||
|
<!-- Quick Downloads -->
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<div>
|
<div>
|
||||||
<h2 class="card-title">Installer Builder</h2>
|
<h2 class="card-title">Quick Downloads</h2>
|
||||||
|
<p class="card-description">Download viewer or create temp support sessions</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 16px;">
|
||||||
|
<div style="padding: 20px; background: hsl(var(--muted)); border-radius: 8px;">
|
||||||
|
<h3 style="font-size: 16px; margin-bottom: 8px;">Viewer Only</h3>
|
||||||
|
<p style="font-size: 13px; color: hsl(var(--muted-foreground)); margin-bottom: 16px;">
|
||||||
|
Installs the protocol handler for connecting to remote sessions. No agent functionality.
|
||||||
|
</p>
|
||||||
|
<a href="/api/download/viewer" class="btn btn-primary" style="text-decoration: none;">Download Viewer</a>
|
||||||
|
</div>
|
||||||
|
<div style="padding: 20px; background: hsl(var(--muted)); border-radius: 8px;">
|
||||||
|
<h3 style="font-size: 16px; margin-bottom: 8px;">Temp Support Session</h3>
|
||||||
|
<p style="font-size: 13px; color: hsl(var(--muted-foreground)); margin-bottom: 12px;">
|
||||||
|
Generate a support code first, then create a download link with that code embedded.
|
||||||
|
</p>
|
||||||
|
<div style="display: flex; gap: 8px; align-items: center;">
|
||||||
|
<input type="text" id="supportCodeInput" placeholder="6-digit code" maxlength="6"
|
||||||
|
style="width: 120px; padding: 8px 12px; font-size: 14px; background: hsl(var(--input)); border: 1px solid hsl(var(--border)); border-radius: 6px; color: hsl(var(--foreground));">
|
||||||
|
<button class="btn btn-outline" onclick="downloadSupportAgent()">Generate Link</button>
|
||||||
|
</div>
|
||||||
|
<div id="supportDownloadLink" style="margin-top: 12px; display: none;">
|
||||||
|
<a href="#" id="supportDownloadAnchor" class="btn btn-primary" style="text-decoration: none;">Download Support Agent</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Permanent Agent Builder -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<div>
|
||||||
|
<h2 class="card-title">Permanent Agent Builder</h2>
|
||||||
<p class="card-description">Create customized agent installers for unattended access</p>
|
<p class="card-description">Create customized agent installers for unattended access</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-grid">
|
<div class="form-grid">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="buildName">Name</label>
|
<label for="buildCompany">Company *</label>
|
||||||
<input type="text" id="buildName" placeholder="Machine name (auto if blank)">
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="buildCompany">Company</label>
|
|
||||||
<input type="text" id="buildCompany" placeholder="Client organization">
|
<input type="text" id="buildCompany" placeholder="Client organization">
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
@@ -498,29 +528,19 @@
|
|||||||
<input type="text" id="buildSite" placeholder="Physical location">
|
<input type="text" id="buildSite" placeholder="Physical location">
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="buildDepartment">Department</label>
|
<label for="buildTags">Tags</label>
|
||||||
<input type="text" id="buildDepartment" placeholder="Business unit">
|
<input type="text" id="buildTags" placeholder="Comma-separated (e.g., workstation, finance)">
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="buildDeviceType">Device Type</label>
|
<label for="buildApiKey">API Key</label>
|
||||||
<select id="buildDeviceType">
|
<input type="text" id="buildApiKey" placeholder="Optional (uses default if blank)">
|
||||||
<option value="workstation">Workstation</option>
|
|
||||||
<option value="laptop">Laptop</option>
|
|
||||||
<option value="server">Server</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="buildTag">Tag</label>
|
|
||||||
<input type="text" id="buildTag" placeholder="Custom label">
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style="margin-top: 24px; display: flex; gap: 12px;">
|
<div style="margin-top: 24px; display: flex; gap: 12px; flex-wrap: wrap;">
|
||||||
<button class="btn btn-primary" disabled>Build EXE (64-bit)</button>
|
<button class="btn btn-primary" onclick="buildPermanentAgent()">Download Configured Agent</button>
|
||||||
<button class="btn btn-outline" disabled>Build EXE (32-bit)</button>
|
|
||||||
<button class="btn btn-outline" disabled>Build MSI</button>
|
|
||||||
</div>
|
</div>
|
||||||
<p style="margin-top: 16px; font-size: 13px; color: hsl(var(--muted-foreground));">
|
<p style="margin-top: 16px; font-size: 13px; color: hsl(var(--muted-foreground));">
|
||||||
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.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1371,6 +1391,46 @@
|
|||||||
div.textContent = text;
|
div.textContent = text;
|
||||||
return div.innerHTML;
|
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;
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
BIN
server/static/downloads/guruconnect.exe
Normal file
BIN
server/static/downloads/guruconnect.exe
Normal file
Binary file not shown.
Reference in New Issue
Block a user