Files
guru-connect/agent/src/config.rs
Mike Swanson d0de888dd1
All checks were successful
Build and Test / Build Agent (Windows) (push) Successful in 6m53s
Build and Test / Build Server (Linux) (push) Successful in 10m59s
Build and Test / Security Audit (push) Successful in 4m31s
Build and Test / Build Summary (push) Successful in 10s
style(agent): clear 77 pre-existing clippy -D warnings
CI never ran clippy on the agent crate (the build-server clippy job is
Linux-only and can't compile the Windows agent; build-agent only runs cargo
build), so 77 clippy -D-warnings errors had accumulated. Behavior-preserving
cleanup, code-reviewed APPROVED, locally verified (cargo clippy --workspace
--all-targets --all-features -- -D warnings exits 0; cargo test --workspace =
57 passed).

- let _ = on Win32 resource-teardown BOOL returns (gdi.rs); fallible
  BitBlt/GetDIBits stay error-handled
- removed unused imports/vars; idiom fixes (div_ceil, is_null, transmute
  annotations, match collapsing, useless_conversion)
- #[allow(dead_code)] + comment on genuine Task-6/7 scaffolding (vk consts,
  SpecialKey emission, SAS mgmt API, modifier tracking, GDI frame-diff fields)
- Cargo.lock: cargo pruned ~147 stale transitive entries (no version changes)

Follow-up: add cargo clippy -D warnings to the build-agent CI job so the agent
crate stays clippy-clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 08:51:45 -07:00

462 lines
14 KiB
Rust

//! 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::{anyhow, Context, Result};
use serde::{Deserialize, Serialize};
use std::io::{Read, Seek, SeekFrom};
use std::path::PathBuf;
use tracing::info;
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 {
/// Server WebSocket URL (e.g., wss://connect.example.com/ws)
pub server_url: String,
/// Agent API key for authentication
pub api_key: String,
/// Unique agent identifier (generated on first run)
#[serde(default = "generate_agent_id")]
pub agent_id: String,
/// Optional hostname override
pub hostname_override: Option<String>,
/// 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>,
/// Capture settings
#[serde(default)]
pub capture: CaptureConfig,
/// Encoding settings
#[serde(default)]
pub encoding: EncodingConfig,
}
fn generate_agent_id() -> String {
Uuid::new_v4().to_string()
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CaptureConfig {
/// Target frames per second (1-60)
#[serde(default = "default_fps")]
pub fps: u32,
/// Use DXGI Desktop Duplication (recommended)
#[serde(default = "default_true")]
pub use_dxgi: bool,
/// Fall back to GDI if DXGI fails
#[serde(default = "default_true")]
pub gdi_fallback: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EncodingConfig {
/// Preferred codec (auto, raw, vp9, h264)
#[serde(default = "default_codec")]
pub codec: String,
/// Quality (1-100, higher = better quality, more bandwidth)
#[serde(default = "default_quality")]
pub quality: u32,
/// Use hardware encoding if available
#[serde(default = "default_true")]
pub hardware_encoding: bool,
}
fn default_fps() -> u32 {
30
}
fn default_true() -> bool {
true
}
fn default_codec() -> String {
"auto".to_string()
}
fn default_quality() -> u32 {
75
}
impl Default for CaptureConfig {
fn default() -> Self {
Self {
fps: default_fps(),
use_dxgi: true,
gdi_fallback: true,
}
}
}
impl Default for EncodingConfig {
fn default() -> Self {
Self {
codec: default_codec(),
quality: default_quality(),
hardware_encoding: true,
}
}
}
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(['-', '_', '.']) {
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() {
return true;
}
// Check in program data directory (Windows)
#[cfg(windows)]
{
if let Ok(program_data) = std::env::var("ProgramData") {
let path = PathBuf::from(program_data)
.join("GuruConnect")
.join("agent.toml");
if path.exists() {
return true;
}
}
}
false
}
/// Load configuration from embedded config, file, or environment
pub fn load() -> Result<Self> {
// 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() {
let contents = std::fs::read_to_string(&config_path)
.with_context(|| format!("Failed to read config from {:?}", config_path))?;
let mut config: Config =
toml::from_str(&contents).with_context(|| "Failed to parse config file")?;
// Ensure agent_id is set and saved
if config.agent_id.is_empty() {
config.agent_id = generate_agent_id();
let _ = config.save();
}
// support_code is always None when loading from file (set via CLI)
config.support_code = None;
return Ok(config);
}
// 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());
let api_key =
std::env::var("GURUCONNECT_API_KEY").unwrap_or_else(|_| "dev-key".to_string());
let agent_id =
std::env::var("GURUCONNECT_AGENT_ID").unwrap_or_else(|_| generate_agent_id());
let config = Config {
server_url,
api_key,
agent_id,
hostname_override: std::env::var("GURUCONNECT_HOSTNAME").ok(),
company: None,
site: None,
tags: Vec::new(),
support_code: None,
capture: CaptureConfig::default(),
encoding: EncodingConfig::default(),
};
// Save config with generated agent_id for persistence
let _ = config.save();
Ok(config)
}
/// Get the configuration file path
fn config_path() -> PathBuf {
// Check for config in current directory first
let local_config = PathBuf::from("guruconnect.toml");
if local_config.exists() {
return local_config;
}
// Check in program data directory (Windows)
#[cfg(windows)]
{
if let Ok(program_data) = std::env::var("ProgramData") {
let path = PathBuf::from(program_data)
.join("GuruConnect")
.join("agent.toml");
if path.exists() {
return path;
}
}
}
// Default to local config
local_config
}
/// Get the hostname to use
pub fn hostname(&self) -> String {
self.hostname_override.clone().unwrap_or_else(|| {
hostname::get()
.map(|h| h.to_string_lossy().to_string())
.unwrap_or_else(|_| "unknown".to_string())
})
}
/// Save current configuration to file
pub fn save(&self) -> Result<()> {
let config_path = Self::config_path();
// Ensure parent directory exists
if let Some(parent) = config_path.parent() {
std::fs::create_dir_all(parent)?;
}
let contents = toml::to_string_pretty(self)?;
std::fs::write(&config_path, contents)?;
Ok(())
}
}
/// Example configuration file content
// Retained for documentation / config-template generation.
#[allow(dead_code)]
pub fn example_config() -> &'static str {
r#"# GuruConnect Agent Configuration
# Server connection
server_url = "wss://connect.example.com/ws"
api_key = "your-agent-api-key"
agent_id = "auto-generated-uuid"
# Optional: override hostname
# hostname_override = "custom-hostname"
[capture]
fps = 30
use_dxgi = true
gdi_fallback = true
[encoding]
codec = "auto" # auto, raw, vp9, h264
quality = 75 # 1-100
hardware_encoding = true
"#
}