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:
2025-12-30 11:13:16 -07:00
parent 0387295401
commit 5a82637a04
7 changed files with 629 additions and 79 deletions

View File

@@ -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<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
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Config {
@@ -21,7 +62,19 @@ pub struct Config {
/// Optional hostname override
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)]
pub support_code: Option<String>,
@@ -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<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
/// 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<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();
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(),
};

View File

@@ -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<String>) -> 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<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)
#[cfg(windows)]
fn show_message_box(title: &str, message: &str) {