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

268
server/src/api/downloads.rs Normal file
View 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 (&params.company, &params.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"));
}
}

View File

@@ -3,6 +3,7 @@
pub mod auth;
pub mod users;
pub mod releases;
pub mod downloads;
use axum::{
extract::{Path, State, Query},