All checks were successful
Flip both CI gates from informational to hard-fail (SPEC-001 quality gates): - clippy: `-- -D warnings` on the server crate. Cleared the debt via clippy --fix (unused imports/style), targeted #[allow(dead_code)] on native-remote-control future API, and #[allow(clippy::too_many_arguments)] on 3 protocol-mirroring fns. - cargo audit: hard-fail with documented per-ID --ignore flags (rsa RUSTSEC-2023-0071 unfixable/unreachable in active tree; gtk-rs + glib Linux-only tray backend not compiled into the Windows agent; proc-macro-error build-time). New advisories fail. - Move [profile.release] to the workspace root (it was silently ignored in the server member), activating lto/codegen-units/strip. No behavioral changes. Reviewed and gates verified passing on the build host. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
279 lines
8.8 KiB
Rust
279 lines
8.8 KiB
Rust
//! 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::Query,
|
|
http::{header, StatusCode},
|
|
response::{IntoResponse, Response},
|
|
};
|
|
use serde::{Deserialize, Serialize};
|
|
use std::path::PathBuf;
|
|
use tracing::{error, info};
|
|
|
|
/// 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"));
|
|
}
|
|
}
|