From 237d1f861eeede7a3c789c04c47e49754eb0b405 Mon Sep 17 00:00:00 2001 From: denys-gif Date: Tue, 23 Dec 2025 13:43:59 +0000 Subject: [PATCH] feat: rust agent as lib --- openframe-agent-lib/.gitignore | 18 + openframe-agent-lib/Cargo.toml | 63 + openframe-agent-lib/src/api.rs | 264 +++++ .../src/clients/auth_client.rs | 93 ++ openframe-agent-lib/src/clients/mod.rs | 9 + .../src/clients/registration_client.rs | 54 + .../src/clients/tool_agent_file_client.rs | 36 + .../src/clients/tool_api_client.rs | 47 + openframe-agent-lib/src/config/mod.rs | 32 + .../src/config/update_config.rs | 16 + .../installation_initial_config_service.rs | 98 ++ openframe-agent-lib/src/lib.rs | 462 ++++++++ openframe-agent-lib/src/listener/mod.rs | 7 + .../openframe_client_update_listener.rs | 203 ++++ .../listener/tool_agent_update_listener.rs | 214 ++++ .../tool_installation_message_listener.rs | 128 ++ openframe-agent-lib/src/logging/metrics.rs | 162 +++ openframe-agent-lib/src/logging/mod.rs | 539 +++++++++ openframe-agent-lib/src/logging/platform.rs | 107 ++ openframe-agent-lib/src/logging/shipping.rs | 103 ++ openframe-agent-lib/src/metrics.rs | 7 + .../src/models/agent_configuration.rs | 10 + .../src/models/agent_registration_request.rs | 11 + .../src/models/agent_registration_response.rs | 9 + .../src/models/agent_token_response.rs | 10 + .../src/models/download_configuration.rs | 28 + .../src/models/initial_configuration.rs | 12 + .../src/models/installed_agent_message.rs | 9 + .../src/models/installed_tool.rs | 41 + .../src/models/machine_heartbeat_message.rs | 13 + openframe-agent-lib/src/models/mod.rs | 37 + .../src/models/openframe_client_info.rs | 23 + .../models/openframe_client_update_message.rs | 9 + .../src/models/tool_agent_update_message.rs | 10 + .../src/models/tool_connection.rs | 8 + .../src/models/tool_connection_message.rs | 8 + .../src/models/tool_installation_message.rs | 54 + .../src/models/tool_installation_result.rs | 6 + .../src/models/update_state.rs | 33 + openframe-agent-lib/src/monitoring/mod.rs | 1 + .../src/monitoring/permissions.rs | 95 ++ .../src/platform/directories.rs | 1048 +++++++++++++++++ openframe-agent-lib/src/platform/file_lock.rs | 181 +++ openframe-agent-lib/src/platform/mod.rs | 20 + .../src/platform/permissions.rs | 628 ++++++++++ .../src/platform/powershell.rs | 46 + openframe-agent-lib/src/platform/uninstall.rs | 452 +++++++ .../src/platform/update_scripts/macos.rs | 139 +++ .../src/platform/update_scripts/mod.rs | 11 + .../src/platform/update_scripts/windows.rs | 124 ++ .../src/platform/updater_launcher/linux.rs | 13 + .../src/platform/updater_launcher/macos.rs | 71 ++ .../src/platform/updater_launcher/mod.rs | 24 + .../src/platform/updater_launcher/windows.rs | 45 + .../src/platform/windows_cleanup.rs | 273 +++++ openframe-agent-lib/src/service.rs | 550 +++++++++ openframe-agent-lib/src/service_adapter.rs | 595 ++++++++++ .../src/services/agent_auth_service.rs | 99 ++ .../services/agent_configuration_service.rs | 94 ++ .../services/agent_registration_service.rs | 82 ++ .../src/services/device_data_fetcher.rs | 37 + .../src/services/encryption_service.rs | 36 + .../src/services/github_download_service.rs | 267 +++++ .../initial_authentication_processor.rs | 58 + .../services/initial_configuration_service.rs | 79 ++ .../installed_agent_message_publisher.rs | 34 + .../src/services/installed_tools_service.rs | 71 ++ .../src/services/local_tls_config_provider.rs | 71 ++ .../services/machine_heartbeat_publisher.rs | 37 + .../services/machine_heartbeat_run_manager.rs | 32 + openframe-agent-lib/src/services/mod.rs | 62 + .../src/services/nats_connection_manager.rs | 138 +++ .../src/services/nats_message_publisher.rs | 26 + .../services/openframe_client_info_service.rs | 81 ++ .../openframe_client_update_service.rs | 268 +++++ .../src/services/registration_processor.rs | 58 + .../src/services/shared_token_service.rs | 32 + .../src/services/tool_agent_update_service.rs | 184 +++ .../services/tool_command_params_resolver.rs | 64 + .../tool_connection_message_publisher.rs | 34 + .../tool_connection_processing_manager.rs | 265 +++++ .../src/services/tool_connection_service.rs | 74 ++ .../src/services/tool_installation_service.rs | 472 ++++++++ .../src/services/tool_kill_service.rs | 279 +++++ .../src/services/tool_run_manager.rs | 633 ++++++++++ .../src/services/tool_uninstall_service.rs | 178 +++ .../src/services/tool_url_params_resolver.rs | 25 + .../src/services/update_cleanup_service.rs | 91 ++ .../src/services/update_handler_service.rs | 123 ++ .../src/services/update_state_service.rs | 76 ++ openframe-agent-lib/src/system.rs | 61 + .../src/utils/version_comparator.rs | 20 + 92 files changed, 11550 insertions(+) create mode 100644 openframe-agent-lib/.gitignore create mode 100644 openframe-agent-lib/Cargo.toml create mode 100644 openframe-agent-lib/src/api.rs create mode 100644 openframe-agent-lib/src/clients/auth_client.rs create mode 100644 openframe-agent-lib/src/clients/mod.rs create mode 100644 openframe-agent-lib/src/clients/registration_client.rs create mode 100644 openframe-agent-lib/src/clients/tool_agent_file_client.rs create mode 100644 openframe-agent-lib/src/clients/tool_api_client.rs create mode 100644 openframe-agent-lib/src/config/mod.rs create mode 100644 openframe-agent-lib/src/config/update_config.rs create mode 100644 openframe-agent-lib/src/installation_initial_config_service.rs create mode 100644 openframe-agent-lib/src/lib.rs create mode 100644 openframe-agent-lib/src/listener/mod.rs create mode 100644 openframe-agent-lib/src/listener/openframe_client_update_listener.rs create mode 100644 openframe-agent-lib/src/listener/tool_agent_update_listener.rs create mode 100644 openframe-agent-lib/src/listener/tool_installation_message_listener.rs create mode 100644 openframe-agent-lib/src/logging/metrics.rs create mode 100644 openframe-agent-lib/src/logging/mod.rs create mode 100644 openframe-agent-lib/src/logging/platform.rs create mode 100644 openframe-agent-lib/src/logging/shipping.rs create mode 100644 openframe-agent-lib/src/metrics.rs create mode 100644 openframe-agent-lib/src/models/agent_configuration.rs create mode 100644 openframe-agent-lib/src/models/agent_registration_request.rs create mode 100644 openframe-agent-lib/src/models/agent_registration_response.rs create mode 100644 openframe-agent-lib/src/models/agent_token_response.rs create mode 100644 openframe-agent-lib/src/models/download_configuration.rs create mode 100644 openframe-agent-lib/src/models/initial_configuration.rs create mode 100644 openframe-agent-lib/src/models/installed_agent_message.rs create mode 100644 openframe-agent-lib/src/models/installed_tool.rs create mode 100644 openframe-agent-lib/src/models/machine_heartbeat_message.rs create mode 100644 openframe-agent-lib/src/models/mod.rs create mode 100644 openframe-agent-lib/src/models/openframe_client_info.rs create mode 100644 openframe-agent-lib/src/models/openframe_client_update_message.rs create mode 100644 openframe-agent-lib/src/models/tool_agent_update_message.rs create mode 100644 openframe-agent-lib/src/models/tool_connection.rs create mode 100644 openframe-agent-lib/src/models/tool_connection_message.rs create mode 100644 openframe-agent-lib/src/models/tool_installation_message.rs create mode 100644 openframe-agent-lib/src/models/tool_installation_result.rs create mode 100644 openframe-agent-lib/src/models/update_state.rs create mode 100644 openframe-agent-lib/src/monitoring/mod.rs create mode 100644 openframe-agent-lib/src/monitoring/permissions.rs create mode 100644 openframe-agent-lib/src/platform/directories.rs create mode 100644 openframe-agent-lib/src/platform/file_lock.rs create mode 100644 openframe-agent-lib/src/platform/mod.rs create mode 100644 openframe-agent-lib/src/platform/permissions.rs create mode 100644 openframe-agent-lib/src/platform/powershell.rs create mode 100644 openframe-agent-lib/src/platform/uninstall.rs create mode 100644 openframe-agent-lib/src/platform/update_scripts/macos.rs create mode 100644 openframe-agent-lib/src/platform/update_scripts/mod.rs create mode 100644 openframe-agent-lib/src/platform/update_scripts/windows.rs create mode 100644 openframe-agent-lib/src/platform/updater_launcher/linux.rs create mode 100644 openframe-agent-lib/src/platform/updater_launcher/macos.rs create mode 100644 openframe-agent-lib/src/platform/updater_launcher/mod.rs create mode 100644 openframe-agent-lib/src/platform/updater_launcher/windows.rs create mode 100644 openframe-agent-lib/src/platform/windows_cleanup.rs create mode 100644 openframe-agent-lib/src/service.rs create mode 100644 openframe-agent-lib/src/service_adapter.rs create mode 100644 openframe-agent-lib/src/services/agent_auth_service.rs create mode 100644 openframe-agent-lib/src/services/agent_configuration_service.rs create mode 100644 openframe-agent-lib/src/services/agent_registration_service.rs create mode 100644 openframe-agent-lib/src/services/device_data_fetcher.rs create mode 100644 openframe-agent-lib/src/services/encryption_service.rs create mode 100644 openframe-agent-lib/src/services/github_download_service.rs create mode 100644 openframe-agent-lib/src/services/initial_authentication_processor.rs create mode 100644 openframe-agent-lib/src/services/initial_configuration_service.rs create mode 100644 openframe-agent-lib/src/services/installed_agent_message_publisher.rs create mode 100644 openframe-agent-lib/src/services/installed_tools_service.rs create mode 100644 openframe-agent-lib/src/services/local_tls_config_provider.rs create mode 100644 openframe-agent-lib/src/services/machine_heartbeat_publisher.rs create mode 100644 openframe-agent-lib/src/services/machine_heartbeat_run_manager.rs create mode 100644 openframe-agent-lib/src/services/mod.rs create mode 100644 openframe-agent-lib/src/services/nats_connection_manager.rs create mode 100644 openframe-agent-lib/src/services/nats_message_publisher.rs create mode 100644 openframe-agent-lib/src/services/openframe_client_info_service.rs create mode 100644 openframe-agent-lib/src/services/openframe_client_update_service.rs create mode 100644 openframe-agent-lib/src/services/registration_processor.rs create mode 100644 openframe-agent-lib/src/services/shared_token_service.rs create mode 100644 openframe-agent-lib/src/services/tool_agent_update_service.rs create mode 100644 openframe-agent-lib/src/services/tool_command_params_resolver.rs create mode 100644 openframe-agent-lib/src/services/tool_connection_message_publisher.rs create mode 100644 openframe-agent-lib/src/services/tool_connection_processing_manager.rs create mode 100644 openframe-agent-lib/src/services/tool_connection_service.rs create mode 100644 openframe-agent-lib/src/services/tool_installation_service.rs create mode 100644 openframe-agent-lib/src/services/tool_kill_service.rs create mode 100644 openframe-agent-lib/src/services/tool_run_manager.rs create mode 100644 openframe-agent-lib/src/services/tool_uninstall_service.rs create mode 100644 openframe-agent-lib/src/services/tool_url_params_resolver.rs create mode 100644 openframe-agent-lib/src/services/update_cleanup_service.rs create mode 100644 openframe-agent-lib/src/services/update_handler_service.rs create mode 100644 openframe-agent-lib/src/services/update_state_service.rs create mode 100644 openframe-agent-lib/src/system.rs create mode 100644 openframe-agent-lib/src/utils/version_comparator.rs diff --git a/openframe-agent-lib/.gitignore b/openframe-agent-lib/.gitignore new file mode 100644 index 000000000..9df243d2c --- /dev/null +++ b/openframe-agent-lib/.gitignore @@ -0,0 +1,18 @@ +# Build artifacts +/target/ + +# Cargo.lock is not needed for libraries +Cargo.lock + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# OS files +.DS_Store +Thumbs.db + +# Debug files +*.pdb diff --git a/openframe-agent-lib/Cargo.toml b/openframe-agent-lib/Cargo.toml new file mode 100644 index 000000000..d41b75676 --- /dev/null +++ b/openframe-agent-lib/Cargo.toml @@ -0,0 +1,63 @@ +[package] +name = "openframe-agent-lib" +version = "0.1.0" +edition = "2021" +authors = ["Flamingo Team"] +description = "OpenFrame Agent Library - Core functionality for system management and monitoring agents" +license = "MIT" +repository = "https://github.com/flamingo-stack/openframe-oss-lib" +documentation = "https://docs.openframe.org" +readme = "README.md" +keywords = ["system-management", "monitoring", "agent", "automation", "library"] +categories = ["system", "monitoring"] + +[lib] +name = "openframe_agent" +path = "src/lib.rs" + +[dependencies] +tokio = { version = "1.0", features = ["full", "macros", "rt-multi-thread", "sync"] } +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +tracing = { version = "0.1", features = ["attributes", "async-await"] } +tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] } +anyhow = "1.0" +thiserror = "1.0" +reqwest = { version = "0.11", features = ["json", "native-tls"] } +directories = "5.0" +uuid = { version = "1.7", features = ["v4", "serde"] } +config = { version = "0.14", features = ["toml"] } +toml = "0.8" +futures = "0.3" +async-trait = "0.1" +chrono = { version = "0.4", features = ["serde"] } +sysinfo = "0.30" +zip = "0.6" +tempfile = "3.10" +semver = "1.0" +sys-info = "0.9" +aes-gcm = "0.10" +base64 = "0.21" +log = "0.4.27" +whoami = "1.4" +tracing-appender = "0.2" +flate2 = "1.0" +tar = "0.4" +service-manager = "0.8" +plist = "1.7.1" +# System information collection dependencies +hostname = "0.3" +bytes = "1.10.1" +async-nats = { git = "https://github.com/flamingo-stack/nats.rs.git", branch = "feature/openframe-auth-callback", features = ["websockets"] } +rustls-pemfile = "1.0" +regex = "1.11.1" + +[target.'cfg(windows)'.dependencies] +winreg = "0.52" +winapi = { version = "0.3", features = ["winuser", "shellapi", "securitybaseapi", "errhandlingapi", "winerror"] } +is_elevated = "0.1" +windows-service = "0.6" +windows = { version = "0.52", features = ["Win32_Foundation", "Win32_System_Threading", "Win32_System_RemoteDesktop", "Win32_System_JobObjects", "Win32_UI_WindowsAndMessaging", "Win32_System_RestartManager"] } + +[target.'cfg(unix)'.dependencies] +libc = "0.2" diff --git a/openframe-agent-lib/src/api.rs b/openframe-agent-lib/src/api.rs new file mode 100644 index 000000000..68b8a5e50 --- /dev/null +++ b/openframe-agent-lib/src/api.rs @@ -0,0 +1,264 @@ +//! Public API for the OpenFrame Agent library. +//! +//! This module contains all public-facing functions, types, and the main entry point +//! for consumer projects to use the library. + +use anyhow::Result; +use tracing::{error, info, warn}; + +use crate::installation_initial_config_service::InstallConfigParams; +use crate::logging; +use crate::platform::permissions::{Capability, PermissionUtils}; +use crate::service::Service; +use crate::Client; + +// ============================================================================= +// Public helper functions +// ============================================================================= + +/// Check if the current process has admin/root privileges. +/// Returns `true` if running as admin (Windows) or root (Unix). +pub fn is_admin() -> bool { + PermissionUtils::is_admin() +} + +/// Ensure the process is running with admin/root privileges. +/// Exits the process with code 1 if not running as admin. +pub fn ensure_admin_privileges() { + #[cfg(unix)] + { + if unsafe { libc::geteuid() } != 0 { + eprintln!("Please run application with administrator/root privileges"); + std::process::exit(1); + } + } + + #[cfg(windows)] + { + if !PermissionUtils::is_admin() { + eprintln!("Please run application with administrator privileges"); + std::process::exit(1); + } + } +} + +/// Check for capabilities and log warnings if missing. +/// Useful for diagnostics when running in direct mode. +pub fn check_capabilities_and_warn() { + if !PermissionUtils::has_capability(Capability::ManageServices) { + warn!("Process doesn't have capability to manage services"); + } + + if !PermissionUtils::has_capability(Capability::WriteSystemDirectories) { + warn!("Process doesn't have capability to write to system directories"); + } + + if !PermissionUtils::has_capability(Capability::ReadSystemLogs) { + warn!("Process doesn't have capability to read system logs"); + } + + if !PermissionUtils::has_capability(Capability::WriteSystemLogs) { + warn!("Process doesn't have capability to write system logs"); + } +} + +/// Print permission diagnostics to stdout. +/// Returns exit code: 0 if admin, 1 if not admin. +pub fn print_permission_diagnostics() -> i32 { + let is_admin = PermissionUtils::is_admin(); + + println!("Admin privileges: {}", is_admin); + println!( + "Manage services capability: {}", + PermissionUtils::has_capability(Capability::ManageServices) + ); + println!( + "Write system directories capability: {}", + PermissionUtils::has_capability(Capability::WriteSystemDirectories) + ); + println!( + "Read system logs capability: {}", + PermissionUtils::has_capability(Capability::ReadSystemLogs) + ); + println!( + "Write system logs capability: {}", + PermissionUtils::has_capability(Capability::WriteSystemLogs) + ); + + if is_admin { + 0 + } else { + 1 + } +} + +// ============================================================================= +// Command enum and main entry point +// ============================================================================= + +/// Commands that can be executed by the agent +#[derive(Debug, Clone)] +pub enum AgentCommand { + /// Install the OpenFrame client as a system service + Install(InstallConfigParams), + /// Uninstall the OpenFrame client service + Uninstall, + /// Run the OpenFrame client directly (not as a service) + Run, + /// Run as a service (used by service manager) + RunAsService, + /// Check if the current process has the required permissions + CheckPermissions, +} + +/// Main entry point for the OpenFrame agent library. +/// +/// This function initializes logging, checks privileges, and executes the given command. +/// It mirrors the logic from the original main.rs but is callable from any consumer project. +/// +/// # Arguments +/// * `command` - The command to execute. If `None`, runs in legacy service mode. +/// +/// # Returns +/// * `Ok(())` on success +/// * `Err` with appropriate error message on failure +/// +/// # Example +/// ```ignore +/// use openframe_agent::{run, AgentCommand, InstallConfigParams}; +/// +/// // Run as service +/// run(None)?; +/// +/// // Install with parameters +/// let params = InstallConfigParams { +/// server_url: Some("example.com".to_string()), +/// initial_key: Some("key123".to_string()), +/// org_id: Some("org1".to_string()), +/// local_mode: false, +/// }; +/// run(Some(AgentCommand::Install(params)))?; +/// ``` +pub fn run(command: Option) -> Result<()> { + use std::process; + use tokio::runtime::Runtime; + + // Ensure the process is running with sufficient privileges (root/administrator) + ensure_admin_privileges(); + + // Initialize logging first + if let Err(e) = logging::init(None, None) { + eprintln!("Failed to initialize logging: {}", e); + process::exit(1); + } + + // Add explicit startup log entry to verify logging is working + info!("OpenFrame agent starting up"); + + // Check if running with admin privileges + let is_admin = PermissionUtils::is_admin(); + info!("Running with admin privileges: {}", is_admin); + + let rt = Runtime::new()?; + + match command { + Some(AgentCommand::Install(params)) => { + info!("Running install command"); + // Check for admin privileges - this is required for installation + if !is_admin { + error!("Admin/root privileges are required for service installation"); + eprintln!("Please run the installation with administrator/root privileges"); + process::exit(1); + } + + rt.block_on(async { + match Service::install(params).await { + Ok(_) => { + info!("OpenFrame client service installed successfully"); + process::exit(0); + } + Err(e) => { + error!("Failed to install OpenFrame client service: {:#}", e); + process::exit(1); + } + } + }); + } + Some(AgentCommand::Uninstall) => { + info!("Running uninstall command"); + // Check for admin privileges - this is required for uninstallation + if !is_admin { + error!("Admin/root privileges are required for service uninstallation"); + eprintln!("Please run the uninstallation with administrator/root privileges"); + process::exit(1); + } + + rt.block_on(async { + match Service::uninstall().await { + Ok(_) => { + info!("OpenFrame client service uninstalled successfully"); + process::exit(0); + } + Err(e) => { + error!("Failed to uninstall OpenFrame client service: {:#}", e); + process::exit(1); + } + } + }); + } + Some(AgentCommand::Run) => { + info!("Running in direct mode (without service wrapper)"); + + // For direct mode, check capabilities but don't require admin + // Just warn if we don't have certain capabilities + check_capabilities_and_warn(); + + // Run directly without service wrapper + match Client::new() { + Ok(client) => { + info!("Starting OpenFrame client in direct mode"); + if let Err(e) = rt.block_on(client.start()) { + error!("Client failed: {:#}", e); + process::exit(1); + } + } + Err(e) => { + error!("Failed to initialize client: {:#}", e); + process::exit(1); + } + } + } + Some(AgentCommand::RunAsService) => { + info!("Running as service (called by service manager)"); + // When running as a service, we should already have the necessary permissions + // But we'll still check and log any issues + check_capabilities_and_warn(); + + // This command is used when started by the service manager + // Note: run_as_service is now synchronous and handles its own runtime + if let Err(e) = Service::run_as_service() { + error!("Service failed: {:#}", e); + process::exit(1); + } + } + Some(AgentCommand::CheckPermissions) => { + // This command is used to check if we have the necessary permissions + // Useful for diagnostics and troubleshooting + let exit_code = print_permission_diagnostics(); + process::exit(exit_code); + } + None => { + info!("No command specified, running as service (legacy mode)"); + // Run as service by default for backward compatibility + if let Err(e) = rt.block_on(Service::run()) { + error!("Service failed: {:#}", e); + process::exit(1); + } + } + } + + // Add explicit shutdown log entry to verify logging is still working + info!("OpenFrame agent shutting down"); + + Ok(()) +} diff --git a/openframe-agent-lib/src/clients/auth_client.rs b/openframe-agent-lib/src/clients/auth_client.rs new file mode 100644 index 000000000..b72515f4d --- /dev/null +++ b/openframe-agent-lib/src/clients/auth_client.rs @@ -0,0 +1,93 @@ +use anyhow::{Context, Result}; +use reqwest::{Client, header::{HeaderMap, HeaderValue}}; +use std::collections::HashMap; + +use crate::models::AgentTokenResponse; + +#[derive(Clone)] +pub struct AuthClient { + http_client: Client, + base_url: String, +} + +impl AuthClient { + pub fn new(base_url: String, http_client: Client) -> Self { + Self { + http_client, + base_url, + } + } + + + pub async fn authenticate_with_secret( + &self, + client_id: String, + client_secret: String, + ) -> Result { + let url = format!("{}/clients/oauth/token", self.base_url); + + let mut headers = HeaderMap::new(); + headers.insert("Content-Type", HeaderValue::from_static("application/x-www-form-urlencoded")); + + let mut form_data = HashMap::new(); + form_data.insert("grant_type", "client_credentials".to_string()); + form_data.insert("client_id", client_id); + form_data.insert("client_secret", client_secret); + + let response = self.http_client + .post(&url) + .headers(headers) + .form(&form_data) + .send() + .await + .context("Failed to send token request")?; + + let status = response.status(); + + if !status.is_success() { + return Err(anyhow::anyhow!("Failed to obtain access token: with status {} and body {}", status, response.text().await?)); + } + + let token_response: AgentTokenResponse = response + .json() + .await + .context("Failed to parse token response")?; + + Ok(token_response) + } + + pub async fn authenticate_with_refresh_token( + &self, + refresh_token: String, + ) -> Result { + let url = format!("{}/clients/oauth/token", self.base_url); + + let mut headers = HeaderMap::new(); + headers.insert("Content-Type", HeaderValue::from_static("application/x-www-form-urlencoded")); + + let mut form_data = HashMap::new(); + form_data.insert("grant_type", "refresh_token".to_string()); + form_data.insert("refresh_token", refresh_token); + + let response = self.http_client + .post(&url) + .headers(headers) + .form(&form_data) + .send() + .await + .context("Failed to send refresh token request")?; + + let status = response.status(); + + if !status.is_success() { + return Err(anyhow::anyhow!("Failed to refresh access token: HTTP {}", status)); + } + + let token_response: AgentTokenResponse = response + .json() + .await + .context("Failed to parse refresh token response")?; + + Ok(token_response) + } +} \ No newline at end of file diff --git a/openframe-agent-lib/src/clients/mod.rs b/openframe-agent-lib/src/clients/mod.rs new file mode 100644 index 000000000..4730b1ded --- /dev/null +++ b/openframe-agent-lib/src/clients/mod.rs @@ -0,0 +1,9 @@ +pub mod auth_client; +pub mod registration_client; +pub mod tool_agent_file_client; +pub mod tool_api_client; + +pub use auth_client::AuthClient; +pub use registration_client::RegistrationClient; +pub use tool_agent_file_client::ToolAgentFileClient; +pub use tool_api_client::ToolApiClient; \ No newline at end of file diff --git a/openframe-agent-lib/src/clients/registration_client.rs b/openframe-agent-lib/src/clients/registration_client.rs new file mode 100644 index 000000000..28076dff9 --- /dev/null +++ b/openframe-agent-lib/src/clients/registration_client.rs @@ -0,0 +1,54 @@ +use anyhow::{Context, Result}; +use reqwest::{Client, header::{HeaderMap, HeaderValue}}; + +use crate::models::{AgentRegistrationRequest, AgentRegistrationResponse}; + +#[derive(Clone)] +pub struct RegistrationClient { + http_client: Client, + base_url: String, +} + +impl RegistrationClient { + pub fn new(base_url: String, http_client: Client) -> Result { + Ok(Self { http_client, base_url }) + } + + pub async fn register( + &self, + initial_key: &str, + request: AgentRegistrationRequest, + ) -> Result { + let url = format!("{}/clients/api/agents/register", self.base_url); + + let mut headers = HeaderMap::new(); + headers.insert("X-Initial-Key", initial_key.parse() + .context("Failed to parse initial key header")?); + headers.insert("Content-Type", HeaderValue::from_static("application/json")); + + let response = self.http_client + .post(&url) + .headers(headers) + .json(&request) + .send() + .await + .context("Failed to send registration request")?; + + let status = response.status(); + + if !status.is_success() { + return Err(anyhow::anyhow!("Failed to register agent with status {} and body {}", status, response.text().await?)); + } + + let registration_response: AgentRegistrationResponse = response + .json() + .await + .context("Failed to parse registration response")?; + + Ok(registration_response) + } + + +} + + \ No newline at end of file diff --git a/openframe-agent-lib/src/clients/tool_agent_file_client.rs b/openframe-agent-lib/src/clients/tool_agent_file_client.rs new file mode 100644 index 000000000..995f38e44 --- /dev/null +++ b/openframe-agent-lib/src/clients/tool_agent_file_client.rs @@ -0,0 +1,36 @@ +use reqwest::Client; +use anyhow::{Context, Result}; + +#[derive(Clone)] +pub struct ToolAgentFileClient { + http_client: Client, + base_url: String, +} + +impl ToolAgentFileClient { + pub fn new(http_client: Client, base_url: String) -> Self { + Self { http_client, base_url } + } + + pub async fn get_tool_agent_file(&self, asset_id: String) -> Result { + let os_param = if cfg!(target_os = "windows") { "windows" } else { "mac" }; + let url = format!( + "{}/clients/tool-agent/{}?os={}", + self.base_url, asset_id, os_param + ); + let response = self.http_client.get(url).send() + .await + .context("Failed to get tool agent file")?; + + let status = response.status(); + + if !response.status().is_success() { + let error_text = response.text().await.context("Failed to read response text")?; + return Err(anyhow::anyhow!("Failed to get tool agent file with status {} and body {}", status, error_text)); + } + + let body = response.bytes().await?; + Ok(body) + } + +} \ No newline at end of file diff --git a/openframe-agent-lib/src/clients/tool_api_client.rs b/openframe-agent-lib/src/clients/tool_api_client.rs new file mode 100644 index 000000000..abe16cab1 --- /dev/null +++ b/openframe-agent-lib/src/clients/tool_api_client.rs @@ -0,0 +1,47 @@ +use reqwest::Client; +use anyhow::{Context, Result}; + +use crate::services::agent_configuration_service::AgentConfigurationService; + +#[derive(Clone)] +pub struct ToolApiClient { + http_client: Client, + base_url: String, + config_service: AgentConfigurationService, +} + +impl ToolApiClient { + pub fn new(http_client: Client, base_url: String, config_service: AgentConfigurationService) -> Self { + Self { + http_client, + base_url, + config_service, + } + } + + pub async fn get_tool_asset(&self, tool_id: String, asset_path: String) -> Result { + let url = format!("{}/tools/agent/{}{}", self.base_url, tool_id, asset_path); + + // Get access token from configuration service + let access_token = self.config_service.get_access_token() + .await + .context("Failed to get access token from configuration service")?; + + let response = self.http_client + .get(url) + .header("Authorization", format!("Bearer {}", access_token)) + .send() + .await + .context("Failed to get tool asset from tool API")?; + + let status = response.status(); + + if !response.status().is_success() { + let error_text = response.text().await.context("Failed to read response text")?; + return Err(anyhow::anyhow!("Failed to get tool asset with status {} and body {}", status, error_text)); + } + + let body = response.bytes().await?; + Ok(body) + } +} diff --git a/openframe-agent-lib/src/config/mod.rs b/openframe-agent-lib/src/config/mod.rs new file mode 100644 index 000000000..704709449 --- /dev/null +++ b/openframe-agent-lib/src/config/mod.rs @@ -0,0 +1,32 @@ +/// Configuration module for OpenFrame client +/// Contains constants and settings for various subsystems +use serde::{Deserialize, Serialize}; + +pub mod update_config; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[allow(dead_code)] +pub struct Configuration { + pub logging: LoggingConfig, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LoggingConfig { + pub level: String, + pub json: bool, + pub rotation_size_mb: u64, + pub max_files: u32, +} + +impl Default for Configuration { + fn default() -> Self { + Self { + logging: LoggingConfig { + level: "info".to_string(), + json: true, + rotation_size_mb: 10, + max_files: 5, + }, + } + } +} diff --git a/openframe-agent-lib/src/config/update_config.rs b/openframe-agent-lib/src/config/update_config.rs new file mode 100644 index 000000000..0df2beaab --- /dev/null +++ b/openframe-agent-lib/src/config/update_config.rs @@ -0,0 +1,16 @@ +// Download settings +pub const MAX_DOWNLOAD_RETRIES: u32 = 3; +pub const DOWNLOAD_TIMEOUT_SECS: u64 = 300; // 5 minutes +pub const MIN_BINARY_SIZE_BYTES: u64 = 1024 * 100; // 100 KB + +// Consumer retry +pub const MAX_CONSUMER_CREATE_RETRIES: u32 = 5; +pub const INITIAL_RETRY_DELAY_MS: u64 = 1000; // 1 second +pub const MAX_RETRY_DELAY_MS: u64 = 30000; // 30 seconds + +// Reconnection +pub const RECONNECTION_DELAY_MS: u64 = 5000; // 5 seconds + +// NATS message settings +pub const CONSUMER_ACK_WAIT_SECS: u64 = 120; +pub const CONSUMER_MAX_DELIVER: i64 = 10; // Maximum delivery attempts diff --git a/openframe-agent-lib/src/installation_initial_config_service.rs b/openframe-agent-lib/src/installation_initial_config_service.rs new file mode 100644 index 000000000..fcb4cef56 --- /dev/null +++ b/openframe-agent-lib/src/installation_initial_config_service.rs @@ -0,0 +1,98 @@ +use anyhow::{anyhow, Context, Result}; +use std::path::PathBuf; +use std::process::Command; +use tracing::info; + +use crate::models::InitialConfiguration; +use crate::platform::DirectoryManager; +use crate::services::InitialConfigurationService; + +#[derive(Clone)] +pub struct InstallationInitialConfigService { + initial_service: InitialConfigurationService, +} + +#[derive(Debug, Clone)] +pub struct InstallConfigParams { + pub server_url: Option, + pub initial_key: Option, + pub org_id: Option, + pub local_mode: bool, +} + +impl InstallationInitialConfigService { + pub fn new(directory_manager: DirectoryManager) -> Result { + let initial_service = InitialConfigurationService::new(directory_manager) + .context("Failed to initialize initial configuration service")?; + Ok(Self { initial_service }) + } + + /// Build and persist InitialConfiguration based on provided install parameters. + pub fn build_and_save(&self, params: InstallConfigParams) -> Result<()> { + // Validate required params + let server_url = params + .server_url + .filter(|s| !s.trim().is_empty()) + .ok_or_else(|| anyhow!("serverUrl is required"))?; + let initial_key = params + .initial_key + .filter(|s| !s.trim().is_empty()) + .ok_or_else(|| anyhow!("initialKey is required"))?; + let org_id = params + .org_id + .filter(|s| !s.trim().is_empty()) + .ok_or_else(|| anyhow!("orgId is required"))?; + + let mut cfg = InitialConfiguration { + server_host: server_url, + initial_key, + org_id, + local_mode: params.local_mode, + ..Default::default() + }; + + // Only resolve local CA path via mkcert if running in local mode + if params.local_mode { + info!("Resolving mkcert CAROOT during install (local mode enabled)..."); + let output = Command::new("mkcert") + .arg("-CAROOT") + .output() + .context("Failed to execute 'mkcert -CAROOT' during install")?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(anyhow!( + "'mkcert -CAROOT' failed with status {}: {}", + output.status, stderr + )); + } + + let root = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if root.is_empty() { + return Err(anyhow!("'mkcert -CAROOT' returned empty output")); + } + + let ca = PathBuf::from(&root).join("rootCA.pem"); + if !ca.exists() { + return Err(anyhow!( + "rootCA.pem not found at {} (from mkcert -CAROOT)", + ca.to_string_lossy() + )); + } + + cfg.local_ca_cert_path = ca.to_string_lossy().to_string(); + info!("Resolved local CA cert path: {}", cfg.local_ca_cert_path); + } else { + info!("Skipping mkcert CAROOT resolution (local mode disabled)"); + } + + // Save the initial configuration + self.initial_service + .save(&cfg) + .context("Failed to save initial configuration")?; + + Ok(()) + } +} + + diff --git a/openframe-agent-lib/src/lib.rs b/openframe-agent-lib/src/lib.rs new file mode 100644 index 000000000..98c913744 --- /dev/null +++ b/openframe-agent-lib/src/lib.rs @@ -0,0 +1,462 @@ +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; +use std::sync::Arc; +use tokio::sync::RwLock; +use tokio::time::Duration; +use tracing::{error, info}; + +pub mod clients; +mod config; +pub mod listener; +mod metrics; +pub mod models; +pub mod platform; +pub mod services; + +pub mod installation_initial_config_service; +pub mod logging; +pub mod monitoring; +pub mod service; +/// Cross-platform service manager adapters +/// +/// This module provides a unified interface to manage services across different +/// operating systems (Windows, macOS, Linux) using the `service-manager` crate. +/// It implements the adapter pattern to abstract platform-specific service +/// management details behind a common API. +pub mod service_adapter; +pub mod system; + +use crate::clients::tool_agent_file_client::ToolAgentFileClient; +use crate::clients::{AuthClient, RegistrationClient, ToolApiClient}; +use crate::listener::openframe_client_update_listener::OpenFrameClientUpdateListener; +use crate::listener::tool_agent_update_listener::ToolAgentUpdateListener; +use crate::listener::tool_installation_message_listener::ToolInstallationMessageListener; +use crate::platform::DirectoryManager; +use crate::services::agent_configuration_service::AgentConfigurationService; +use crate::services::device_data_fetcher::DeviceDataFetcher; +use crate::services::encryption_service::EncryptionService; +use crate::services::github_download_service::GithubDownloadService; +use crate::services::initial_authentication_processor::InitialAuthenticationProcessor; +use crate::services::installed_agent_message_publisher::InstalledAgentMessagePublisher; +use crate::services::local_tls_config_provider::LocalTlsConfigProvider; +use crate::services::machine_heartbeat_publisher::MachineHeartbeatPublisher; +use crate::services::machine_heartbeat_run_manager::MachineHeartbeatRunManager; +use crate::services::nats_connection_manager::NatsConnectionManager; +use crate::services::nats_message_publisher::NatsMessagePublisher; +use crate::services::openframe_client_info_service::OpenFrameClientInfoService; +use crate::services::openframe_client_update_service::OpenFrameClientUpdateService; +use crate::services::registration_processor::RegistrationProcessor; +use crate::services::shared_token_service::SharedTokenService; +use crate::services::tool_agent_update_service::ToolAgentUpdateService; +use crate::services::tool_connection_message_publisher::ToolConnectionMessagePublisher; +use crate::services::tool_connection_service::ToolConnectionService; +use crate::services::tool_installation_service::ToolInstallationService; +use crate::services::InstalledToolsService; +use crate::services::{ + AgentAuthService, AgentRegistrationService, InitialConfigurationService, + ToolCommandParamsResolver, ToolConnectionProcessingManager, ToolKillService, ToolRunManager, + ToolUrlParamsResolver, +}; +use crate::services::{UpdateCleanupService, UpdateHandlerService, UpdateStateService}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ServerConfig { + pub url: String, + pub check_interval: u64, + pub update_url: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ClientConfig { + pub id: String, + pub log_level: String, + pub update_channel: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MetricsConfig { + pub enabled: bool, + pub collection_interval: u64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SecurityConfig { + pub tls_verify: bool, + pub certificate_path: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ClientConfiguration { + pub server: ServerConfig, + pub client: ClientConfig, + pub metrics: MetricsConfig, + pub security: SecurityConfig, +} + +impl Default for ClientConfiguration { + fn default() -> Self { + Self { + server: ServerConfig { + url: "https://api.openframe.org".to_string(), + check_interval: 3600, + update_url: None, + }, + client: ClientConfig { + id: uuid::Uuid::new_v4().to_string(), + log_level: "info".to_string(), + update_channel: "stable".to_string(), + }, + metrics: MetricsConfig { + enabled: true, + collection_interval: 60, + }, + security: SecurityConfig { + tls_verify: true, + certificate_path: String::new(), + }, + } + } +} + +pub struct Client { + config: Arc>, + directory_manager: DirectoryManager, + registration_processor: RegistrationProcessor, + auth_processor: InitialAuthenticationProcessor, + nats_connection_manager: NatsConnectionManager, + tool_installation_message_listener: ToolInstallationMessageListener, + openframe_client_update_listener: OpenFrameClientUpdateListener, + tool_agent_update_listener: ToolAgentUpdateListener, + tool_run_manager: ToolRunManager, + tool_connection_processing_manager: ToolConnectionProcessingManager, + machine_heartbeat_run_manager: MachineHeartbeatRunManager, + update_handler_service: UpdateHandlerService, +} + +impl Client { + pub fn new() -> Result { + let config = Arc::new(RwLock::new(ClientConfiguration::default())); + + // Check if in development mode + let directory_manager = if std::env::var("OPENFRAME_DEV_MODE").is_ok() { + info!("Client running in development mode, using user directories"); + DirectoryManager::for_development() + } else { + DirectoryManager::new() + }; + + // Perform initial health check + directory_manager.perform_health_check()?; + + // Initialize initial configuration service + let initial_configuration_service = + InitialConfigurationService::new(directory_manager.clone()) + .context("Failed to initialize initial configuration service")?; + + // Initialize configuration service + let config_service = AgentConfigurationService::new(directory_manager.clone()) + .context("Failed to initialize device configuration service")?; + + // Initialize HTTP client + let http_client = reqwest::Client::builder() + .timeout(Duration::from_secs(120)) + // disable TLS verification for dev mode only + .danger_accept_invalid_certs(initial_configuration_service.is_local_mode()?) + .no_proxy() + .build() + .context("Failed to create HTTP client")?; + + // Initialize http url + let http_url = format!( + "https://{}", + initial_configuration_service.get_server_url()? + ); + + // Initialize registration client + let registration_client = RegistrationClient::new(http_url.clone(), http_client.clone()) + .context("Failed to create registration client")?; + + // Initialize device data fetcher + let device_data_fetcher = DeviceDataFetcher::new(); + + // Initialize registration service + let registration_service = AgentRegistrationService::new( + registration_client, + device_data_fetcher, + config_service.clone(), + initial_configuration_service.clone(), + ); + + // Initialize registration processor + let registration_processor = + RegistrationProcessor::new(registration_service, config_service.clone()); + + // Initialize authentication client + let auth_client = AuthClient::new(http_url.clone(), http_client.clone()); + + // Initialize encryption service + let encryption_service = EncryptionService::new(); + + // Initialize shared token service + let shared_token_service = + SharedTokenService::new(directory_manager.clone(), encryption_service.clone()); + + // Initialize authentication service + let auth_service = AgentAuthService::new( + auth_client, + config_service.clone(), + shared_token_service.clone(), + ); + + // Initialize authentication processor + let auth_processor = + InitialAuthenticationProcessor::new(auth_service.clone(), config_service.clone()); + + // Initialize NATS connection manager + let ws_url = format!("wss://{}", initial_configuration_service.get_server_url()?); + let tls_config_provider = + LocalTlsConfigProvider::new(initial_configuration_service.clone()); + let nats_connection_manager = NatsConnectionManager::new( + ws_url, + config_service.clone(), + initial_configuration_service.clone(), + auth_service.clone(), + tls_config_provider, + ); + + // Initialize tool agent file client + let tool_agent_file_client = + ToolAgentFileClient::new(http_client.clone(), http_url.clone()); + + // Initialize tool API client + let tool_api_client = ToolApiClient::new( + http_client.clone(), + http_url.clone(), + config_service.clone(), + ); + + // Initialize installed tools service + let installed_tools_service = InstalledToolsService::new(directory_manager.clone()) + .context("Failed to initialize installed tools service")?; + + // Initialize NATS message publisher + let nats_message_publisher = NatsMessagePublisher::new(nats_connection_manager.clone()); + + // Initialize installed agent message publisher + let installed_agent_message_publisher = + InstalledAgentMessagePublisher::new(nats_message_publisher.clone()); + + // Initialize tool connection message publisher + let tool_connection_message_publisher = + ToolConnectionMessagePublisher::new(nats_message_publisher.clone()); + + // Initialize tool command params resolver + let tool_command_params_resolver = ToolCommandParamsResolver::new( + directory_manager.clone(), + initial_configuration_service.clone(), + ); + + // Initialize tool URL params resolver + let tool_url_params_resolver = + ToolUrlParamsResolver::new(initial_configuration_service.clone()); + + // Initialize tool kill service + let tool_kill_service = ToolKillService::new(); + + // Initialize tool run manager + let tool_run_manager = ToolRunManager::new( + installed_tools_service.clone(), + tool_command_params_resolver.clone(), + tool_kill_service.clone(), + ); + + // Initialize tool connection service + let tool_connection_service = ToolConnectionService::new(directory_manager.clone()) + .context("Failed to initialize tool connection service")?; + + // Initialize tool connection processing manager + let tool_connection_processing_manager = ToolConnectionProcessingManager::new( + installed_tools_service.clone(), + tool_command_params_resolver.clone(), + tool_connection_message_publisher.clone(), + config_service.clone(), + tool_connection_service.clone(), + ); + + // Initialize OpenFrame client info service + let openframe_client_info_service = + OpenFrameClientInfoService::new(directory_manager.clone()) + .context("Failed to initialize OpenFrame client info service")?; + + // Initialize GitHub download service (used by update and installation services) + let github_download_service = GithubDownloadService::new(http_client.clone()); + + // Initialize update state and cleanup services (needed by update service) + let update_state_service = UpdateStateService::new(directory_manager.clone()) + .context("Failed to initialize update state service")?; + let update_cleanup_service = + UpdateCleanupService::new().context("Failed to initialize update cleanup service")?; + + // Initialize tool installation service + let tool_installation_service = ToolInstallationService::new( + github_download_service.clone(), + tool_agent_file_client.clone(), + tool_api_client, + tool_command_params_resolver.clone(), + tool_url_params_resolver.clone(), + installed_tools_service.clone(), + directory_manager.clone(), + tool_run_manager.clone(), + tool_connection_processing_manager.clone(), + config_service.clone(), + installed_agent_message_publisher.clone(), + tool_connection_service.clone(), + ); + + // Initialize OpenFrame client update service + let openframe_client_update_service = OpenFrameClientUpdateService::new( + openframe_client_info_service.clone(), + github_download_service.clone(), + update_state_service.clone(), + ); + + // Initialize tool agent update service + let tool_agent_update_service = ToolAgentUpdateService::new( + github_download_service.clone(), + tool_agent_file_client.clone(), + installed_tools_service.clone(), + tool_kill_service.clone(), + tool_run_manager.clone(), + directory_manager.clone(), + config_service.clone(), + installed_agent_message_publisher.clone(), + ); + + // Initialize tool installation message listener + let tool_installation_message_listener = ToolInstallationMessageListener::new( + nats_connection_manager.clone(), + tool_installation_service, + config_service.clone(), + ); + + // Initialize OpenFrame client update listener + let openframe_client_update_listener = OpenFrameClientUpdateListener::new( + nats_connection_manager.clone(), + openframe_client_update_service, + config_service.clone(), + ); + + // Initialize tool agent update listener + let tool_agent_update_listener = ToolAgentUpdateListener::new( + nats_connection_manager.clone(), + tool_agent_update_service, + config_service.clone(), + ); + + // Initialize machine heartbeat publisher and run manager + let machine_heartbeat_publisher = + MachineHeartbeatPublisher::new(nats_message_publisher.clone(), config_service.clone()); + let machine_heartbeat_run_manager = + MachineHeartbeatRunManager::new(machine_heartbeat_publisher); + + // Initialize update handler service + let update_handler_service = UpdateHandlerService::new( + update_state_service.clone(), + openframe_client_info_service.clone(), + update_cleanup_service.clone(), + installed_agent_message_publisher.clone(), + config_service.clone(), + ); + + Ok(Self { + config, + directory_manager, + registration_processor, + auth_processor, + nats_connection_manager, + tool_installation_message_listener, + openframe_client_update_listener, + tool_agent_update_listener, + tool_run_manager, + tool_connection_processing_manager, + machine_heartbeat_run_manager, + update_handler_service, + }) + } + + pub async fn start(&self) -> Result<()> { + info!("Starting OpenFrame Client"); + + // Process initial registration and authentication + // if it haven't been done yet + // Processors retry it till success + self.registration_processor.process().await?; + self.auth_processor.process().await?; + + // Connect to NATS + self.nats_connection_manager.connect().await?; + + // Handle any pending update from previous run (after NATS is connected) + if let Err(e) = self.update_handler_service.handle_pending_update().await { + error!("Failed to handle pending update: {:#}", e); + // Continue startup even if update handling fails - don't block the client + } + + // Start machine heartbeat run manager + self.machine_heartbeat_run_manager.start(); + + //Start tool installation message listener in background + self.tool_installation_message_listener.start().await?; + + // Start OpenFrame client update listener in background + self.openframe_client_update_listener.start().await?; + + // Start tool agent update listener in background + self.tool_agent_update_listener.start().await?; + + // Start tool run manager + self.tool_run_manager.run().await?; + + // Start tool connection processing manager + self.tool_connection_processing_manager.run().await?; + + // Initialize logging + let config_guard = self.config.read().await; + info!( + "Initializing logging with level: {}", + config_guard.client.log_level + ); + drop(config_guard); // Release the lock + + // Initialize metrics collection + metrics::init()?; + + // Start periodic health checks + self.directory_manager.perform_health_check()?; + + // Keep the client running + loop { + tokio::time::sleep(tokio::time::Duration::from_secs(1)).await; + } + + #[allow(unreachable_code)] + Ok(()) + } +} + +// ============================================================================= +// Public API module and re-exports +// ============================================================================= + +mod api; + +// Re-export public API at crate root for convenience +pub use api::{ + check_capabilities_and_warn, ensure_admin_privileges, is_admin, print_permission_diagnostics, + run, AgentCommand, +}; +pub use installation_initial_config_service::{ + InstallConfigParams, InstallationInitialConfigService, +}; +pub use platform::permissions::{Capability, PermissionUtils}; +pub use service::Service; diff --git a/openframe-agent-lib/src/listener/mod.rs b/openframe-agent-lib/src/listener/mod.rs new file mode 100644 index 000000000..3bfdd98a7 --- /dev/null +++ b/openframe-agent-lib/src/listener/mod.rs @@ -0,0 +1,7 @@ +pub mod tool_installation_message_listener; +pub mod openframe_client_update_listener; +pub mod tool_agent_update_listener; + +pub use tool_installation_message_listener::ToolInstallationMessageListener; +pub use openframe_client_update_listener::OpenFrameClientUpdateListener; +pub use tool_agent_update_listener::ToolAgentUpdateListener; \ No newline at end of file diff --git a/openframe-agent-lib/src/listener/openframe_client_update_listener.rs b/openframe-agent-lib/src/listener/openframe_client_update_listener.rs new file mode 100644 index 000000000..203ff63b9 --- /dev/null +++ b/openframe-agent-lib/src/listener/openframe_client_update_listener.rs @@ -0,0 +1,203 @@ +use crate::services::nats_connection_manager::NatsConnectionManager; +use crate::services::openframe_client_update_service::OpenFrameClientUpdateService; +use crate::config::update_config::{ + MAX_CONSUMER_CREATE_RETRIES, + INITIAL_RETRY_DELAY_MS, + MAX_RETRY_DELAY_MS, + RECONNECTION_DELAY_MS, + CONSUMER_ACK_WAIT_SECS, + CONSUMER_MAX_DELIVER, +}; +use async_nats::jetstream::consumer::PushConsumer; +use async_nats::jetstream::consumer::push; +use async_nats::jetstream::consumer::DeliverPolicy; +use tokio::time::Duration; +use anyhow::{Result, Context}; +use async_nats::jetstream; +use futures::StreamExt; +use tracing::{error, info, warn}; +use crate::services::AgentConfigurationService; +use crate::models::openframe_client_update_message::OpenFrameClientUpdateMessage; + +#[derive(Clone)] +pub struct OpenFrameClientUpdateListener { + pub nats_connection_manager: NatsConnectionManager, + pub openframe_client_update_service: OpenFrameClientUpdateService, + pub config_service: AgentConfigurationService, +} + +impl OpenFrameClientUpdateListener { + + const STREAM_NAME: &'static str = "CLIENT_UPDATE"; + + pub fn new( + nats_connection_manager: NatsConnectionManager, + openframe_client_update_service: OpenFrameClientUpdateService, + config_service: AgentConfigurationService + ) -> Self { + Self { + nats_connection_manager, + openframe_client_update_service, + config_service + } + } + + /// Start listening for messages in a background task + pub async fn start(&self) -> Result> { + let listener = self.clone(); + let handle = tokio::spawn(async move { + // Reconnection loop - keeps trying to reconnect on failure + loop { + info!("Starting OpenFrame client update listener..."); + match listener.listen().await { + Ok(_) => { + warn!("OpenFrame client update listener exited normally (unexpected)"); + } + Err(e) => { + error!("OpenFrame client update listener error: {:#}", e); + } + } + + // Wait before reconnecting + info!( + "Reconnecting OpenFrame client update listener in {} seconds...", + RECONNECTION_DELAY_MS / 1000 + ); + tokio::time::sleep(Duration::from_millis(RECONNECTION_DELAY_MS)).await; + } + }); + Ok(handle) + } + + async fn listen(&self) -> Result<()> { + info!("Run OpenFrame client update message listener"); + let client = self.nats_connection_manager + .get_client() + .await?; + let js = jetstream::new((*client).clone()); + + let machine_id = self.config_service.get_machine_id().await?; + + let consumer = self.create_consumer(&js, &machine_id).await?; + + info!("Start listening for OpenFrame client update messages"); + let mut messages = consumer.messages().await?; + while let Some(message) = messages.next().await { + info!("Received OpenFrame client update message: {:?}", message); + + let message = message?; + + let payload = String::from_utf8_lossy(&message.payload); + let client_update_message: OpenFrameClientUpdateMessage = serde_json::from_str(&payload)?; + let version = client_update_message.version.clone(); + + match self.openframe_client_update_service.process_update(client_update_message).await { + Ok(_) => { + // ack + info!("Acknowledging client update message for version: {}", version); + message.ack().await + .map_err(|e| anyhow::anyhow!("Failed to ack message: {}", e))?; + info!("Client update message acknowledged for version: {}", version); + } + Err(e) => { + // do not ack: let message be redelivered per consumer ack policy + error!("Failed to process client update message for version {}: {:#}", version, e); + info!("Leaving message unacked for potential redelivery: version {}", version); + } + } + } + Ok(()) + } + + async fn create_consumer(&self, js: &jetstream::Context, machine_id: &str) -> Result { + let consumer_configuration = Self::build_consumer_configuration(machine_id); + + // Retry loop with exponential backoff + let mut retry_count = 0; + let mut delay_ms = INITIAL_RETRY_DELAY_MS; + + loop { + info!( + "Creating consumer for stream {} (attempt {}/{})", + Self::STREAM_NAME, + retry_count + 1, + MAX_CONSUMER_CREATE_RETRIES + ); + + // Try to create consumer directly on stream - if it exists, it will return the existing one + match js.create_consumer_on_stream(consumer_configuration.clone(), Self::STREAM_NAME).await { + Ok(consumer) => { + info!("Consumer ready for stream: {}", Self::STREAM_NAME); + return Ok(consumer); + } + Err(e) => { + // Check if this is a "consumer already exists" type error - we can try to get existing consumer + let error_msg = format!("{:?}", e); + if error_msg.contains("consumer name already in use") || error_msg.contains("10013") { + warn!("Consumer already exists, attempting to get existing consumer"); + // Try to get the existing consumer + let durable_name = Self::build_durable_name(machine_id); + if let Ok(existing_consumer) = js.get_consumer_from_stream(Self::STREAM_NAME, &durable_name).await { + info!("Retrieved existing consumer for stream: {}", Self::STREAM_NAME); + return Ok(existing_consumer); + } + } + + retry_count += 1; + + if retry_count >= MAX_CONSUMER_CREATE_RETRIES { + error!( + "Failed to create consumer after {} attempts: {:#}", + MAX_CONSUMER_CREATE_RETRIES, e + ); + return Err(e).context(format!( + "Failed to create consumer after {} retries", + MAX_CONSUMER_CREATE_RETRIES + )); + } + + warn!( + "Failed to create consumer (attempt {}/{}): {:#}. Retrying in {} ms...", + retry_count, MAX_CONSUMER_CREATE_RETRIES, e, delay_ms + ); + + tokio::time::sleep(Duration::from_millis(delay_ms)).await; + delay_ms = (delay_ms * 2).min(MAX_RETRY_DELAY_MS); + } + } + } + } + + fn build_consumer_configuration(machine_id: &str) -> push::Config { + let filter_subject = Self::build_filter_subject(machine_id); + let deliver_subject = Self::build_deliver_subject(machine_id); + let durable_name = Self::build_durable_name(machine_id); + + info!("Consumer configuration - filter subject: {}, deliver subject: {}, durable name: {}", filter_subject, deliver_subject, durable_name); + + push::Config { + filter_subject, + deliver_subject, + durable_name: Some(durable_name), + ack_wait: Duration::from_secs(CONSUMER_ACK_WAIT_SECS), + deliver_policy: DeliverPolicy::New, + max_deliver: CONSUMER_MAX_DELIVER, + ..Default::default() + } + } + + fn build_filter_subject(_machine_id: &str) -> String { + "machine.all.client-update".to_string() + } + + fn build_deliver_subject(machine_id: &str) -> String { + format!("machine.{}.client-update.inbox", machine_id) + } + + fn build_durable_name(machine_id: &str) -> String { + // v2 suffix forces recreation of consumer with proper DeliverPolicy::Last + // Remove v2 suffix once old consumers are cleaned up + format!("machine_{}_client-update_consumer_v2", machine_id) + } + +} diff --git a/openframe-agent-lib/src/listener/tool_agent_update_listener.rs b/openframe-agent-lib/src/listener/tool_agent_update_listener.rs new file mode 100644 index 000000000..f4f0b19a1 --- /dev/null +++ b/openframe-agent-lib/src/listener/tool_agent_update_listener.rs @@ -0,0 +1,214 @@ +use crate::services::nats_connection_manager::NatsConnectionManager; +use crate::services::tool_agent_update_service::ToolAgentUpdateService; +use crate::config::update_config::{ + MAX_CONSUMER_CREATE_RETRIES, + INITIAL_RETRY_DELAY_MS, + MAX_RETRY_DELAY_MS, + RECONNECTION_DELAY_MS, + CONSUMER_ACK_WAIT_SECS, + CONSUMER_MAX_DELIVER, +}; +use async_nats::jetstream::consumer::PushConsumer; +use async_nats::jetstream::consumer::push; +use async_nats::jetstream::consumer::DeliverPolicy; +use tokio::time::Duration; +use anyhow::{Result, Context}; +use async_nats::jetstream; +use futures::StreamExt; +use tracing::{error, info, warn}; +use crate::services::AgentConfigurationService; +use crate::models::tool_agent_update_message::ToolAgentUpdateMessage; + +#[derive(Clone)] +pub struct ToolAgentUpdateListener { + pub nats_connection_manager: NatsConnectionManager, + pub tool_agent_update_service: ToolAgentUpdateService, + pub config_service: AgentConfigurationService, +} + +impl ToolAgentUpdateListener { + + const STREAM_NAME: &'static str = "TOOL_UPDATE"; + + pub fn new( + nats_connection_manager: NatsConnectionManager, + tool_agent_update_service: ToolAgentUpdateService, + config_service: AgentConfigurationService + ) -> Self { + Self { + nats_connection_manager, + tool_agent_update_service, + config_service + } + } + + /// Start listening for messages in a background task + pub async fn start(&self) -> Result> { + let listener = self.clone(); + let handle = tokio::spawn(async move { + // Reconnection loop - keeps trying to reconnect on failure + loop { + info!("Starting tool agent update listener..."); + match listener.listen().await { + Ok(_) => { + warn!("Tool agent update listener exited normally (unexpected)"); + } + Err(e) => { + error!("Tool agent update listener error: {:#}", e); + } + } + + // Wait before reconnecting + info!( + "Reconnecting tool agent update listener in {} seconds...", + RECONNECTION_DELAY_MS / 1000 + ); + tokio::time::sleep(Duration::from_millis(RECONNECTION_DELAY_MS)).await; + } + }); + Ok(handle) + } + + async fn listen(&self) -> Result<()> { + info!("Run tool agent update message listener"); + let client = self.nats_connection_manager + .get_client() + .await?; + let js = jetstream::new((*client).clone()); + + let machine_id = self.config_service.get_machine_id().await?; + + let consumer = self.create_consumer(&js, &machine_id).await?; + + info!("Start listening for tool agent update messages"); + let mut messages = consumer.messages().await?; + while let Some(message) = messages.next().await { + info!("Received tool agent update message: {:?}", message); + + let message = message?; + + let payload = String::from_utf8_lossy(&message.payload); + let tool_agent_update_message: ToolAgentUpdateMessage = serde_json::from_str(&payload)?; + let tool_agent_id = tool_agent_update_message.tool_agent_id.clone(); + + match self.tool_agent_update_service.process_update(tool_agent_update_message).await { + Ok(_) => { + // ack + info!("Acknowledging tool agent update message for tool: {}", tool_agent_id); + message.ack().await + .map_err(|e| anyhow::anyhow!("Failed to ack message: {}", e))?; + info!("Tool agent update message acknowledged for tool: {}", tool_agent_id); + } + Err(e) => { + // do not ack: let message be redelivered per consumer ack policy + error!("Failed to process tool agent update message for tool {}: {:#}", tool_agent_id, e); + info!("Leaving message unacked for potential redelivery: tool {}", tool_agent_id); + } + } + } + Ok(()) + } + + async fn create_consumer(&self, js: &jetstream::Context, machine_id: &str) -> Result { + let consumer_configuration = Self::build_consumer_configuration(machine_id); + + // Retry loop with exponential backoff + let mut retry_count = 0; + let mut delay_ms = INITIAL_RETRY_DELAY_MS; + + loop { + info!( + "Creating consumer for stream {} (attempt {}/{})", + Self::STREAM_NAME, + retry_count + 1, + MAX_CONSUMER_CREATE_RETRIES + ); + + // Try to create consumer directly - simpler approach without get_consumer_from_stream + match js.create_consumer_on_stream(consumer_configuration.clone(), Self::STREAM_NAME).await { + Ok(consumer) => { + info!("Consumer created for stream: {}", Self::STREAM_NAME); + return Ok(consumer); + } + Err(e) => { + // Check if consumer already exists - try to get it + let error_msg = format!("{:?}", e); + if error_msg.contains("consumer name already in use") || error_msg.contains("10013") { + warn!("Consumer already exists, attempting to get existing consumer"); + let durable_name = Self::build_durable_name(machine_id); + if let Ok(existing_consumer) = js.get_consumer_from_stream(Self::STREAM_NAME, &durable_name).await { + info!("Retrieved existing consumer for stream: {}", Self::STREAM_NAME); + return Ok(existing_consumer); + } + } + + retry_count += 1; + + // Check if this is a permissions error - fail fast + if error_msg.contains("Permissions Violation") { + error!( + "Permission denied for TOOL_UPDATE stream. This requires server-side NATS permissions configuration. \ + Consumer name: {}, Stream: {}", + Self::build_durable_name(machine_id), + Self::STREAM_NAME + ); + return Err(e).context( + "Insufficient NATS permissions for TOOL_UPDATE stream. \ + Please configure permissions on the server for: $JS.API.CONSUMER.CREATE.TOOL_UPDATE.>" + ); + } + + if retry_count >= MAX_CONSUMER_CREATE_RETRIES { + error!( + "Failed to create consumer after {} attempts: {:#}", + MAX_CONSUMER_CREATE_RETRIES, e + ); + return Err(e).context(format!( + "Failed to create consumer after {} retries", + MAX_CONSUMER_CREATE_RETRIES + )); + } + + warn!( + "Failed to create consumer (attempt {}/{}): {:#}. Retrying in {} ms...", + retry_count, MAX_CONSUMER_CREATE_RETRIES, e, delay_ms + ); + + tokio::time::sleep(Duration::from_millis(delay_ms)).await; + delay_ms = (delay_ms * 2).min(MAX_RETRY_DELAY_MS); + } + } + } + } + + fn build_consumer_configuration(machine_id: &str) -> push::Config { + let filter_subject = Self::build_filter_subject(machine_id); + let deliver_subject = Self::build_deliver_subject(machine_id); + let durable_name = Self::build_durable_name(machine_id); + + info!("Consumer configuration - filter subject: {}, deliver subject: {}, durable name: {}", filter_subject, deliver_subject, durable_name); + + push::Config { + filter_subject, + deliver_subject, + durable_name: Some(durable_name), + ack_wait: Duration::from_secs(CONSUMER_ACK_WAIT_SECS), + deliver_policy: DeliverPolicy::New, + max_deliver: CONSUMER_MAX_DELIVER, + ..Default::default() + } + } + + fn build_filter_subject(_machine_id: &str) -> String { + "machine.all.tool.*.update".to_string() + } + + fn build_deliver_subject(machine_id: &str) -> String { + format!("machine.{}.tool.update.inbox", machine_id) + } + + fn build_durable_name(machine_id: &str) -> String { + format!("machine_{}_tool_update_consumer", machine_id) + } + +} diff --git a/openframe-agent-lib/src/listener/tool_installation_message_listener.rs b/openframe-agent-lib/src/listener/tool_installation_message_listener.rs new file mode 100644 index 000000000..641fdb538 --- /dev/null +++ b/openframe-agent-lib/src/listener/tool_installation_message_listener.rs @@ -0,0 +1,128 @@ +use crate::services::nats_connection_manager::NatsConnectionManager; +use crate::services::tool_installation_service::ToolInstallationService; +use crate::config::update_config::{CONSUMER_ACK_WAIT_SECS, CONSUMER_MAX_DELIVER}; +use async_nats::jetstream::consumer::PushConsumer; +use async_nats::jetstream::consumer::push; +use tokio::time::Duration; +use anyhow::Result; +use async_nats::jetstream; +use futures::StreamExt; +use tracing::{error, info}; +use crate::services::AgentConfigurationService; +use crate::models::tool_installation_message::ToolInstallationMessage; + +#[derive(Clone)] +pub struct ToolInstallationMessageListener { + pub nats_connection_manager: NatsConnectionManager, + pub tool_installation_service: ToolInstallationService, + pub config_service: AgentConfigurationService, +} + +impl ToolInstallationMessageListener { + + const STREAM_NAME: &'static str = "TOOL_INSTALLATION"; + + pub fn new( + nats_connection_manager: NatsConnectionManager, + tool_installation_service: ToolInstallationService, + config_service: AgentConfigurationService + ) -> Self { + Self { + nats_connection_manager, + tool_installation_service, + config_service + } + } + + /// Start listening for messages in a background task + pub async fn start(&self) -> Result> { + let listener = self.clone(); + let handle = tokio::spawn(async move { + // TODO: add reconnection and consumer creation loop after token fallback is implemented + if let Err(e) = listener.listen().await { + error!("Tool installation message listener error: {:#}", e); + } + }); + Ok(handle) + } + + async fn listen(&self) -> Result<()> { + info!("Run tool installation message listener"); + let client = self.nats_connection_manager + .get_client() + .await?; + let js = jetstream::new((*client).clone()); + + let machine_id = self.config_service.get_machine_id().await?; + + let consumer = self.create_consumer(&js, &machine_id).await?; + + info!("Start listening for tool installation messages"); + let mut messages = consumer.messages().await?; + while let Some(message) = messages.next().await { + let message = message?; + let payload = String::from_utf8_lossy(&message.payload); + info!("Received tool installation message: {:?}", payload); + + let tool_installation_message: ToolInstallationMessage = serde_json::from_str(&payload)?; + let tool_agent_id = tool_installation_message.tool_agent_id.clone(); + + match self.tool_installation_service.install(tool_installation_message).await { + Ok(_) => { + // ack + info!("Acknowledging installation message for tool: {}", tool_agent_id); + message.ack().await + .map_err(|e| anyhow::anyhow!("Failed to ack message: {}", e))?; + info!("Installation message acknowledged for tool: {}", tool_agent_id); + } + Err(e) => { + // do not ack: let message be redelivered per consumer ack policy + error!("Failed to process tool installation message for tool {}: {:#}", tool_agent_id, e); + info!("Leaving message unacked for potential redelivery: tool {}", tool_agent_id); + } + } + } + Ok(()) + } + + async fn create_consumer(&self, js: &jetstream::Context, machine_id: &str) -> Result { + // TODO: retry if failed to create + let consumer_configuration = Self::build_consumer_configuration(machine_id); + info!("Creating consumer for stream {} ", Self::STREAM_NAME); + let consumer = js.create_consumer_on_stream(consumer_configuration, Self::STREAM_NAME).await?; + info!("Consumer created for stream: {}", Self::STREAM_NAME); + Ok(consumer) + } + + fn build_consumer_configuration(machine_id: &str) -> push::Config { + let filter_subject = Self::build_filter_subject(machine_id); + let deliver_subject = Self::build_deliver_subject(machine_id); + let durable_name = Self::build_durable_name(machine_id); + + info!("Consumer configuration - filter subject: {}, deliver subject: {}, durable name: {}", filter_subject, deliver_subject, durable_name); + + push::Config { + filter_subject, + deliver_subject, + durable_name: Some(durable_name), + ack_wait: Duration::from_secs(CONSUMER_ACK_WAIT_SECS), + max_deliver: CONSUMER_MAX_DELIVER, + ..Default::default() + } + } + + fn build_filter_subject(machine_id: &str) -> String { + format!("machine.{}.tool-installation", machine_id) + } + + fn build_deliver_subject(machine_id: &str) -> String { + format!("machine.{}.tool-installation.inbox", machine_id) + } + + fn build_durable_name(machine_id: &str) -> String { + format!("machine_{}_tool-installation_consumer", machine_id) + } + +} + + diff --git a/openframe-agent-lib/src/logging/metrics.rs b/openframe-agent-lib/src/logging/metrics.rs new file mode 100644 index 000000000..639eefab4 --- /dev/null +++ b/openframe-agent-lib/src/logging/metrics.rs @@ -0,0 +1,162 @@ +use std::collections::HashMap; +use std::sync::Arc; +use tokio::sync::RwLock; +use tracing::{Event, Subscriber}; +use tracing_subscriber::Layer; + +/// Metric types we support +#[derive(Debug, Clone)] +pub enum MetricValue { + Counter(u64), + Gauge(f64), + Histogram(Vec), +} + +/// A metric with its metadata +#[derive(Debug, Clone)] +#[allow(dead_code)] +pub struct Metric { + name: String, + value: MetricValue, + labels: HashMap, + timestamp: chrono::DateTime, +} + +/// Storage for our metrics +#[derive(Default)] +pub struct MetricsStore { + metrics: HashMap, +} + +impl MetricsStore { + pub fn new() -> Self { + Self { + metrics: HashMap::new(), + } + } + + pub fn record_counter(&mut self, name: &str, value: u64, labels: HashMap) { + self.metrics.insert( + name.to_string(), + Metric { + name: name.to_string(), + value: MetricValue::Counter(value), + labels, + timestamp: chrono::Utc::now(), + }, + ); + } + + pub fn record_gauge(&mut self, name: &str, value: f64, labels: HashMap) { + self.metrics.insert( + name.to_string(), + Metric { + name: name.to_string(), + value: MetricValue::Gauge(value), + labels, + timestamp: chrono::Utc::now(), + }, + ); + } + + pub fn record_histogram(&mut self, name: &str, value: f64, labels: HashMap) { + let metric = self + .metrics + .entry(name.to_string()) + .or_insert_with(|| Metric { + name: name.to_string(), + value: MetricValue::Histogram(Vec::new()), + labels, + timestamp: chrono::Utc::now(), + }); + + if let MetricValue::Histogram(values) = &mut metric.value { + values.push(value); + } + } + + pub fn get_metrics(&self) -> Vec<&Metric> { + self.metrics.values().collect() + } +} + +/// Layer that captures metrics from tracing events +#[derive(Clone)] +pub struct MetricsLayer { + store: Arc>, +} + +impl MetricsLayer { + pub fn new() -> (Self, Arc>) { + let store = Arc::new(RwLock::new(MetricsStore::new())); + ( + Self { + store: store.clone(), + }, + store, + ) + } +} + +impl Layer for MetricsLayer +where + S: Subscriber, +{ + fn on_event(&self, event: &Event<'_>, _ctx: tracing_subscriber::layer::Context<'_, S>) { + // Extract metric information from the event + let mut visitor = MetricVisitor::default(); + event.record(&mut visitor); + + if let Some((name, value, labels)) = visitor.metric { + let mut store = self.store.blocking_write(); + match value { + MetricValue::Counter(v) => store.record_counter(&name, v, labels), + MetricValue::Gauge(v) => store.record_gauge(&name, v, labels), + MetricValue::Histogram(v) => { + for value in v { + store.record_histogram(&name, value, labels.clone()); + } + } + } + } + } +} + +#[derive(Default)] +struct MetricVisitor { + metric: Option<(String, MetricValue, HashMap)>, +} + +impl tracing::field::Visit for MetricVisitor { + fn record_f64(&mut self, field: &tracing::field::Field, value: f64) { + if field.name() == "gauge" { + self.metric = Some(( + field.name().to_string(), + MetricValue::Gauge(value), + HashMap::new(), + )); + } else if field.name() == "histogram" { + self.metric = Some(( + field.name().to_string(), + MetricValue::Histogram(vec![value]), + HashMap::new(), + )); + } + } + + fn record_u64(&mut self, field: &tracing::field::Field, value: u64) { + if field.name() == "counter" { + self.metric = Some(( + field.name().to_string(), + MetricValue::Counter(value), + HashMap::new(), + )); + } + } + + fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn std::fmt::Debug) { + if let Some((_, _, labels)) = &mut self.metric { + labels.insert(field.name().to_string(), format!("{:?}", value)); + } + } +} diff --git a/openframe-agent-lib/src/logging/mod.rs b/openframe-agent-lib/src/logging/mod.rs new file mode 100644 index 000000000..a4bc845f7 --- /dev/null +++ b/openframe-agent-lib/src/logging/mod.rs @@ -0,0 +1,539 @@ +/// Cross-platform logging system +/// +/// This module implements a robust, cross-platform logging system using the tracing +/// library. Key features: +/// +/// - Platform-specific log paths (Windows, macOS, Linux) +/// - Automatic log rotation and compression +/// - Configurable output format (text one-liners by default, optional JSON) +/// - Metrics collection via the metrics submodule +/// - Fallback manual logging when tracing fails +/// +/// The logging system is initialized via the `init()` function and should be +/// called early in the application lifecycle. +pub mod metrics; +pub mod shipping; + +use crate::platform::DirectoryManager; +use flate2::write::GzEncoder; +use flate2::Compression; +use metrics::MetricsStore; +use serde::Serialize; +use std::collections::HashMap; +use std::fs; +use std::io::{self, Read, Write}; +use std::path::PathBuf; +use std::sync::Arc; +use std::sync::Mutex; +use std::time::{SystemTime, UNIX_EPOCH}; +use tokio::sync::RwLock; +use tracing::Level; +use tracing_subscriber::{ + fmt::{self}, + layer::SubscriberExt, + EnvFilter, Layer, Registry, +}; + +// Add non-blocking file writer guard to keep file logging alive +use tracing_appender::non_blocking::{self, WorkerGuard}; + +#[derive(Debug, Serialize)] +struct LogEntry { + timestamp: String, + level: String, + target: String, + module_path: Option, + file: Option, + line: Option, + thread: String, + message: String, + #[serde(skip_serializing_if = "Option::is_none")] + error: Option, + #[serde(flatten)] + context: serde_json::Value, +} + +// Global file logger for direct writes when tracing fails +static LOG_FILE: std::sync::OnceLock>>> = + std::sync::OnceLock::new(); + +// Keep non-blocking worker guard alive for file logging +static LOG_GUARD: std::sync::OnceLock = std::sync::OnceLock::new(); + +// Get access to the global log file for manual writing +fn get_log_file() -> Arc>> { + LOG_FILE.get().cloned().unwrap_or_else(|| { + let file = Arc::new(Mutex::new(None)); + let _ = LOG_FILE.set(file.clone()); + file + }) +} + +// Manual log function that writes directly to file - fallback when tracing fails +pub fn manual_log(level: &str, message: &str) { + let timestamp = chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Micros, true); + // Write a simple single-line text log + let line = format!("{} {} [{}] {}", timestamp, level, "manual", message); + + if let Ok(mut file_lock) = get_log_file().lock() { + if let Some(ref mut file) = *file_lock { + let _ = writeln!(file, "{}", line); + let _ = file.flush(); + } + } + + // Also write to stdout for capture by LaunchDaemon + println!("{}", line); +} + +pub struct JsonLayer { + writer: Arc>, + metrics: Arc>, +} + +impl JsonLayer { + fn new(log_file: PathBuf) -> std::io::Result { + let file = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(log_file)?; + + Ok(Self { + writer: Arc::new(std::sync::Mutex::new(file)), + metrics: Arc::new(RwLock::new(MetricsStore::new())), + }) + } +} + +impl Layer for JsonLayer +where + S: tracing::Subscriber, +{ + fn on_event( + &self, + event: &tracing::Event<'_>, + _ctx: tracing_subscriber::layer::Context<'_, S>, + ) { + let mut visitor = JsonVisitor::default(); + event.record(&mut visitor); + + let level = event.metadata().level().to_string(); + + // Update metrics based on log level + let mut labels = HashMap::new(); + labels.insert("level".to_string(), level.clone()); + + if let Ok(mut metrics) = self.metrics.try_write() { + metrics.record_counter("log_count", 1, labels); + } + + // Store message content in a separate variable before using it + let message_content = visitor.message.clone().unwrap_or_default(); + + let log_entry = LogEntry { + timestamp: chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Micros, true), + level, + target: event.metadata().target().to_string(), + module_path: event.metadata().module_path().map(|s| s.to_string()), + file: event.metadata().file().map(|s| s.to_string()), + line: event.metadata().line(), + thread: format!("{:?}", std::thread::current().id()), + message: message_content.clone(), + error: visitor.error, + context: serde_json::Value::Object(visitor.fields), + }; + + if let Ok(json) = serde_json::to_string(&log_entry) { + if let Ok(mut file) = self.writer.lock() { + let _ = writeln!(file, "{}", json); + let _ = file.flush(); + } + + // Also write to stdout for capture by LaunchDaemon + // Use stdout for INFO or lower level logs, stderr for warnings/errors + if event.metadata().level() <= &Level::INFO { + println!("{}", json); + } else { + eprintln!("{}", json); + } + } + + // Don't write to manual log as backup - this was causing duplicate entries + // manual_log(&event.metadata().level().to_string(), &message_content); + } +} + +#[derive(Default)] +struct JsonVisitor { + message: Option, + error: Option, + fields: serde_json::Map, +} + +impl tracing::field::Visit for JsonVisitor { + fn record_str(&mut self, field: &tracing::field::Field, value: &str) { + if field.name() == "message" { + self.message = Some(value.to_string()); + // Don't add message to fields as we'll use the dedicated message field + } else if field.name() == "error" { + self.error = Some(value.to_string()); + } else { + self.fields.insert(field.name().to_string(), value.into()); + } + } + + fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn std::fmt::Debug) { + if field.name() == "message" { + // Capture message content when it comes through record_debug as well + self.message = Some(format!("{:?}", value)); + } else { + self.fields + .insert(field.name().to_string(), format!("{:?}", value).into()); + } + } +} + +/// Initialize logging with optional endpoint and agent ID +pub fn init(log_endpoint: Option, agent_id: Option) -> std::io::Result<()> { + // Check if logging is already initialized + static INIT: std::sync::Once = std::sync::Once::new(); + let mut init_result = Ok(()); + + INIT.call_once(|| { + // Initialize directory manager based on environment + let dir_manager = if std::env::var("OPENFRAME_DEV_MODE").is_ok() { + // In development mode, use user logs directory to avoid permission issues + eprintln!("Running in development mode, using user logs directory"); + DirectoryManager::for_development() + } else { + // In normal mode, use system logs directory + DirectoryManager::new() + }; + + if let Err(e) = dir_manager.perform_health_check() { + init_result = Err(std::io::Error::other(format!( + "Failed to initialize logging directories: {}", + e + ))); + eprintln!("ERROR: Failed to initialize logging directories: {}", e); + return; + } + + // Get the log file path from the directory manager + let log_file_path = get_log_file_path(&dir_manager); + + eprintln!("Initializing logging to {}", log_file_path.display()); + + // Initialize the log file for manual writing when tracing fails + match std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(&log_file_path) + { + Ok(file) => { + let _ = LOG_FILE.set(Arc::new(Mutex::new(Some(file)))); + } + Err(e) => { + eprintln!("ERROR: Failed to open log file: {}", e); + init_result = Err(e); + return; + } + } + + // Try to compress old log files in a background thread + let dir_manager_clone = dir_manager.clone(); + std::thread::spawn(move || { + // This runs in a background thread so we just log any errors + loop { + if let Err(e) = compress_old_logs(&dir_manager_clone) { + log::error!("Error compressing old logs: {:#}", e); + } + // Check for files to compress every hour + std::thread::sleep(std::time::Duration::from_secs(3600)); + } + }); + + // Create metrics layer and store + let (metrics_layer, metrics_store) = metrics::MetricsLayer::new(); + + // Set global metrics store for later access + let _ = METRICS_STORE.set(Arc::clone(&metrics_store)); + + // Set up the full tracing subscriber + let env_filter = + EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")); + + // Decide output format: default to text (one-liners). Set OPENFRAME_LOG_FORMAT=json to use JSON + let format = std::env::var("OPENFRAME_LOG_FORMAT").unwrap_or_else(|_| "text".into()); + + if format.eq_ignore_ascii_case("json") { + // JSON structured logging (legacy behavior) + // Create a JSON layer for structured logging + let json_layer = match JsonLayer::new(log_file_path.clone()) { + Ok(layer) => layer, + Err(e) => { + eprintln!("ERROR: Failed to create JSON logging layer: {}", e); + init_result = Err(e); + return; + } + }; + + let subscriber = Registry::default() + .with(env_filter) + .with(json_layer) + .with(metrics_layer); + + if let Err(e) = tracing::subscriber::set_global_default(subscriber) { + eprintln!("ERROR: Failed to set global tracing subscriber: {}", e); + init_result = Err(std::io::Error::other(format!( + "Failed to set global tracing subscriber: {}", + e + ))); + return; + } + } else { + // Text one-liner logging to stdout and file + // Non-blocking file writer + let file = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(&log_file_path) + .expect("failed to open log file for text logging"); + let (file_writer, guard) = non_blocking::NonBlockingBuilder::default() + .lossy(false) + .thread_name("of-log-writer") + .finish(file); + let _ = LOG_GUARD.set(guard); // keep guard alive + + // stdout layer (compact, single-line) + let stdout_layer = fmt::layer() + .with_target(true) + .with_level(true) + .compact() + .with_ansi(false); + + // file layer (compact, single-line) + let file_layer = fmt::layer() + .with_target(true) + .with_level(true) + .compact() + .with_ansi(false) + .with_writer(file_writer); + + let subscriber = Registry::default() + .with(env_filter) + .with(stdout_layer) + .with(file_layer) + .with(metrics_layer); + + if let Err(e) = tracing::subscriber::set_global_default(subscriber) { + eprintln!("ERROR: Failed to set global tracing subscriber: {}", e); + init_result = Err(std::io::Error::other(format!( + "Failed to set global tracing subscriber: {}", + e + ))); + return; + } + } + + // Force an initial log entry with explicit info level to ensure logging is working + tracing::info!("OpenFrame logging system initialized"); + manual_log("INFO", "Logging system initialized"); + + // Initialize log shipping if endpoint is provided + if let Some(endpoint) = log_endpoint { + if let Some(agent) = agent_id.clone() { + // Create a log shipper instance - it starts itself with its background task + let _shipper = shipping::LogShipper::new(endpoint.clone(), agent.clone()); + tracing::info!("Log shipping initialized to endpoint: {}", endpoint); + } + } + }); + + init_result +} + +// Static storage for metrics +static METRICS_STORE: std::sync::OnceLock>> = std::sync::OnceLock::new(); + +/// Get access to the metrics store +pub fn get_metrics_store() -> Option>> { + METRICS_STORE.get().cloned() +} + +/// Try to compress old log files +fn compress_old_logs(dir_manager: &DirectoryManager) -> io::Result<()> { + // If we fail to open the log dir that's okay, just return + let log_dir = match dir_manager.logs_dir().canonicalize() { + Ok(dir) => dir, + Err(e) => { + log::error!("Failed to get logs directory: {:#}", e); + return Ok(()); + } + }; + + // Find log files older than 1 day and compress them + let entries = match fs::read_dir(log_dir) { + Ok(entries) => entries, + Err(e) => { + log::error!("Failed to read logs directory: {:#}", e); + return Ok(()); + } + }; + + let one_day_ago = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs() as i64 + - 86400; + + for entry in entries { + let entry = match entry { + Ok(entry) => entry, + Err(e) => { + log::error!("Failed to read directory entry: {}", e); + continue; + } + }; + + let metadata = match entry.metadata() { + Ok(metadata) => metadata, + Err(e) => { + log::error!( + "Failed to get metadata for {}: {}", + entry.path().display(), + e + ); + continue; + } + }; + + // Skip directories and non-log files + if metadata.is_dir() || !entry.file_name().to_string_lossy().ends_with(".log") { + continue; + } + + // Skip if already compressed + if entry.file_name().to_string_lossy().ends_with(".gz") { + continue; + } + + // Skip if modified in the last day + let modified = match metadata.modified() { + Ok(time) => match time.duration_since(UNIX_EPOCH) { + Ok(duration) => duration.as_secs() as i64, + Err(e) => { + log::error!( + "Failed to get modification time for {}: {}", + entry.path().display(), + e + ); + continue; + } + }, + Err(e) => { + log::error!( + "Failed to get modification time for {}: {}", + entry.path().display(), + e + ); + continue; + } + }; + + if modified > one_day_ago { + continue; + } + + // Compress the file + if let Err(e) = compress_log_file(&entry.path()) { + log::error!("Failed to compress {}: {}", entry.path().display(), e); + } else { + log::info!("Compressed old log file: {}", entry.path().display()); + } + } + + Ok(()) +} + +/// Check if the given log file is the current day's log +#[allow(dead_code)] +fn is_current_log(filename: &str) -> bool { + let today = chrono::Local::now().format("%Y-%m-%d").to_string(); + filename.contains(&today) +} + +/// Compress a log file using gzip compression +fn compress_log_file(path: &PathBuf) -> std::io::Result<()> { + let mut input = fs::File::open(path)?; + let mut contents = Vec::new(); + input.read_to_end(&mut contents)?; + + let gz_path = path.with_extension("log.gz"); + let output = fs::File::create(&gz_path)?; + let mut encoder = GzEncoder::new(output, Compression::default()); + encoder.write_all(&contents)?; + encoder.finish()?; + + // Remove the original file after successful compression + fs::remove_file(path)?; + + Ok(()) +} + +/// Get the current log file path +pub fn get_log_file_path(dir_manager: &DirectoryManager) -> PathBuf { + // In development mode, use the user logs directory instead of system logs + if std::env::var("OPENFRAME_DEV_MODE").is_ok() { + dir_manager.user_logs_dir().join("openframe.log") + } else { + dir_manager.logs_dir().join("openframe.log") + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Read; + use tempfile::tempdir; + use tracing::{debug, error, info, trace, warn}; + + #[test] + fn test_structured_logging() -> std::io::Result<()> { + let temp_dir = tempdir()?; + let log_file = temp_dir.path().join("test.log"); + + let json_layer = JsonLayer::new(log_file.clone())?; + let subscriber = Registry::default().with(json_layer); + + tracing::subscriber::set_global_default(subscriber).expect("Failed to set subscriber"); + + // Log messages with different levels and context + error!(error = "test error", "Error message"); + warn!(user = "test_user", "Warning message"); + info!(request_id = 123, "Info message"); + debug!(status = "pending", "Debug message"); + trace!(correlation_id = "abc", "Trace message"); + + // Read and verify log file contents + let mut file = std::fs::File::open(log_file)?; + let mut contents = String::new(); + file.read_to_string(&mut contents)?; + + // Verify each log level appears in the file + assert!(contents.contains(r#""level":"ERROR"#)); + assert!(contents.contains(r#""level":"WARN"#)); + assert!(contents.contains(r#""level":"INFO"#)); + assert!(contents.contains(r#""level":"DEBUG"#)); + assert!(contents.contains(r#""level":"TRACE"#)); + + // Verify custom fields are included + assert!(contents.contains(r#""error":"test error"#)); + assert!(contents.contains(r#""user":"test_user"#)); + assert!(contents.contains(r#""request_id":"123"#)); + assert!(contents.contains(r#""status":"pending"#)); + assert!(contents.contains(r#""correlation_id":"abc"#)); + + Ok(()) + } +} diff --git a/openframe-agent-lib/src/logging/platform.rs b/openframe-agent-lib/src/logging/platform.rs new file mode 100644 index 000000000..26bc7fae3 --- /dev/null +++ b/openframe-agent-lib/src/logging/platform.rs @@ -0,0 +1,107 @@ +use std::fs; +use std::fs::OpenOptions; +use std::path::PathBuf; +use std::process::Command; + +/// Returns the platform-specific log directory path +pub fn get_log_directory() -> PathBuf { + #[cfg(target_os = "windows")] + { + let program_data = + std::env::var_os("ProgramData").expect("ProgramData environment variable not found"); + let mut path = PathBuf::from(program_data); + path.push("OpenFrame"); + path.push("logs"); + path + } + + #[cfg(target_os = "macos")] + { + PathBuf::from("/Library/Logs/OpenFrame") + } + + #[cfg(target_os = "linux")] + { + PathBuf::from("/var/log/openframe") + } +} + +/// Ensures the log directory exists and has correct permissions +pub fn ensure_log_directory() -> Result { + let log_dir = get_log_directory(); + + // Create parent directories if they don't exist + if let Some(parent) = log_dir.parent() { + fs::create_dir_all(parent)?; + } + + // Create the OpenFrame log directory if it doesn't exist + if !log_dir.exists() { + fs::create_dir_all(&log_dir)?; + + #[cfg(target_os = "macos")] + { + // On macOS, we need root:admin ownership and 775 permissions + // These commands will fail gracefully if not run as root + let _ = Command::new("chown") + .args(["-R", "root:admin", log_dir.to_str().unwrap()]) + .status(); + let _ = Command::new("chmod") + .args(["-R", "775", log_dir.to_str().unwrap()]) + .status(); + } + + #[cfg(not(target_os = "macos"))] + { + // Set directory permissions to 755 (rwxr-xr-x) on other Unix systems + use std::os::unix::fs::PermissionsExt; + let permissions = fs::Permissions::from_mode(0o755); + fs::set_permissions(&log_dir, permissions)?; + } + } + + // Verify we can write to the directory + if !can_write_to_directory(&log_dir) { + return Err(std::io::Error::new( + std::io::ErrorKind::PermissionDenied, + format!( + "Insufficient permissions to write to log directory: {}", + log_dir.display() + ), + )); + } + + Ok(log_dir) +} + +fn can_write_to_directory(path: &PathBuf) -> bool { + // Try to create a temporary file in the directory + let temp_file = path.join(".write_test"); + let result = OpenOptions::new().write(true).create(true).open(&temp_file); + + // Clean up the test file if it was created + if temp_file.exists() { + let _ = fs::remove_file(&temp_file); + } + + result.is_ok() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_get_log_directory() { + let log_dir = get_log_directory(); + + #[cfg(target_os = "windows")] + assert!(log_dir.to_string_lossy().contains("OpenFrame\\logs")); + + #[cfg(target_os = "macos")] + assert_eq!(log_dir.to_string_lossy(), "/Library/Logs/OpenFrame"); + + #[cfg(target_os = "linux")] + assert_eq!(log_dir.to_string_lossy(), "/var/log/openframe"); + } +} diff --git a/openframe-agent-lib/src/logging/shipping.rs b/openframe-agent-lib/src/logging/shipping.rs new file mode 100644 index 000000000..0b353a9bc --- /dev/null +++ b/openframe-agent-lib/src/logging/shipping.rs @@ -0,0 +1,103 @@ +use anyhow::Result; +use serde::Serialize; +use tokio::sync::mpsc; +use tokio::time::{sleep, Duration}; + +const BATCH_SIZE: usize = 100; +const BATCH_TIMEOUT: Duration = Duration::from_secs(30); + +#[derive(Debug, Clone, Serialize)] +pub struct LogBatch { + pub logs: Vec, + pub agent_id: String, + pub timestamp: chrono::DateTime, +} + +pub struct LogShipper { + sender: mpsc::Sender, + #[allow(dead_code)] + endpoint: String, + #[allow(dead_code)] + agent_id: String, +} + +impl LogShipper { + pub fn new(endpoint: String, agent_id: String) -> Self { + let (sender, receiver) = mpsc::channel(1000); + + // Clone values before moving them + let endpoint_clone = endpoint.clone(); + let agent_id_clone = agent_id.clone(); + + let shipper = LogShipper { + sender, + endpoint, + agent_id, + }; + + // Spawn background shipping task + tokio::spawn(async move { + Self::ship_logs(receiver, endpoint_clone, agent_id_clone).await; + }); + + shipper + } + + pub async fn send(&self, log: String) -> Result<()> { + self.sender.send(log).await?; + Ok(()) + } + + async fn ship_logs(mut receiver: mpsc::Receiver, endpoint: String, agent_id: String) { + let mut batch = Vec::with_capacity(BATCH_SIZE); + let client = reqwest::Client::new(); + + loop { + tokio::select! { + // Wait for either a new log message or the batch timeout + Some(log) = receiver.recv() => { + batch.push(log); + + // Ship batch if it reaches max size + if batch.len() >= BATCH_SIZE { + if let Err(e) = Self::send_batch(&client, &endpoint, &agent_id, batch.clone()).await { + tracing::error!("Failed to ship log batch: {:#}", e); + } + batch.clear(); + } + } + _ = sleep(BATCH_TIMEOUT) => { + // Ship current batch if we have any logs + if !batch.is_empty() { + if let Err(e) = Self::send_batch(&client, &endpoint, &agent_id, batch.clone()).await { + tracing::error!("Failed to ship log batch: {:#}", e); + } + batch.clear(); + } + } + } + } + } + + async fn send_batch( + client: &reqwest::Client, + endpoint: &str, + agent_id: &str, + logs: Vec, + ) -> Result<()> { + let batch = LogBatch { + logs, + agent_id: agent_id.to_string(), + timestamp: chrono::Utc::now(), + }; + + client + .post(endpoint) + .json(&batch) + .send() + .await? + .error_for_status()?; + + Ok(()) + } +} diff --git a/openframe-agent-lib/src/metrics.rs b/openframe-agent-lib/src/metrics.rs new file mode 100644 index 000000000..88a699b08 --- /dev/null +++ b/openframe-agent-lib/src/metrics.rs @@ -0,0 +1,7 @@ +use anyhow::Result; +use tracing::info; + +pub fn init() -> Result<()> { + info!("Initializing metrics collection"); + Ok(()) +} diff --git a/openframe-agent-lib/src/models/agent_configuration.rs b/openframe-agent-lib/src/models/agent_configuration.rs new file mode 100644 index 000000000..87c71112f --- /dev/null +++ b/openframe-agent-lib/src/models/agent_configuration.rs @@ -0,0 +1,10 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] +pub struct AgentConfiguration { + pub machine_id: String, + pub client_id: String, + pub client_secret: String, + pub access_token: String, + pub refresh_token: String, +} diff --git a/openframe-agent-lib/src/models/agent_registration_request.rs b/openframe-agent-lib/src/models/agent_registration_request.rs new file mode 100644 index 000000000..b119b4f0c --- /dev/null +++ b/openframe-agent-lib/src/models/agent_registration_request.rs @@ -0,0 +1,11 @@ +use serde::Serialize; + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentRegistrationRequest { + pub hostname: String, + pub agent_version: String, + #[serde(skip_serializing_if = "String::is_empty", default)] + pub organization_id: String, + pub os_type: String, +} \ No newline at end of file diff --git a/openframe-agent-lib/src/models/agent_registration_response.rs b/openframe-agent-lib/src/models/agent_registration_response.rs new file mode 100644 index 000000000..43ea7c258 --- /dev/null +++ b/openframe-agent-lib/src/models/agent_registration_response.rs @@ -0,0 +1,9 @@ +use serde::Deserialize; + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentRegistrationResponse { + pub machine_id: String, + pub client_id: String, + pub client_secret: String, +} \ No newline at end of file diff --git a/openframe-agent-lib/src/models/agent_token_response.rs b/openframe-agent-lib/src/models/agent_token_response.rs new file mode 100644 index 000000000..edce00006 --- /dev/null +++ b/openframe-agent-lib/src/models/agent_token_response.rs @@ -0,0 +1,10 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentTokenResponse { + pub access_token: String, + pub refresh_token: String, + pub token_type: String, + pub expires_in: Option, +} \ No newline at end of file diff --git a/openframe-agent-lib/src/models/download_configuration.rs b/openframe-agent-lib/src/models/download_configuration.rs new file mode 100644 index 000000000..f34267787 --- /dev/null +++ b/openframe-agent-lib/src/models/download_configuration.rs @@ -0,0 +1,28 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DownloadConfiguration { + pub os: String, + pub file_name: String, + pub agent_file_name: String, + pub link: String, +} + +impl DownloadConfiguration { + /// Checks if this configuration matches the current OS + pub fn matches_current_os(&self) -> bool { + let current_os = if cfg!(target_os = "windows") { + "windows" + } else if cfg!(target_os = "macos") { + "macos" + } else if cfg!(target_os = "linux") { + "linux" + } else { + return false; + }; + + self.os.eq_ignore_ascii_case(current_os) + } +} + diff --git a/openframe-agent-lib/src/models/initial_configuration.rs b/openframe-agent-lib/src/models/initial_configuration.rs new file mode 100644 index 000000000..4532af76b --- /dev/null +++ b/openframe-agent-lib/src/models/initial_configuration.rs @@ -0,0 +1,12 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, Serialize, Deserialize, Default)] +pub struct InitialConfiguration { + pub server_host: String, + pub initial_key: String, + pub local_mode: bool, + #[serde(default)] + pub org_id: String, + #[serde(default)] + pub local_ca_cert_path: String, +} diff --git a/openframe-agent-lib/src/models/installed_agent_message.rs b/openframe-agent-lib/src/models/installed_agent_message.rs new file mode 100644 index 000000000..74f86aa24 --- /dev/null +++ b/openframe-agent-lib/src/models/installed_agent_message.rs @@ -0,0 +1,9 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct InstalledAgentMessage { + pub agent_type: String, + pub version: String, +} + diff --git a/openframe-agent-lib/src/models/installed_tool.rs b/openframe-agent-lib/src/models/installed_tool.rs new file mode 100644 index 000000000..7b13424ea --- /dev/null +++ b/openframe-agent-lib/src/models/installed_tool.rs @@ -0,0 +1,41 @@ +use crate::models::SessionType; +use serde::{Deserialize, Serialize}; + +/// Installation status of the tool on the endpoint. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +#[derive(Default)] +pub enum ToolStatus { + #[default] + Installed, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct InstalledTool { + pub tool_agent_id: String, + pub tool_id: String, + pub tool_type: String, + pub version: String, + pub session_type: SessionType, + pub run_command_args: Vec, + pub tool_agent_id_command_args: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub uninstallation_command_args: Option>, + pub status: ToolStatus, +} + +impl Default for InstalledTool { + fn default() -> Self { + Self { + tool_agent_id: String::new(), + tool_id: String::new(), + tool_type: String::new(), + version: String::new(), + session_type: SessionType::Service, + run_command_args: Vec::new(), + status: ToolStatus::default(), + tool_agent_id_command_args: Vec::new(), + uninstallation_command_args: None, + } + } +} diff --git a/openframe-agent-lib/src/models/machine_heartbeat_message.rs b/openframe-agent-lib/src/models/machine_heartbeat_message.rs new file mode 100644 index 000000000..ab37b7ccb --- /dev/null +++ b/openframe-agent-lib/src/models/machine_heartbeat_message.rs @@ -0,0 +1,13 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MachineHeartbeatMessage { + // Empty object - machineId comes from topic name, timestamp generated at service side +} + +impl MachineHeartbeatMessage { + pub fn new() -> Self { + Self {} + } +} diff --git a/openframe-agent-lib/src/models/mod.rs b/openframe-agent-lib/src/models/mod.rs new file mode 100644 index 000000000..81981d1a3 --- /dev/null +++ b/openframe-agent-lib/src/models/mod.rs @@ -0,0 +1,37 @@ +pub mod agent_registration_request; +pub mod agent_registration_response; +pub mod agent_configuration; +pub mod initial_configuration; +pub mod agent_token_response; +pub mod tool_installation_result; +pub mod tool_installation_message; +pub mod tool_connection_message; +pub mod installed_tool; +pub mod tool_connection; +pub mod openframe_client_update_message; +pub mod tool_agent_update_message; +pub mod openframe_client_info; +pub mod machine_heartbeat_message; +pub mod download_configuration; +pub mod installed_agent_message; +pub mod update_state; + +pub use agent_registration_request::AgentRegistrationRequest; +pub use agent_registration_response::AgentRegistrationResponse; +pub use agent_configuration::AgentConfiguration; +pub use initial_configuration::InitialConfiguration; +pub use agent_token_response::AgentTokenResponse; +pub use tool_installation_result::ToolInstallationResult; +pub use tool_installation_message::ToolInstallationMessage; +pub use tool_installation_message::SessionType; +pub use tool_connection_message::ToolConnectionMessage; +pub use installed_tool::InstalledTool; +pub use installed_tool::ToolStatus; +pub use tool_connection::ToolConnection; +pub use openframe_client_update_message::OpenFrameClientUpdateMessage; +pub use tool_agent_update_message::ToolAgentUpdateMessage; +pub use openframe_client_info::OpenFrameClientInfo; +pub use machine_heartbeat_message::MachineHeartbeatMessage; +pub use download_configuration::DownloadConfiguration; +pub use installed_agent_message::InstalledAgentMessage; +pub use update_state::{UpdateState, UpdatePhase}; diff --git a/openframe-agent-lib/src/models/openframe_client_info.rs b/openframe-agent-lib/src/models/openframe_client_info.rs new file mode 100644 index 000000000..57a2194e5 --- /dev/null +++ b/openframe-agent-lib/src/models/openframe_client_info.rs @@ -0,0 +1,23 @@ +use serde::{Deserialize, Serialize}; + +/// Status of the OpenFrame client update. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +#[derive(Default)] +pub enum ClientUpdateStatus { + #[default] + Current, + Updating, + Updated, + Failed, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct OpenFrameClientInfo { + pub current_version: String, + pub target_version: Option, + pub status: ClientUpdateStatus, + pub binary_path: String, + pub last_update_check: Option, + pub last_updated: Option, +} diff --git a/openframe-agent-lib/src/models/openframe_client_update_message.rs b/openframe-agent-lib/src/models/openframe_client_update_message.rs new file mode 100644 index 000000000..ee2b0fd46 --- /dev/null +++ b/openframe-agent-lib/src/models/openframe_client_update_message.rs @@ -0,0 +1,9 @@ +use serde::{Deserialize, Serialize}; +use super::download_configuration::DownloadConfiguration; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct OpenFrameClientUpdateMessage { + pub version: String, + pub download_configurations: Vec, +} diff --git a/openframe-agent-lib/src/models/tool_agent_update_message.rs b/openframe-agent-lib/src/models/tool_agent_update_message.rs new file mode 100644 index 000000000..635c6eaf9 --- /dev/null +++ b/openframe-agent-lib/src/models/tool_agent_update_message.rs @@ -0,0 +1,10 @@ +use serde::{Deserialize, Serialize}; +use super::download_configuration::DownloadConfiguration; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ToolAgentUpdateMessage { + pub tool_agent_id: String, + pub version: String, + pub download_configurations: Vec, +} diff --git a/openframe-agent-lib/src/models/tool_connection.rs b/openframe-agent-lib/src/models/tool_connection.rs new file mode 100644 index 000000000..08fab25a6 --- /dev/null +++ b/openframe-agent-lib/src/models/tool_connection.rs @@ -0,0 +1,8 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] +pub struct ToolConnection { + pub tool_agent_id: String, + pub agent_tool_id: String, + pub published: bool, +} diff --git a/openframe-agent-lib/src/models/tool_connection_message.rs b/openframe-agent-lib/src/models/tool_connection_message.rs new file mode 100644 index 000000000..3200f0fbe --- /dev/null +++ b/openframe-agent-lib/src/models/tool_connection_message.rs @@ -0,0 +1,8 @@ +use serde::{Serialize, Deserialize}; + +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ToolConnectionMessage { + pub tool_type: String, + pub agent_tool_id: String, +} \ No newline at end of file diff --git a/openframe-agent-lib/src/models/tool_installation_message.rs b/openframe-agent-lib/src/models/tool_installation_message.rs new file mode 100644 index 000000000..cd511b822 --- /dev/null +++ b/openframe-agent-lib/src/models/tool_installation_message.rs @@ -0,0 +1,54 @@ +use serde::{Serialize, Deserialize}; +use super::download_configuration::DownloadConfiguration; + +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct ToolInstallationMessage { + pub tool_agent_id: String, + pub tool_id: String, + pub tool_type: String, + pub version: String, + pub reinstall: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub session_type: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub download_configurations: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub installation_command_args: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub uninstallation_command_args: Option>, + pub run_command_args: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub tool_agent_id_command_args: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub assets: Option>, +} + +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +pub enum SessionType { + #[serde(rename = "SERVICE")] + Service, + #[serde(rename = "CONSOLE")] + Console, + #[serde(rename = "USER")] + User, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct Asset { + pub id: String, + pub local_filename: String, + pub source: AssetSource, + pub path: Option, + #[serde(default)] + pub executable: bool, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub enum AssetSource { + #[serde(rename = "ARTIFACTORY")] + Artifactory, + #[serde(rename = "TOOL_API")] + ToolApi, +} \ No newline at end of file diff --git a/openframe-agent-lib/src/models/tool_installation_result.rs b/openframe-agent-lib/src/models/tool_installation_result.rs new file mode 100644 index 000000000..70de478c3 --- /dev/null +++ b/openframe-agent-lib/src/models/tool_installation_result.rs @@ -0,0 +1,6 @@ +use serde::{Serialize, Deserialize}; + +#[derive(Debug, Serialize, Deserialize)] +pub struct ToolInstallationResult { + pub tool_agent_id: String, +} \ No newline at end of file diff --git a/openframe-agent-lib/src/models/update_state.rs b/openframe-agent-lib/src/models/update_state.rs new file mode 100644 index 000000000..047f79502 --- /dev/null +++ b/openframe-agent-lib/src/models/update_state.rs @@ -0,0 +1,33 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum UpdatePhase { + Validating, + Downloading, + Extracting, + PreparingUpdater, + UpdaterLaunched, + Completed, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UpdateState { + pub target_version: String, + + /// Current phase + pub phase: UpdatePhase, +} + +impl UpdateState { + pub fn new(target_version: String) -> Self { + Self { + target_version, + phase: UpdatePhase::Validating, + } + } + + pub fn set_phase(&mut self, phase: UpdatePhase) { + self.phase = phase; + } +} diff --git a/openframe-agent-lib/src/monitoring/mod.rs b/openframe-agent-lib/src/monitoring/mod.rs new file mode 100644 index 000000000..a527b6368 --- /dev/null +++ b/openframe-agent-lib/src/monitoring/mod.rs @@ -0,0 +1 @@ +pub mod permissions; diff --git a/openframe-agent-lib/src/monitoring/permissions.rs b/openframe-agent-lib/src/monitoring/permissions.rs new file mode 100644 index 000000000..512107129 --- /dev/null +++ b/openframe-agent-lib/src/monitoring/permissions.rs @@ -0,0 +1,95 @@ +use crate::logging::metrics::MetricsStore; +use crate::platform::directories::DirectoryManager; +use std::collections::HashMap; +use std::sync::Arc; +use std::time::{Duration, SystemTime}; +use tokio::sync::RwLock; +use tokio::time::interval; +use tracing::{error, info}; + +pub struct PermissionMonitor { + directory_manager: Arc, + check_interval: Duration, + metrics: Arc>, +} + +impl PermissionMonitor { + pub fn new(directory_manager: Arc) -> Self { + Self { + directory_manager, + check_interval: Duration::from_secs(3600), // Check every hour by default + metrics: Arc::new(RwLock::new(MetricsStore::new())), + } + } + + pub fn with_interval(mut self, interval: Duration) -> Self { + self.check_interval = interval; + self + } + + pub async fn start_monitoring(self) { + info!( + "Starting permission monitoring with interval: {:?}", + self.check_interval + ); + let mut interval = interval(self.check_interval); + + loop { + interval.tick().await; + self.perform_check().await; + } + } + + async fn perform_check(&self) { + info!("Performing permission check"); + + // Update last check timestamp + if let Ok(mut metrics) = self.metrics.try_write() { + metrics.record_gauge( + "openframe_last_permission_check_timestamp", + SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .unwrap_or_default() + .as_secs() as f64, + HashMap::new(), + ); + } + + match self.directory_manager.perform_health_check() { + Ok(()) => { + info!("Permission check completed successfully"); + } + Err(e) => { + error!("Permission check failed: {:#}", e); + + // Increment error counter + if let Ok(mut metrics) = self.metrics.try_write() { + metrics.record_counter("openframe_permission_errors_total", 1, HashMap::new()); + } + + // Attempt to fix permissions + if let Err(fix_err) = self.directory_manager.fix_permissions() { + error!("Failed to fix permissions: {}", fix_err); + } else { + info!("Successfully fixed permissions"); + // Increment fix counter + if let Ok(mut metrics) = self.metrics.try_write() { + metrics.record_counter( + "openframe_permission_fixes_total", + 1, + HashMap::new(), + ); + } + } + } + } + } + + pub fn get_metrics(&self) -> Vec<(&str, f64)> { + vec![ + ("openframe_permission_errors_total", 0.0), + ("openframe_permission_fixes_total", 0.0), + ("openframe_last_permission_check_timestamp", 0.0), + ] + } +} diff --git a/openframe-agent-lib/src/platform/directories.rs b/openframe-agent-lib/src/platform/directories.rs new file mode 100644 index 000000000..8cdb5c7b5 --- /dev/null +++ b/openframe-agent-lib/src/platform/directories.rs @@ -0,0 +1,1048 @@ +/// Cross-platform directory management +/// +/// This module provides a unified interface for managing platform-specific directories +/// across Windows, macOS, and Linux. It handles: +/// +/// - Application support directories +/// - Log directories +/// - Permissions and ownership +/// - Directory health checks +/// - Platform-specific path resolution +/// +/// The DirectoryManager struct provides a common API that hides platform-specific +/// implementation details. +use directories::BaseDirs; +use std::fs; +use std::io; +#[cfg(unix)] +use std::os::unix::fs::PermissionsExt; +use std::path::{Path, PathBuf}; +#[cfg(unix)] +use std::process::Command; +use tracing::info; + +use super::permissions::{PermissionError, Permissions}; + +#[derive(Debug)] +pub enum DirectoryError { + CreateFailed(PathBuf, io::Error), + PermissionDenied(PathBuf), + ValidationFailed(PathBuf, String), + FixFailed(PathBuf, String), + HomeDirectoryNotFound, +} + +impl std::fmt::Display for DirectoryError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + DirectoryError::CreateFailed(path, err) => { + write!(f, "Failed to create directory {}: {}", path.display(), err) + } + DirectoryError::PermissionDenied(path) => { + write!(f, "Permission denied for {}", path.display()) + } + DirectoryError::ValidationFailed(path, reason) => { + write!(f, "Validation failed for {}: {}", path.display(), reason) + } + DirectoryError::FixFailed(path, reason) => { + write!( + f, + "Failed to fix permissions for {}: {}", + path.display(), + reason + ) + } + DirectoryError::HomeDirectoryNotFound => { + write!(f, "Could not determine user's home directory") + } + } + } +} + +impl std::error::Error for DirectoryError {} + +impl From for DirectoryError { + fn from(err: PermissionError) -> Self { + match err { + PermissionError::Io(e) => DirectoryError::CreateFailed(PathBuf::new(), e), + PermissionError::InvalidMode(msg) => { + DirectoryError::ValidationFailed(PathBuf::new(), msg) + } + PermissionError::InvalidPath(msg) => { + DirectoryError::ValidationFailed(PathBuf::new(), msg) + } + PermissionError::AdminCheckFailed(msg) => { + DirectoryError::ValidationFailed(PathBuf::new(), msg) + } + PermissionError::ElevationRequired => DirectoryError::ValidationFailed( + PathBuf::new(), + "Elevation to admin/root required".to_string(), + ), + PermissionError::CommandFailed(code) => DirectoryError::ValidationFailed( + PathBuf::new(), + format!("Command failed with code: {}", code), + ), + } + } +} + +/// Returns the platform-specific app support directory path +pub fn get_app_support_directory() -> PathBuf { + #[cfg(target_os = "windows")] + { + let program_data = + std::env::var_os("ProgramData").expect("ProgramData environment variable not found"); + let mut path = PathBuf::from(program_data); + path.push("OpenFrame"); + path + } + + #[cfg(target_os = "macos")] + { + PathBuf::from("/Library/Application Support/OpenFrame") + } + + #[cfg(target_os = "linux")] + { + PathBuf::from("/var/lib/openframe") + } +} + +/// Returns the platform-specific logs directory path +pub fn get_logs_directory() -> PathBuf { + // First check for environment variable override + if let Ok(log_dir) = std::env::var("OPENFRAME_LOG_DIR") { + let path = PathBuf::from(log_dir); + + // Ensure the directory exists + if !path.exists() { + if let Err(e) = std::fs::create_dir_all(&path) { + // Log error but continue with the path + eprintln!( + "Failed to create custom log directory {}: {}", + path.display(), + e + ); + } + } + + return path; + } + + // If no override, use platform-specific defaults + #[cfg(target_os = "windows")] + { + let program_data = + std::env::var_os("ProgramData").expect("ProgramData environment variable not found"); + let mut path = PathBuf::from(program_data); + path.push("OpenFrame"); + path.push("logs"); + path + } + + #[cfg(target_os = "macos")] + { + PathBuf::from("/Library/Logs/OpenFrame") + } + + #[cfg(target_os = "linux")] + { + PathBuf::from("/var/log/openframe") + } +} + +/// Returns the platform-specific secured directory path (admin/root access only) +pub fn get_secured_directory() -> PathBuf { + #[cfg(target_os = "windows")] + { + let program_data = + std::env::var_os("ProgramData").expect("ProgramData environment variable not found"); + let mut path = PathBuf::from(program_data); + path.push("OpenFrame"); + path.push("secured"); + path + } + + #[cfg(target_os = "macos")] + { + PathBuf::from("/Library/Application Support/OpenFrame/secured") + } + + #[cfg(target_os = "linux")] + { + PathBuf::from("/var/lib/openframe/secured") + } +} + +/// Sets the correct platform-specific permissions on a directory +pub fn set_directory_permissions(path: &Path) -> io::Result<()> { + #[cfg(target_os = "windows")] + { + // On Windows, we rely on default permissions from the system + // When running as Administrator, directories inherit proper permissions automatically + info!( + "Windows directory created, using default system permissions: {}", + path.display() + ); + // No explicit permission setting needed + } + + #[cfg(target_os = "macos")] + { + // On macOS, we need root:admin ownership and 755 permissions + info!( + "Setting macOS directory permissions for: {}", + path.display() + ); + + // These commands will fail gracefully if not run as root + let _ = Command::new("chown") + .args(["-R", "root:admin", path.to_str().unwrap()]) + .status(); + let _ = Command::new("chmod") + .args(["-R", "755", path.to_str().unwrap()]) + .status(); + } + + #[cfg(target_os = "linux")] + { + // Set directory permissions to 755 (rwxr-xr-x) on Linux + info!( + "Setting Linux directory permissions for: {}", + path.display() + ); + + #[cfg(unix)] + { + let permissions = fs::Permissions::from_mode(0o755); + fs::set_permissions(path, permissions)?; + + // On Linux, we typically want root:root ownership + let _ = Command::new("chown") + .args(["-R", "root:root", path.to_str().unwrap()]) + .status(); + } + } + + Ok(()) +} + +/// Sets admin-only permissions on a secured directory +pub fn set_secured_directory_permissions(path: &Path) -> io::Result<()> { + #[cfg(target_os = "windows")] + { + // On Windows, we rely on default permissions from the system + // When running as Administrator, secured directories inherit proper admin-only permissions + info!( + "Windows secured directory created, using default system permissions: {}", + path.display() + ); + // No explicit permission setting needed + } + + #[cfg(target_os = "macos")] + { + info!( + "Setting macOS secured directory permissions for: {}", + path.display() + ); + + // Set ownership to root:wheel and 700 permissions (owner only) + let _ = Command::new("chown") + .args(["-R", "root:wheel", path.to_str().unwrap()]) + .status(); + let _ = Command::new("chmod") + .args(["-R", "700", path.to_str().unwrap()]) + .status(); + } + + #[cfg(target_os = "linux")] + { + info!( + "Setting Linux secured directory permissions for: {}", + path.display() + ); + + #[cfg(unix)] + { + // Set permissions to 700 (rwx------) - only owner can access + let permissions = fs::Permissions::from_mode(0o700); + fs::set_permissions(path, permissions)?; + + // Set ownership to root:root + let _ = Command::new("chown") + .args(["-R", "root:root", path.to_str().unwrap()]) + .status(); + } + } + + Ok(()) +} + +#[derive(Debug, Clone)] +pub struct DirectoryManager { + logs_dir: PathBuf, + app_support_dir: PathBuf, + secured_dir: PathBuf, + user_logs_dir: Option, // For per-user logs when needed +} + +impl Default for DirectoryManager { + fn default() -> Self { + Self { + logs_dir: get_logs_directory(), + app_support_dir: get_app_support_directory(), + secured_dir: get_secured_directory(), + user_logs_dir: None, + } + } +} + +impl DirectoryManager { + /// Creates a new DirectoryManager with default platform-specific paths + pub fn new() -> Self { + Self::default() + } + + /// Creates a new DirectoryManager with custom directories + pub fn with_custom_dirs( + logs_dir: PathBuf, + app_support_dir: PathBuf, + secured_dir: PathBuf, + ) -> Self { + Self { + logs_dir, + app_support_dir, + secured_dir, + user_logs_dir: None, + } + } + + /// Creates a new DirectoryManager with a user-specific logs directory + pub fn with_user_logs_dir() -> Self { + let system_logs_dir = get_logs_directory(); + let system_app_dir = get_app_support_directory(); + + // Set up user-specific logs directory based on platform + let user_logs = Self::get_user_logs_directory(); + + Self { + logs_dir: system_logs_dir, + app_support_dir: system_app_dir, + secured_dir: get_secured_directory(), + user_logs_dir: Some(user_logs), + } + } + + /// Creates a development mode DirectoryManager that only uses user directories + pub fn for_development() -> Self { + let user_logs = Self::get_user_logs_directory(); + + // In development mode, use user logs for everything to avoid permission issues + Self { + logs_dir: user_logs.clone(), + app_support_dir: user_logs.clone(), + secured_dir: user_logs.clone(), + user_logs_dir: Some(user_logs), + } + } + + /// Checks if this DirectoryManager is configured for development mode + fn is_development_mode(&self) -> bool { + // Development mode is detected when user_logs_dir is set and + // the logs_dir points to a user directory (not system directory) + if let Some(user_logs) = &self.user_logs_dir { + self.logs_dir == *user_logs + } else { + false + } + } + + /// Get the platform-specific user logs directory based on the platform + fn get_user_logs_directory() -> PathBuf { + // Cross-platform implementation for user-specific logs + #[cfg(target_os = "windows")] + { + if let Some(base_dirs) = BaseDirs::new() { + let mut path = base_dirs.data_local_dir().to_path_buf(); + path.push("OpenFrame"); + path.push("Logs"); + return path; + } + } + + #[cfg(target_os = "macos")] + { + if let Some(base_dirs) = BaseDirs::new() { + let mut path = base_dirs.home_dir().to_path_buf(); + path.push("Library"); + path.push("Logs"); + path.push("OpenFrame"); + return path; + } + } + + #[cfg(target_os = "linux")] + { + if let Some(base_dirs) = BaseDirs::new() { + let mut path = base_dirs.home_dir().to_path_buf(); + path.push(".local"); + path.push("share"); + path.push("openframe"); + path.push("logs"); + return path; + } + } + + // Fallback to temporary directory if we can't determine the home directory + let mut path = std::env::temp_dir(); + path.push("OpenFrame"); + path.push("Logs"); + path + } + + /// Runs a health check on all managed directories + pub fn perform_health_check(&self) -> Result<(), DirectoryError> { + info!("Performing directory health check"); + + // Create directories if they don't exist + self.ensure_directories()?; + + // Validate permissions + self.validate_permissions()?; + + info!("Directory health check completed successfully"); + Ok(()) + } + + /// Ensures all required directories exist with correct permissions + pub fn ensure_directories(&self) -> Result<(), DirectoryError> { + info!("Ensuring required directories exist..."); + + let dir_perms = Permissions::directory(); + + // Create and verify logs directory + self.create_directory_with_permissions(&self.logs_dir, &dir_perms)?; + + // Create and verify application support directory + self.create_directory_with_permissions(&self.app_support_dir, &dir_perms)?; + + // Create and verify secured directory with admin-only permissions + self.create_secured_directory(&self.secured_dir)?; + + // If user logs directory is set, create and verify it too + if let Some(user_logs) = &self.user_logs_dir { + self.create_directory_with_permissions(user_logs, &dir_perms)?; + } + + Ok(()) + } + + /// Creates a directory with specified permissions if it doesn't exist + fn create_directory_with_permissions( + &self, + path: &Path, + perms: &Permissions, + ) -> Result<(), DirectoryError> { + if !path.exists() { + info!("Creating directory: {}", path.display()); + + // Create parent directories if they don't exist + if let Some(parent) = path.parent() { + if !parent.exists() { + fs::create_dir_all(parent) + .map_err(|e| DirectoryError::CreateFailed(parent.to_path_buf(), e))?; + } + } + + fs::create_dir_all(path) + .map_err(|e| DirectoryError::CreateFailed(path.to_path_buf(), e))?; + + // Set platform-specific permissions + set_directory_permissions(path) + .map_err(|e| DirectoryError::FixFailed(path.to_path_buf(), e.to_string()))?; + } + + info!("Setting permissions for: {}", path.display()); + perms + .apply(path) + .map_err(|e| DirectoryError::FixFailed(path.to_path_buf(), e.to_string()))?; + + // Verify we can write to the directory + if !self.can_write_to_directory(path) { + return Err(DirectoryError::PermissionDenied(path.to_path_buf())); + } + + Ok(()) + } + + /// Creates a secured directory with admin-only permissions if it doesn't exist + fn create_secured_directory(&self, path: &Path) -> Result<(), DirectoryError> { + if !path.exists() { + info!("Creating secured directory: {}", path.display()); + + // Create parent directories if they don't exist + if let Some(parent) = path.parent() { + if !parent.exists() { + fs::create_dir_all(parent) + .map_err(|e| DirectoryError::CreateFailed(parent.to_path_buf(), e))?; + } + } + + fs::create_dir_all(path) + .map_err(|e| DirectoryError::CreateFailed(path.to_path_buf(), e))?; + + // In development mode, use regular permissions to avoid permission issues + if self.is_development_mode() { + info!( + "Development mode: using regular directory permissions for secured directory" + ); + set_directory_permissions(path) + .map_err(|e| DirectoryError::FixFailed(path.to_path_buf(), e.to_string()))?; + } else { + // Set admin-only permissions in production mode + set_secured_directory_permissions(path) + .map_err(|e| DirectoryError::FixFailed(path.to_path_buf(), e.to_string()))?; + } + } else if !self.is_development_mode() { + // Directory exists, ensure it has correct secured permissions (production mode only) + info!("Updating secured permissions for: {}", path.display()); + set_secured_directory_permissions(path) + .map_err(|e| DirectoryError::FixFailed(path.to_path_buf(), e.to_string()))?; + } + + Ok(()) + } + + /// Validates permissions on all directories + pub fn validate_permissions(&self) -> Result<(), DirectoryError> { + let dir_perms = Permissions::directory(); + + self.validate_directory_permissions(&self.logs_dir, &dir_perms)?; + self.validate_directory_permissions(&self.app_support_dir, &dir_perms)?; + + // Validate secured directory with special admin-only checks + self.validate_secured_directory_permissions(&self.secured_dir)?; + + // Validate user logs directory if set + if let Some(user_logs) = &self.user_logs_dir { + self.validate_directory_permissions(user_logs, &dir_perms)?; + } + + Ok(()) + } + + /// Validates permissions for a specific directory + fn validate_directory_permissions( + &self, + path: &Path, + expected_perms: &Permissions, + ) -> Result<(), DirectoryError> { + if !path.exists() { + return Err(DirectoryError::ValidationFailed( + path.to_path_buf(), + "Directory does not exist".to_string(), + )); + } + + // Verify permissions - skip on Windows as it uses a different permission model + #[cfg(unix)] + { + if let Ok(current) = Permissions::from_path(path) { + if current.mode != expected_perms.mode { + return Err(DirectoryError::ValidationFailed( + path.to_path_buf(), + format!( + "Expected mode {:o}, got {:o}", + expected_perms.mode, current.mode + ), + )); + } + } + } + + // Verify we can write to the directory + if !self.can_write_to_directory(path) { + return Err(DirectoryError::PermissionDenied(path.to_path_buf())); + } + + Ok(()) + } + + /// Validates permissions for the secured directory (admin-only access) + fn validate_secured_directory_permissions(&self, path: &Path) -> Result<(), DirectoryError> { + if !path.exists() { + return Err(DirectoryError::ValidationFailed( + path.to_path_buf(), + "Secured directory does not exist".to_string(), + )); + } + + // In development mode, use more relaxed validation + if self.is_development_mode() { + // Just verify we can write to the directory in development mode + if !self.can_write_to_directory(path) { + return Err(DirectoryError::PermissionDenied(path.to_path_buf())); + } + return Ok(()); + } + + // Check admin-only permissions on Unix systems (production mode only) + #[cfg(unix)] + { + if let Ok(current) = Permissions::from_path(path) { + // For secured directory, we expect 700 permissions (owner only) + if current.mode & 0o777 != 0o700 { + return Err(DirectoryError::ValidationFailed( + path.to_path_buf(), + format!( + "Expected mode 700 for secured directory, got {:o}", + current.mode & 0o777 + ), + )); + } + } + } + + // Verify only admin/root can write to the directory + if !self.is_admin_only_directory(path) { + return Err(DirectoryError::PermissionDenied(path.to_path_buf())); + } + + Ok(()) + } + + /// Checks if directory is accessible only by admin/root + fn is_admin_only_directory(&self, path: &Path) -> bool { + #[cfg(target_os = "windows")] + { + // On Windows, we rely on default system permissions + // If the directory exists and was created by Administrator, we trust it has proper permissions + path.exists() + } + + #[cfg(unix)] + { + // Check if the directory has 700 permissions and is owned by root + if let Ok(metadata) = fs::metadata(path) { + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt; + let mode = metadata.permissions().mode() & 0o777; + let uid = metadata.uid(); + + // Directory should have 700 permissions and be owned by root (uid 0) + mode == 0o700 && uid == 0 + } + #[cfg(not(unix))] + { + true + } + } else { + false + } + } + } + + /// Attempts to fix permissions on all directories + pub fn fix_permissions(&self) -> Result<(), DirectoryError> { + let dir_perms = Permissions::directory(); + + self.fix_directory_permissions(&self.logs_dir, &dir_perms)?; + self.fix_directory_permissions(&self.app_support_dir, &dir_perms)?; + + // Fix secured directory with admin-only permissions + self.fix_secured_directory_permissions(&self.secured_dir)?; + + // Fix user logs directory if set + if let Some(user_logs) = &self.user_logs_dir { + self.fix_directory_permissions(user_logs, &dir_perms)?; + } + + Ok(()) + } + + /// Attempts to fix permissions for a specific directory + fn fix_directory_permissions( + &self, + path: &Path, + perms: &Permissions, + ) -> Result<(), DirectoryError> { + if !path.exists() { + return Err(DirectoryError::ValidationFailed( + path.to_path_buf(), + "Directory does not exist".to_string(), + )); + } + + // Set platform-specific permissions + set_directory_permissions(path) + .map_err(|e| DirectoryError::FixFailed(path.to_path_buf(), e.to_string()))?; + + // Apply specific permission mode + perms + .apply(path) + .map_err(|e| DirectoryError::FixFailed(path.to_path_buf(), e.to_string()))?; + + Ok(()) + } + + /// Attempts to fix permissions for the secured directory (admin-only access) + fn fix_secured_directory_permissions(&self, path: &Path) -> Result<(), DirectoryError> { + if !path.exists() { + return Err(DirectoryError::ValidationFailed( + path.to_path_buf(), + "Secured directory does not exist".to_string(), + )); + } + + if self.is_development_mode() { + // In development mode, use regular permissions + set_directory_permissions(path) + .map_err(|e| DirectoryError::FixFailed(path.to_path_buf(), e.to_string()))?; + } else { + // Set admin-only permissions in production mode + set_secured_directory_permissions(path) + .map_err(|e| DirectoryError::FixFailed(path.to_path_buf(), e.to_string()))?; + } + + Ok(()) + } + + /// Determines if a user can write to the given directory + /// + /// This is a cross-platform implementation that works on Windows, macOS, and Linux. + fn can_write_to_directory(&self, path: &Path) -> bool { + // Try to create a temporary file in the directory + let temp_file = path.join(".write_test"); + let result = std::fs::OpenOptions::new() + .write(true) + .create(true) + .truncate(true) + .open(&temp_file); + + // Clean up the test file if it was created + if temp_file.exists() { + let _ = fs::remove_file(&temp_file); + } + + result.is_ok() + } + + /// Returns the logs directory path + pub fn logs_dir(&self) -> &Path { + &self.logs_dir + } + + /// Returns the application support directory path + pub fn app_support_dir(&self) -> &Path { + &self.app_support_dir + } + + /// Returns the secured directory path + pub fn secured_dir(&self) -> &Path { + &self.secured_dir + } + + /// Returns the user logs directory path if set, or falls back to system logs + pub fn user_logs_dir(&self) -> &Path { + if let Some(user_logs) = &self.user_logs_dir { + user_logs + } else { + &self.logs_dir + } + } + + /// Returns the path to the agent executable for a specific tool + pub fn get_agent_path(&self, tool_agent_id: &str) -> PathBuf { + let agent_name = if cfg!(target_os = "windows") { + "agent.exe" + } else { + "agent" + }; + + self.app_support_dir().join(tool_agent_id).join(agent_name) + } + + /// Returns the path to an asset file for a specific tool, adding .exe extension on Windows if executable + pub fn get_asset_path( + &self, + tool_agent_id: &str, + asset_filename: &str, + is_executable: bool, + ) -> PathBuf { + let asset_name = if cfg!(target_os = "windows") && is_executable { + format!("{}.exe", asset_filename) + } else { + asset_filename.to_string() + }; + + self.app_support_dir().join(tool_agent_id).join(asset_name) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + #[test] + fn test_directory_creation() { + let temp_dir = tempdir().unwrap(); + let logs_dir = temp_dir.path().join("logs"); + let app_dir = temp_dir.path().join("app"); + let secured_dir = temp_dir.path().join("secured"); + + let manager = + DirectoryManager::with_custom_dirs(logs_dir.clone(), app_dir.clone(), secured_dir); + + // Test directory creation + assert!(manager.ensure_directories().is_ok()); + assert!(logs_dir.exists()); + assert!(app_dir.exists()); + } + + #[test] + fn test_directory_permissions() { + let temp_dir = tempdir().unwrap(); + let logs_dir = temp_dir.path().join("logs"); + let app_dir = temp_dir.path().join("app"); + let secured_dir = temp_dir.path().join("secured"); + + let manager = + DirectoryManager::with_custom_dirs(logs_dir.clone(), app_dir.clone(), secured_dir); + + // Create directories first + assert!(manager.ensure_directories().is_ok()); + + // Test permission validation and fixing + assert!(manager.validate_permissions().is_ok()); + + // Test user directory creation + let user_manager = DirectoryManager::with_user_logs_dir(); + if let Some(user_logs) = &user_manager.user_logs_dir { + assert!( + user_logs.to_string_lossy().contains("OpenFrame") + || user_logs.to_string_lossy().contains("openframe") + ); + } + } + + #[test] + fn test_file_permissions() { + let temp_dir = tempdir().unwrap(); + let logs_dir = temp_dir.path().join("logs"); + let app_dir = temp_dir.path().join("app"); + let secured_dir = temp_dir.path().join("secured"); + + let manager = + DirectoryManager::with_custom_dirs(logs_dir.clone(), app_dir.clone(), secured_dir); + + // Create directories first + assert!(manager.ensure_directories().is_ok()); + + // Create a test file in the logs directory + let test_file = logs_dir.join("test.log"); + fs::write(&test_file, "test").unwrap(); + + // Apply file permissions + let file_perms = Permissions::file(); + assert!(file_perms.apply(&test_file).is_ok()); + + // Verify file permissions + #[cfg(unix)] + { + if unsafe { libc::geteuid() } == 0 { + // Only run this check if we're root, otherwise it will fail + let metadata = fs::metadata(&test_file).unwrap(); + assert_eq!(metadata.permissions().mode() & 0o777, 0o644); + } + } + } + + #[test] + fn test_error_handling() { + // Test with a non-existent directory + let non_existent = PathBuf::from("/non_existent_dir_for_test"); + + let manager = DirectoryManager::with_custom_dirs( + non_existent.clone(), + non_existent.clone(), + non_existent.clone(), + ); + + // This should fail on validate because we can't create the directory + if cfg!(unix) && unsafe { libc::geteuid() } != 0 { + // We expect this to fail if we're not root + assert!(manager.validate_permissions().is_err()); + } + } + + #[test] + fn test_user_logs_directory() { + let manager = DirectoryManager::with_user_logs_dir(); + + // Ensure the user logs directory exists + assert!(manager.user_logs_dir.is_some()); + + #[cfg(target_os = "macos")] + { + let user_logs = manager.user_logs_dir.clone().unwrap(); + assert!(user_logs + .to_string_lossy() + .contains("Library/Logs/OpenFrame")); + } + + #[cfg(target_os = "windows")] + { + let user_logs = manager.user_logs_dir.clone().unwrap(); + assert!(user_logs.to_string_lossy().contains("OpenFrame\\Logs")); + } + + #[cfg(target_os = "linux")] + { + let user_logs = manager.user_logs_dir.clone().unwrap(); + assert!(user_logs + .to_string_lossy() + .contains(".local/share/openframe/logs")); + } + } + + #[test] + fn test_health_check() { + let temp_dir = tempdir().unwrap(); + let logs_dir = temp_dir.path().join("logs"); + let app_dir = temp_dir.path().join("app"); + let secured_dir = temp_dir.path().join("secured"); + + let manager = + DirectoryManager::with_custom_dirs(logs_dir.clone(), app_dir.clone(), secured_dir); + + // Test health check + assert!(manager.perform_health_check().is_ok()); + assert!(logs_dir.exists()); + assert!(app_dir.exists()); + + // Intentionally corrupt permissions to test fixing + #[cfg(unix)] + { + if unsafe { libc::geteuid() } == 0 { + // Only run this check if we're root, otherwise it will fail + use std::os::unix::fs::PermissionsExt; + let bad_perms = fs::Permissions::from_mode(0o700); + fs::set_permissions(&logs_dir, bad_perms).unwrap(); + + // Health check should fix the permissions + assert!(manager.perform_health_check().is_ok()); + + // Verify permissions were fixed + let metadata = fs::metadata(&logs_dir).unwrap(); + assert_eq!(metadata.permissions().mode() & 0o777, 0o755); + } + } + } + + #[test] + fn test_write_permissions() { + let temp_dir = tempdir().unwrap(); + let logs_dir = temp_dir.path().join("logs"); + let app_dir = temp_dir.path().join("app"); + let secured_dir = temp_dir.path().join("secured"); + + let manager = + DirectoryManager::with_custom_dirs(logs_dir.clone(), app_dir.clone(), secured_dir); + + // Create directories first + assert!(manager.ensure_directories().is_ok()); + + // Test write permissions + assert!(manager.can_write_to_directory(&logs_dir)); + assert!(manager.can_write_to_directory(&app_dir)); + } + + #[test] + fn test_get_logs_directory() { + let logs_dir = get_logs_directory(); + + #[cfg(target_os = "macos")] + assert_eq!(logs_dir, PathBuf::from("/Library/Logs/OpenFrame")); + + #[cfg(target_os = "linux")] + assert_eq!(logs_dir, PathBuf::from("/var/log/openframe")); + + #[cfg(target_os = "windows")] + { + let program_data = std::env::var_os("ProgramData").unwrap_or_default(); + let expected = PathBuf::from(program_data).join("OpenFrame").join("logs"); + assert_eq!(logs_dir, expected); + } + } + + #[test] + fn test_get_app_support_directory() { + let app_dir = get_app_support_directory(); + + #[cfg(target_os = "macos")] + assert_eq!( + app_dir, + PathBuf::from("/Library/Application Support/OpenFrame") + ); + + #[cfg(target_os = "linux")] + assert_eq!(app_dir, PathBuf::from("/var/lib/openframe")); + + #[cfg(target_os = "windows")] + { + let program_data = std::env::var_os("ProgramData").unwrap_or_default(); + let expected = PathBuf::from(program_data).join("OpenFrame"); + assert_eq!(app_dir, expected); + } + } + + #[test] + fn test_secured_directory() { + let temp_dir = tempdir().unwrap(); + let logs_dir = temp_dir.path().join("logs"); + let app_dir = temp_dir.path().join("app"); + let secured_dir = temp_dir.path().join("secured"); + + let manager = DirectoryManager::with_custom_dirs(logs_dir, app_dir, secured_dir.clone()); + + // Test secured directory creation + assert!(manager.ensure_directories().is_ok()); + assert!(secured_dir.exists()); + + // Test secured directory permissions (only on Unix systems with root) + #[cfg(unix)] + { + if unsafe { libc::geteuid() } == 0 { + // Only run this check if we're root + let metadata = fs::metadata(&secured_dir).unwrap(); + assert_eq!(metadata.permissions().mode() & 0o777, 0o700); + } + } + } + + #[test] + fn test_get_secured_directory() { + let secured_dir = get_secured_directory(); + + #[cfg(target_os = "macos")] + assert_eq!( + secured_dir, + PathBuf::from("/Library/Application Support/OpenFrame/secured") + ); + + #[cfg(target_os = "linux")] + assert_eq!(secured_dir, PathBuf::from("/var/lib/openframe/secured")); + + #[cfg(target_os = "windows")] + { + let program_data = std::env::var_os("ProgramData").unwrap_or_default(); + let expected = PathBuf::from(program_data) + .join("OpenFrame") + .join("secured"); + assert_eq!(secured_dir, expected); + } + } +} diff --git a/openframe-agent-lib/src/platform/file_lock.rs b/openframe-agent-lib/src/platform/file_lock.rs new file mode 100644 index 000000000..49dd03e8f --- /dev/null +++ b/openframe-agent-lib/src/platform/file_lock.rs @@ -0,0 +1,181 @@ +//! Windows file lock detection using Restart Manager API. + +#[cfg(target_os = "windows")] +use std::ffi::OsStr; +#[cfg(target_os = "windows")] +use std::os::windows::ffi::OsStrExt; +#[cfg(target_os = "windows")] +use windows::core::{PCWSTR, PWSTR}; +#[cfg(target_os = "windows")] +use windows::Win32::Foundation::FILETIME; +#[cfg(target_os = "windows")] +use windows::Win32::System::RestartManager::{ + RmEndSession, RmGetList, RmRegisterResources, RmStartSession, RM_PROCESS_INFO, + RM_UNIQUE_PROCESS, +}; + +#[cfg(target_os = "windows")] +#[derive(Debug, Clone)] +pub struct LockingProcess { + pub pid: u32, + pub name: String, +} + +#[cfg(target_os = "windows")] +impl std::fmt::Display for LockingProcess { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{} (PID: {})", self.name, self.pid) + } +} + +/// Returns processes that have the file open. +#[cfg(target_os = "windows")] +pub fn get_locking_processes(file_path: &str) -> Result, String> { + unsafe { + let mut session_handle: u32 = 0; + let mut session_key = [0u16; 33]; // CCH_RM_SESSION_KEY + 1 + + RmStartSession(&mut session_handle, 0, PWSTR(session_key.as_mut_ptr())) + .map_err(|e| format!("RmStartSession failed: {:?}", e))?; + + // RAII guard for session cleanup + struct SessionGuard(u32); + impl Drop for SessionGuard { + fn drop(&mut self) { + unsafe { + let _ = RmEndSession(self.0); + } + } + } + let _guard = SessionGuard(session_handle); + + let wide_path: Vec = OsStr::new(file_path) + .encode_wide() + .chain(std::iter::once(0)) + .collect(); + let file_ptr = PCWSTR(wide_path.as_ptr()); + + RmRegisterResources(session_handle, Some(&[file_ptr]), None, None) + .map_err(|e| format!("RmRegisterResources failed: {:?}", e))?; + + let mut needed: u32 = 0; + let mut count: u32 = 0; + let mut reboot_reason: u32 = 0; + + // First call to get count (ERROR_MORE_DATA = 234 is expected) + let first_result = RmGetList( + session_handle, + &mut needed, + &mut count, + None, + &mut reboot_reason, + ); + + if let Err(ref e) = first_result { + if e.code().0 as u32 != 234 { + return Err(format!("RmGetList (count) failed: {:?}", e)); + } + } + + if needed == 0 { + return Ok(vec![]); + } + + let mut processes: Vec = vec![ + RM_PROCESS_INFO { + Process: RM_UNIQUE_PROCESS { + dwProcessId: 0, + ProcessStartTime: FILETIME { + dwLowDateTime: 0, + dwHighDateTime: 0, + }, + }, + strAppName: [0; 256], + strServiceShortName: [0; 64], + ApplicationType: Default::default(), + AppStatus: 0, + TSSessionId: 0, + bRestartable: Default::default(), + }; + needed as usize + ]; + count = needed; + + RmGetList( + session_handle, + &mut needed, + &mut count, + Some(processes.as_mut_ptr()), + &mut reboot_reason, + ) + .map_err(|e| format!("RmGetList (data) failed: {:?}", e))?; + + let locking: Vec = processes + .iter() + .take(count as usize) + .map(|p| { + let name = String::from_utf16_lossy(&p.strAppName) + .trim_end_matches('\0') + .to_string(); + LockingProcess { + pid: p.Process.dwProcessId, + name, + } + }) + .collect(); + + Ok(locking) + } +} + +#[cfg(target_os = "windows")] +pub fn format_locking_processes(processes: &[LockingProcess]) -> String { + if processes.is_empty() { + return String::from("No processes detected"); + } + processes + .iter() + .map(|p| format!("{} (PID: {})", p.name, p.pid)) + .collect::>() + .join(", ") +} + +#[cfg(target_os = "windows")] +pub fn is_file_in_use_error(error: &std::io::Error) -> bool { + error.raw_os_error() == Some(32) // ERROR_SHARING_VIOLATION +} + +/// Logs which processes are locking a file if error is "file in use". Returns true if it was. +#[cfg(target_os = "windows")] +pub fn log_file_lock_info(error: &std::io::Error, file_path: &str, operation: &str) -> bool { + use tracing::error; + + if !is_file_in_use_error(error) { + return false; + } + + match get_locking_processes(file_path) { + Ok(processes) if !processes.is_empty() => { + error!( + "Failed to {}: file '{}' is locked by: {}", + operation, + file_path, + format_locking_processes(&processes) + ); + } + Ok(_) => { + error!( + "Failed to {}: file '{}' is locked, but could not identify locking process", + operation, file_path + ); + } + Err(lock_err) => { + error!( + "Failed to {}: file '{}' is locked, failed to query locking processes: {}", + operation, file_path, lock_err + ); + } + } + + true +} diff --git a/openframe-agent-lib/src/platform/mod.rs b/openframe-agent-lib/src/platform/mod.rs new file mode 100644 index 000000000..68d999ba3 --- /dev/null +++ b/openframe-agent-lib/src/platform/mod.rs @@ -0,0 +1,20 @@ +pub mod directories; +pub mod file_lock; +pub mod permissions; +pub mod uninstall; +pub mod update_scripts; +pub mod updater_launcher; + +#[cfg(target_os = "windows")] +pub mod windows_cleanup; +#[cfg(target_os = "windows")] +pub mod powershell; + +// Re-export commonly used items +pub use directories::{DirectoryError, DirectoryManager}; +#[cfg(target_os = "windows")] +pub use file_lock::{format_locking_processes, get_locking_processes, is_file_in_use_error, log_file_lock_info, LockingProcess}; +pub use permissions::{Capability, PermissionError, PermissionUtils, Permissions}; +pub use uninstall::remove_directory_with_retry; +#[cfg(target_os = "windows")] +pub use powershell::get_powershell_path; diff --git a/openframe-agent-lib/src/platform/permissions.rs b/openframe-agent-lib/src/platform/permissions.rs new file mode 100644 index 000000000..49edd4417 --- /dev/null +++ b/openframe-agent-lib/src/platform/permissions.rs @@ -0,0 +1,628 @@ +use std::fs::{self}; +use std::io; +#[cfg(unix)] +use std::os::unix::fs::PermissionsExt; +// Windows std::fs::Permissions already has readonly flag setters; no extra trait needed +use std::path::Path; +use std::process::Command; +use tracing::{error, info}; + +#[cfg(unix)] +use libc; +#[cfg(target_os = "windows")] +use winapi::um::shellapi::ShellExecuteW; +#[cfg(target_os = "windows")] +use winapi::um::winuser::SW_NORMAL; + +use std::sync::atomic::{AtomicBool, Ordering}; + +/// Static flag to remember if we've already obtained admin privileges +static ADMIN_PRIVILEGES_GRANTED: AtomicBool = AtomicBool::new(false); + +/// Default GID for admin group on macOS +#[cfg(unix)] +const ADMIN_GID: u32 = 80; + +#[cfg(not(unix))] +const ROOT_UID: u32 = 0; +#[cfg(not(unix))] +const ADMIN_GID: u32 = 0; + +#[derive(Debug)] +pub enum PermissionError { + Io(io::Error), + InvalidMode(String), + InvalidPath(String), + AdminCheckFailed(String), + ElevationRequired, + CommandFailed(i32), +} + +impl std::fmt::Display for PermissionError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + PermissionError::Io(e) => write!(f, "IO error: {}", e), + PermissionError::InvalidMode(msg) => write!(f, "Invalid mode: {}", msg), + PermissionError::InvalidPath(msg) => write!(f, "Invalid path: {}", msg), + PermissionError::AdminCheckFailed(msg) => write!(f, "Admin check failed: {}", msg), + PermissionError::ElevationRequired => write!(f, "Elevation to admin/root required"), + PermissionError::CommandFailed(code) => write!(f, "Command failed with code: {}", code), + } + } +} + +impl std::error::Error for PermissionError {} + +impl From for PermissionError { + fn from(err: io::Error) -> Self { + PermissionError::Io(err) + } +} + +#[derive(Debug, Clone)] +pub struct Permissions { + pub mode: u32, +} + +impl Permissions { + /// Create standard directory permissions (755, root:admin) + pub fn directory() -> Self { + Self { mode: 0o755 } + } + + /// Create standard file permissions (644, root:admin) + pub fn file() -> Self { + Self { mode: 0o644 } + } + + /// Apply permissions to a path + pub fn apply(&self, path: &Path) -> Result<(), PermissionError> { + #[cfg(unix)] + { + let perms = fs::Permissions::from_mode(self.mode); + fs::set_permissions(path, perms).map_err(PermissionError::Io) + } + + #[cfg(not(unix))] + { + // For non-Unix platforms like Windows, we can't directly set numeric modes + // so we'll just ensure the file exists and is writable if needed + if self.mode & 0o200 != 0 && path.exists() { + let metadata = fs::metadata(path)?; + let mut perms = metadata.permissions(); + #[cfg(target_os = "windows")] + { + // Use cross-platform readonly flag instead of Windows-only bits + if perms.readonly() { + perms.set_readonly(false); + fs::set_permissions(path, perms)?; + } + } + } + Ok(()) + } + } + + /// Verify permissions on a path + pub fn verify(&self, path: &Path) -> Result { + let metadata = fs::metadata(path).map_err(PermissionError::Io)?; + + #[cfg(unix)] + { + Ok((metadata.permissions().mode() & 0o777) == self.mode) + } + + #[cfg(not(unix))] + { + // On Windows, we can check if the file is read-only if that's what we care about + #[cfg(target_os = "windows")] + { + let perms = metadata.permissions(); + let needs_write = self.mode & 0o200 != 0; + let is_readonly = perms.readonly(); + return Ok(!needs_write || !is_readonly); + } + + // Default implementation for other platforms + Ok(true) + } + } + + /// Get permissions from an existing path + pub fn from_path(path: &Path) -> Result { + let metadata = fs::metadata(path).map_err(PermissionError::Io)?; + + #[cfg(unix)] + { + Ok(Self { + mode: metadata.permissions().mode() & 0o777, + }) + } + + #[cfg(not(unix))] + { + // On non-Unix platforms, we'll return a default value based on readonly status + #[cfg(target_os = "windows")] + { + let is_readonly = metadata.permissions().readonly(); + if is_readonly { + Ok(Self { mode: 0o444 }) + } else { + Ok(Self { mode: 0o644 }) + } + } + + #[cfg(not(target_os = "windows"))] + { + Ok(Self { mode: 0o644 }) // Default read-write for non-Windows, non-Unix + } + } + } +} + +/// Permission utilities for checking and obtaining admin/root privileges +pub struct PermissionUtils; + +impl PermissionUtils { + /// Check if the current process is running with admin/root privileges + pub fn is_admin() -> bool { + // If we've already been granted admin privileges in this session, return true + if ADMIN_PRIVILEGES_GRANTED.load(Ordering::Relaxed) { + return true; + } + + #[cfg(unix)] + { + // On Unix systems, check if effective user ID is 0 (root) + unsafe { libc::geteuid() == 0 } + } + + #[cfg(target_os = "windows")] + { + // TODO: implement proper admin check on Windows; returning false for now + is_elevated::is_elevated() + } + + #[cfg(all(not(unix), not(target_os = "windows")))] + { + // Default implementation for unsupported platforms + false + } + } + + /// Check if admin privileges are required for an operation and request them if needed + pub fn ensure_admin() -> Result<(), PermissionError> { + // If we've already been granted admin privileges or are running as admin, return immediately + if ADMIN_PRIVILEGES_GRANTED.load(Ordering::Relaxed) || Self::is_admin() { + return Ok(()); + } + + info!("Operation requires elevated privileges"); + + #[cfg(target_os = "windows")] + { + // On Windows, we can use ShellExecute with "runas" verb to trigger UAC + // We'll attempt to run a simple command to get admin rights + use std::ffi::OsStr; + use std::iter::once; + use std::os::windows::ffi::OsStrExt; + + let cmd = "cmd.exe"; + let args = "/c echo Admin privileges obtained"; + + // Convert command to wide string + let wide_cmd: Vec = OsStr::new(cmd).encode_wide().chain(once(0)).collect(); + + // Convert args to a wide string + let wide_args: Vec = OsStr::new(args).encode_wide().chain(once(0)).collect(); + + // Create the runas verb as a wide string + let runas: Vec = OsStr::new("runas").encode_wide().chain(once(0)).collect(); + + let result = unsafe { + ShellExecuteW( + std::ptr::null_mut(), // hwnd + runas.as_ptr(), // lpOperation - "runas" verb for UAC elevation + wide_cmd.as_ptr(), // lpFile - the command + wide_args.as_ptr(), // lpParameters + std::ptr::null(), // lpDirectory + SW_NORMAL, // nShowCmd + ) + }; + + // ShellExecute returns a value greater than 32 if successful + if result as usize <= 32 { + error!("Failed to obtain admin privileges, error code: {:?}", result); + return Err(PermissionError::CommandFailed(result as i32)); + } + + // If we got here, we should have admin privileges + info!("Successfully obtained admin privileges on Windows"); + ADMIN_PRIVILEGES_GRANTED.store(true, Ordering::Relaxed); + Ok(()) + } + + #[cfg(target_os = "macos")] + { + // On macOS, use osascript to show a GUI prompt for admin privileges + info!("Requesting admin privileges on macOS"); + + // Create an AppleScript that will force the authentication dialog + // This implements a proper authentication prompt that explains what's happening + let apple_script = "do shell script \"echo 'Admin privileges obtained'\" with administrator privileges with prompt \"OpenFrame requires administrator privileges to continue\""; + + // Execute the AppleScript + let result = Command::new("osascript") + .arg("-e") + .arg(apple_script) + .status(); + + match result { + Ok(status) if status.success() => { + info!("Successfully obtained admin privileges"); + // Set the flag to remember we have admin privileges + ADMIN_PRIVILEGES_GRANTED.store(true, Ordering::Relaxed); + Ok(()) + } + Ok(status) => { + error!( + "Failed to obtain admin privileges, exit code: {}", + status.code().unwrap_or(-1) + ); + Err(PermissionError::CommandFailed(status.code().unwrap_or(-1))) + } + Err(e) => { + error!("Failed to execute osascript: {}", e); + Err(PermissionError::Io(e)) + } + } + } + + #[cfg(target_os = "linux")] + { + // On Linux, we can try pkexec or sudo to request admin privileges + info!("Requesting admin privileges on Linux"); + + // Try pkexec first (better UI experience) + let pkexec_result = Command::new("pkexec") + .arg("echo") + .arg("Admin privileges obtained") + .status(); + + match pkexec_result { + Ok(status) if status.success() => { + info!("Successfully obtained admin privileges with pkexec"); + ADMIN_PRIVILEGES_GRANTED.store(true, Ordering::Relaxed); + return Ok(()); + } + _ => { + // Try sudo as fallback + info!("pkexec failed, trying sudo"); + let sudo_result = Command::new("sudo") + .arg("echo") + .arg("Admin privileges obtained") + .status(); + + match sudo_result { + Ok(status) if status.success() => { + info!("Successfully obtained admin privileges with sudo"); + ADMIN_PRIVILEGES_GRANTED.store(true, Ordering::Relaxed); + Ok(()) + } + Ok(status) => { + error!( + "Failed to obtain admin privileges with sudo, exit code: {}", + status.code().unwrap_or(-1) + ); + Err(PermissionError::CommandFailed(status.code().unwrap_or(-1))) + } + Err(e) => { + error!("Failed to execute sudo: {}", e); + Err(PermissionError::Io(e)) + } + } + } + } + } + + #[cfg(all( + not(target_os = "windows"), + not(target_os = "macos"), + not(target_os = "linux") + ))] + { + // Default implementation for unsupported platforms + error!("This operation requires administrator privileges."); + Err(PermissionError::ElevationRequired) + } + } + + /// Try to run a command with elevated privileges + pub fn run_as_admin(command: &str, args: &[&str]) -> Result<(), PermissionError> { + // If we've already ensured admin privileges, we can just run the command directly + if ADMIN_PRIVILEGES_GRANTED.load(Ordering::Relaxed) { + return Self::run_command(command, args); + } + + // If already admin, no need to elevate + if Self::is_admin() { + return Self::run_command(command, args); + } + + info!( + "Attempting to run command with elevated privileges: {} {}", + command, + args.join(" ") + ); + + #[cfg(target_os = "windows")] + { + // First ensure we have admin privileges + Self::ensure_admin()?; + + // Now we can just run the command directly + Self::run_command(command, args) + } + + #[cfg(target_os = "linux")] + { + // First ensure we have admin privileges + Self::ensure_admin()?; + + // Now we can just run the command directly + Self::run_command(command, args) + } + + #[cfg(target_os = "macos")] + { + // First ensure we have admin privileges + Self::ensure_admin()?; + + // Now we can just run the command directly + Self::run_command(command, args) + } + + #[cfg(all( + not(target_os = "windows"), + not(target_os = "macos"), + not(target_os = "linux") + ))] + { + // Default implementation for unsupported platforms + Err(PermissionError::AdminCheckFailed( + "Platform not supported".to_string(), + )) + } + } + + /// Run a command without elevation + pub fn run_command(command: &str, args: &[&str]) -> Result<(), PermissionError> { + let output = Command::new(command).args(args).output(); + + match output { + Ok(output) => { + // Log the stdout and stderr + if !output.stdout.is_empty() { + let stdout = String::from_utf8_lossy(&output.stdout); + info!("Command stdout: {}", stdout); + } + if !output.stderr.is_empty() { + let stderr = String::from_utf8_lossy(&output.stderr); + error!("Command stderr: {}", stderr); + } + + if output.status.success() { + Ok(()) + } else { + Err(PermissionError::CommandFailed( + output.status.code().unwrap_or(-1), + )) + } + } + Err(e) => Err(PermissionError::Io(e)), + } + } + + /// Check if a process has capability to perform a specific operation + pub fn has_capability(capability: Capability) -> bool { + match capability { + Capability::ManageServices => Self::is_admin(), + Capability::WriteSystemDirectories => Self::is_admin(), + Capability::ReadSystemLogs => Self::can_read_system_logs(), + Capability::WriteSystemLogs => Self::is_admin(), + } + } + + /// Check if the process can read system logs + fn can_read_system_logs() -> bool { + #[cfg(unix)] + { + // On Unix, check if we're root or in the proper group + if unsafe { libc::geteuid() } == 0 { + return true; + } + + #[cfg(target_os = "macos")] + { + // On macOS, check if we're in the admin group + // This is a simplified check - in practice you might need more sophisticated group checking + let groups = Self::get_current_user_groups(); + groups.contains(&ADMIN_GID) + } + + #[cfg(target_os = "linux")] + { + // On Linux, check if we can access the system log directory + Path::new("/var/log") + .metadata() + .map(|m| { + // Check if 'other' has read permissions (or we're the owner/group) + let mode = m.permissions().mode(); + mode & 0o004 != 0 + || (mode & 0o400 != 0 && m.uid() == unsafe { libc::geteuid() }) + || (mode & 0o040 != 0 + && Self::get_current_user_groups().contains(&m.gid())) + }) + .unwrap_or(false) + } + + #[cfg(all(unix, not(target_os = "macos"), not(target_os = "linux")))] + { + false + } + } + + #[cfg(target_os = "windows")] + { + // On Windows, try to open the event log + // This is a simplified approach - in practice you might use Windows-specific APIs + Path::new("C:\\Windows\\System32\\winevt\\Logs").exists() && Self::is_admin() + } + + #[cfg(all(not(unix), not(target_os = "windows")))] + { + false + } + } + + #[cfg(unix)] + fn get_current_user_groups() -> Vec { + let mut groups = Vec::new(); + let mut ngroups: i32 = 16; // Start with space for 16 groups + let mut group_list: Vec = vec![0; ngroups as usize]; + + unsafe { + // First call to get the actual number of groups + libc::getgroups(ngroups, group_list.as_mut_ptr()); + + // Get the actual groups + ngroups = libc::getgroups(ngroups, group_list.as_mut_ptr()); + if ngroups > 0 { + group_list.truncate(ngroups as usize); + groups = group_list; + } + } + + groups + } +} + +/// Capabilities that a process might need +#[derive(Debug, Clone, Copy)] +pub enum Capability { + ManageServices, + WriteSystemDirectories, + ReadSystemLogs, + WriteSystemLogs, +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + #[test] + fn test_permissions_creation() { + let dir_perms = Permissions::directory(); + assert_eq!(dir_perms.mode, 0o755); + + let file_perms = Permissions::file(); + assert_eq!(file_perms.mode, 0o644); + } + + #[cfg(unix)] + #[test] + fn test_permissions_verification() { + if unsafe { libc::geteuid() } == 0 { + let temp = tempdir().unwrap(); + let test_path = temp.path().join("test_file"); + fs::write(&test_path, "test").unwrap(); + + let perms = Permissions::file(); + assert!(perms.apply(&test_path).is_ok()); + assert!(perms.verify(&test_path).unwrap()); + } + } + + #[test] + fn test_is_admin() { + // This just verifies the function runs without errors + let is_admin = PermissionUtils::is_admin(); + println!("Running with admin privileges: {}", is_admin); + } + + #[test] + fn test_has_capability() { + // Test all capabilities + for cap in &[ + Capability::ManageServices, + Capability::WriteSystemDirectories, + Capability::ReadSystemLogs, + Capability::WriteSystemLogs, + ] { + let has_cap = PermissionUtils::has_capability(*cap); + println!("Has capability {:?}: {}", cap, has_cap); + } + } + + #[test] + fn test_ensure_admin() { + // This should return Ok if already admin, or attempt to get privileges + let result = PermissionUtils::ensure_admin(); + + if PermissionUtils::is_admin() { + assert!(result.is_ok()); + } else { + // The function might return Ok if the user granted privileges via the prompt, + // or an error if they declined or if there was an issue with the prompt + println!("Result of ensure_admin when not admin: {:?}", result); + } + } + + #[test] + fn test_run_command() { + // Test running a simple command that should work on all platforms + // On Windows, use "cmd /c echo test" + // On Unix, use "echo test" + #[cfg(target_os = "windows")] + { + let result = PermissionUtils::run_command("cmd", &["/c", "echo", "test"]); + assert!(result.is_ok()); + } + + #[cfg(unix)] + { + let result = PermissionUtils::run_command("echo", &["test"]); + assert!(result.is_ok()); + } + } + + #[test] + fn test_cross_platform_permissions() { + // Create a temporary file and test platform-agnostic permissions + let temp = tempdir().unwrap(); + let test_path = temp.path().join("test_file"); + fs::write(&test_path, "test").unwrap(); + + // Test applying permissions + let perms = Permissions::file(); + let result = perms.apply(&test_path); + assert!(result.is_ok()); + + // Test verifying permissions - should pass on all platforms + // even though the exact permission representation differs + let verify_result = perms.verify(&test_path); + assert!(verify_result.is_ok()); + + // Test retrieving permissions from a path + let retrieved_perms = Permissions::from_path(&test_path); + assert!(retrieved_perms.is_ok()); + } + + #[test] + fn test_can_read_system_logs() { + // Just verify the function runs without errors + let can_read = PermissionUtils::has_capability(Capability::ReadSystemLogs); + println!("Can read system logs: {}", can_read); + } +} diff --git a/openframe-agent-lib/src/platform/powershell.rs b/openframe-agent-lib/src/platform/powershell.rs new file mode 100644 index 000000000..ab9e3f171 --- /dev/null +++ b/openframe-agent-lib/src/platform/powershell.rs @@ -0,0 +1,46 @@ +use std::path::PathBuf; +use std::process::Command; + +/// Get PowerShell path - tries PATH first, then known locations +/// Returns ready-to-use path string +#[cfg(windows)] +pub fn get_powershell_path() -> Result { + // Try powershell.exe from PATH first + if let Ok(mut child) = Command::new("powershell.exe").arg("-?").spawn() { + let _ = child.wait(); + return Ok("powershell.exe".to_string()); + } + + // Fallback to known locations + find_powershell_path() + .map(|p| p.to_string_lossy().to_string()) + .ok_or("PowerShell not found") +} + +/// Find an existing PowerShell path on the system +#[cfg(windows)] +fn find_powershell_path() -> Option { + let system_root = std::env::var("SystemRoot").unwrap_or_else(|_| "C:\\Windows".to_string()); + let program_files = std::env::var("ProgramFiles").unwrap_or_else(|_| "C:\\Program Files".to_string()); + let program_files_x86 = std::env::var("ProgramFiles(x86)").unwrap_or_else(|_| "C:\\Program Files (x86)".to_string()); + + let paths = [ + // Windows PowerShell + format!("{}\\System32\\WindowsPowerShell\\v1.0\\powershell.exe", system_root), + format!("{}\\SysWOW64\\WindowsPowerShell\\v1.0\\powershell.exe", system_root), + "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe".to_string(), + "C:\\Windows\\SysWOW64\\WindowsPowerShell\\v1.0\\powershell.exe".to_string(), + // PowerShell 7 + format!("{}\\PowerShell\\7\\pwsh.exe", program_files), + format!("{}\\PowerShell\\7\\pwsh.exe", program_files_x86), + ]; + + for path in paths { + let p = PathBuf::from(&path); + if p.exists() { + return Some(p); + } + } + + None +} diff --git a/openframe-agent-lib/src/platform/uninstall.rs b/openframe-agent-lib/src/platform/uninstall.rs new file mode 100644 index 000000000..ac389ba70 --- /dev/null +++ b/openframe-agent-lib/src/platform/uninstall.rs @@ -0,0 +1,452 @@ +use anyhow::{Context, Result}; +use std::path::Path; +use tracing::{info, warn}; + +use crate::platform::DirectoryManager; +use crate::service_adapter::{CrossPlatformServiceManager, ServiceConfig}; +use crate::services::{ + InitialConfigurationService, InstalledToolsService, ToolCommandParamsResolver, ToolKillService, + ToolUninstallService, +}; + +const SERVICE_NAME: &str = "client"; +const DISPLAY_NAME: &str = "OpenFrame Client Service"; +const DESCRIPTION: &str = "OpenFrame client service for remote management and monitoring"; + +/// Remove a directory with retry logic for locked files +pub async fn remove_directory_with_retry(path: &Path, max_retries: u32) -> Result<()> { + if !path.exists() { + info!("Directory does not exist: {}", path.display()); + return Ok(()); + } + + for attempt in 1..=max_retries { + if !path.exists() { + info!("Directory no longer exists: {}", path.display()); + return Ok(()); + } + + if attempt == max_retries - 1 { + info!( + "Attempting to unlock files in directory: {}", + path.display() + ); + if let Err(e) = unlock_directory_files(path).await { + warn!("Failed to unlock files: {}", e); + } + } + + match std::fs::remove_dir_all(path) { + Ok(_) => { + info!("Successfully removed directory: {}", path.display()); + return Ok(()); + } + Err(e) => { + if attempt < max_retries { + let wait_secs = std::cmp::min(2_u64.pow(attempt - 1), 8); // Exponential backoff, max 8 seconds + warn!( + "Failed to remove directory {} (attempt {}/{}): {}. Retrying in {} seconds...", + path.display(), + attempt, + max_retries, + e, + wait_secs + ); + tokio::time::sleep(std::time::Duration::from_secs(wait_secs)).await; + } else { + // Last attempt - try force deletion with system commands + warn!( + "Standard removal failed after {} attempts for {}. Attempting force deletion...", + max_retries, + path.display() + ); + + match force_remove_directory(path).await { + Ok(_) => { + info!("Successfully force-removed directory: {}", path.display()); + return Ok(()); + } + Err(force_err) => { + return Err(anyhow::anyhow!( + "Failed to remove directory {} after {} attempts. Last error: {}. Force removal error: {}", + path.display(), + max_retries, + e, + force_err + )); + } + } + } + } + } + } + + Ok(()) +} + +/// Attempt to unlock files in a directory by removing read-only attributes +#[cfg(target_os = "windows")] +async fn unlock_directory_files(path: &Path) -> Result<()> { + use tokio::process::Command; + + // Use attrib command to remove read-only, system, and hidden attributes recursively + let output = Command::new("attrib") + .args(&["-R", "-S", "-H", "/S", "/D"]) + .arg(path) + .output() + .await + .context("Failed to execute attrib command")?; + + if output.status.success() { + info!("Successfully unlocked files in: {}", path.display()); + Ok(()) + } else { + let stderr = String::from_utf8_lossy(&output.stderr); + Err(anyhow::anyhow!("attrib command failed: {}", stderr)) + } +} + +#[cfg(not(target_os = "windows"))] +async fn unlock_directory_files(path: &Path) -> Result<()> { + use tokio::process::Command; + + // Use chmod to make everything writable + let output = Command::new("chmod") + .args(["-R", "777"]) + .arg(path) + .output() + .await + .context("Failed to execute chmod command")?; + + if output.status.success() { + info!("Successfully unlocked files in: {}", path.display()); + Ok(()) + } else { + let stderr = String::from_utf8_lossy(&output.stderr); + Err(anyhow::anyhow!("chmod command failed: {}", stderr)) + } +} + +/// Force remove a directory using system commands +#[cfg(target_os = "windows")] +async fn force_remove_directory(path: &Path) -> Result<()> { + use tokio::process::Command; + + info!( + "Attempting force removal using Windows rd command for: {}", + path.display() + ); + + // First, try to take ownership and grant permissions + let takeown_output = Command::new("takeown") + .args(&["/F", &path.to_string_lossy(), "/R", "/D", "Y"]) + .output() + .await; + + if let Ok(output) = takeown_output { + if output.status.success() { + info!("Successfully took ownership of: {}", path.display()); + } else { + warn!("Failed to take ownership, continuing anyway..."); + } + } + + // Grant full permissions + let icacls_output = Command::new("icacls") + .args(&[ + &path.to_string_lossy(), + "/grant", + "Everyone:F", + "/T", + "/C", + "/Q", + ]) + .output() + .await; + + if let Ok(output) = icacls_output { + if output.status.success() { + info!("Successfully granted permissions for: {}", path.display()); + } else { + warn!("Failed to grant permissions, continuing anyway..."); + } + } + + // Wait for permissions to take effect + tokio::time::sleep(std::time::Duration::from_millis(500)).await; + + // Use rd (rmdir) command with force and recursive flags + let output = Command::new("cmd") + .args(&["/C", "rd", "/S", "/Q"]) + .arg(path) + .output() + .await + .context("Failed to execute rd command")?; + + // Verify directory was removed + tokio::time::sleep(std::time::Duration::from_millis(500)).await; + + if !path.exists() { + info!("Force removal successful: {}", path.display()); + Ok(()) + } else if output.status.success() { + // Command succeeded but directory still exists - might be a timing issue + warn!("rd command succeeded but directory still exists, waiting and rechecking..."); + tokio::time::sleep(std::time::Duration::from_secs(2)).await; + + if !path.exists() { + info!("Directory removed after delay: {}", path.display()); + Ok(()) + } else { + Err(anyhow::anyhow!( + "Directory still exists after force removal" + )) + } + } else { + let stderr = String::from_utf8_lossy(&output.stderr); + let stdout = String::from_utf8_lossy(&output.stdout); + Err(anyhow::anyhow!( + "rd command failed\nstdout: {}\nstderr: {}", + stdout, + stderr + )) + } +} + +#[cfg(not(target_os = "windows"))] +async fn force_remove_directory(path: &Path) -> Result<()> { + use tokio::process::Command; + + info!( + "Attempting force removal using rm command for: {}", + path.display() + ); + + // Use rm -rf for force removal + let output = Command::new("rm") + .args(["-rf"]) + .arg(path) + .output() + .await + .context("Failed to execute rm command")?; + + // Verify directory was removed + tokio::time::sleep(std::time::Duration::from_millis(500)).await; + + if !path.exists() { + info!("Force removal successful: {}", path.display()); + Ok(()) + } else if output.status.success() { + // Command succeeded but directory still exists - might be a timing issue + warn!("rm command succeeded but directory still exists, waiting and rechecking..."); + tokio::time::sleep(std::time::Duration::from_secs(2)).await; + + if !path.exists() { + info!("Directory removed after delay: {}", path.display()); + Ok(()) + } else { + Err(anyhow::anyhow!( + "Directory still exists after force removal" + )) + } + } else { + let stderr = String::from_utf8_lossy(&output.stderr); + Err(anyhow::anyhow!("rm command failed: {}", stderr)) + } +} + +/// Uninstall all integrated tools +pub async fn uninstall_integrated_tools(dir_manager: &DirectoryManager) -> Result<()> { + // Initialize services needed for tool uninstallation + let installed_tools_service = InstalledToolsService::new(dir_manager.clone()) + .context("Failed to initialize InstalledToolsService")?; + + let initial_config_service = InitialConfigurationService::new(dir_manager.clone()) + .context("Failed to initialize InitialConfigurationService")?; + + let command_params_resolver = + ToolCommandParamsResolver::new(dir_manager.clone(), initial_config_service); + + let tool_kill_service = ToolKillService::new(); + + let tool_uninstall_service = ToolUninstallService::new( + installed_tools_service, + command_params_resolver, + tool_kill_service, + dir_manager.clone(), + ); + + // Run tool uninstallation + tool_uninstall_service + .uninstall_all() + .await + .context("Failed to uninstall integrated tools")?; + + Ok(()) +} + +/// Windows-specific uninstall implementation +#[cfg(target_os = "windows")] +pub async fn uninstall_windows(dir_manager: &DirectoryManager, install_path: &Path) -> Result<()> { + info!("========================================"); + info!("OpenFrame Uninstallation"); + info!("========================================"); + info!(""); + + info!("Step 1: Stopping and uninstalling Windows service..."); + let exec_path = std::env::current_exe().context("Failed to get current executable path")?; + let config = ServiceConfig { + name: SERVICE_NAME.to_string(), + display_name: DISPLAY_NAME.to_string(), + description: DESCRIPTION.to_string(), + exec_path, + ..ServiceConfig::default() + }; + let service = CrossPlatformServiceManager::with_config(config); + + match service.uninstall() { + Ok(_) => info!("Service uninstalled successfully"), + Err(e) => warn!("Service uninstall warning: {} (may not be installed)", e), + } + + info!("Step 2: Gracefully uninstalling integrated tools..."); + match uninstall_integrated_tools(dir_manager).await { + Ok(_) => info!("Tools uninstalled successfully"), + Err(e) => warn!("Tools uninstall warning: {} (continuing with cleanup)", e), + } + + info!("Step 3: Cleaning up directories and files..."); + + if dir_manager.logs_dir().exists() && dir_manager.logs_dir() != dir_manager.app_support_dir() { + info!( + "Cleaning up logs directory: {}", + dir_manager.logs_dir().display() + ); + if let Err(e) = remove_directory_with_retry(dir_manager.logs_dir(), 5).await { + warn!("Failed to remove logs directory: {}", e); + } + } + + if dir_manager.app_support_dir().exists() { + info!( + "Cleaning up app support directory: {}", + dir_manager.app_support_dir().display() + ); + if let Err(e) = remove_directory_with_retry(dir_manager.app_support_dir(), 5).await { + warn!("Failed to remove app support directory: {}", e); + } + } + + // Launch cleanup script to remove binary after process exit + if install_path.exists() { + info!("Launching binary cleanup script..."); + + let install_path_buf = install_path.to_path_buf(); + let bin_dir = install_path.parent().map(|p| p.to_path_buf()); + + use crate::platform::windows_cleanup::execute_binary_cleanup_script; + + match execute_binary_cleanup_script(&install_path_buf, bin_dir.as_ref()) { + Ok(_) => info!("Binary cleanup script launched successfully"), + Err(e) => warn!("Failed to launch binary cleanup script: {}", e), + } + } + + info!(""); + info!("========================================"); + info!("OpenFrame service uninstalled successfully"); + info!("========================================"); + + Ok(()) +} + +/// macOS-specific uninstall implementation +#[cfg(target_os = "macos")] +pub async fn uninstall_macos(dir_manager: &DirectoryManager, install_path: &Path) -> Result<()> { + info!("========================================"); + info!("OpenFrame Uninstallation"); + info!("========================================"); + info!(""); + + info!("Step 1: Stopping and uninstalling macOS service..."); + let exec_path = std::env::current_exe().context("Failed to get current executable path")?; + let config = ServiceConfig { + name: SERVICE_NAME.to_string(), + display_name: DISPLAY_NAME.to_string(), + description: DESCRIPTION.to_string(), + exec_path, + ..ServiceConfig::default() + }; + let service = CrossPlatformServiceManager::with_config(config); + + match service.uninstall() { + Ok(_) => info!("Service uninstalled successfully"), + Err(e) => warn!("Service uninstall warning: {} (may not be installed)", e), + } + + info!("Step 2: Gracefully uninstalling integrated tools..."); + match uninstall_integrated_tools(dir_manager).await { + Ok(_) => info!("✓ Tools uninstalled successfully"), + Err(e) => warn!("Tools uninstall warning: {} (continuing with cleanup)", e), + } + + info!("Step 3: Cleaning up directories and files..."); + + // Clean up directories with retry logic + if dir_manager.logs_dir().exists() && dir_manager.logs_dir() != dir_manager.app_support_dir() { + info!( + "Cleaning up logs directory: {}", + dir_manager.logs_dir().display() + ); + if let Err(e) = remove_directory_with_retry(dir_manager.logs_dir(), 5).await { + warn!("Failed to remove logs directory: {}", e); + } + } + + if dir_manager.app_support_dir().exists() { + info!( + "Cleaning up app support directory: {}", + dir_manager.app_support_dir().display() + ); + if let Err(e) = remove_directory_with_retry(dir_manager.app_support_dir(), 5).await { + warn!("Failed to remove app support directory: {}", e); + } + } + + if install_path.exists() { + info!("Removing installed binary: {}", install_path.display()); + + let mut removed = false; + for attempt in 1..=3 { + match std::fs::remove_file(install_path) { + Ok(_) => { + info!("Successfully removed binary"); + removed = true; + break; + } + Err(e) => { + if attempt < 3 { + warn!( + "Failed to remove binary (attempt {}/3): {}. Retrying in 1 second...", + attempt, e + ); + tokio::time::sleep(std::time::Duration::from_secs(1)).await; + } else { + warn!("Failed to remove binary after 3 attempts: {}", e); + } + } + } + } + + if !removed { + warn!("Binary could not be removed, may require manual cleanup"); + } + } + + info!(""); + info!("========================================"); + info!("OpenFrame service uninstalled successfully"); + info!("========================================"); + + Ok(()) +} diff --git a/openframe-agent-lib/src/platform/update_scripts/macos.rs b/openframe-agent-lib/src/platform/update_scripts/macos.rs new file mode 100644 index 000000000..89c5382fb --- /dev/null +++ b/openframe-agent-lib/src/platform/update_scripts/macos.rs @@ -0,0 +1,139 @@ +//! macOS bash update script for self-update functionality + +pub const UPDATER_PLIST_TEMPLATE: &str = r#" + + + + Label + com.openframe.updater + ProgramArguments + + {SCRIPT_PATH} + {BINARY_PATH} + {SERVICE_LABEL} + {TARGET_EXE} + {UPDATE_STATE_PATH} + + RunAtLoad + + StandardOutPath + /tmp/openframe-update-debug.log + StandardErrorPath + /tmp/openframe-update-debug.log + +"#; + +pub const UPDATE_SCRIPT_MACOS: &str = r#"#!/bin/bash + +BINARY_PATH="$1" +SERVICE_LABEL="$2" +TARGET_EXE="$3" +UPDATE_STATE_PATH="$4" + +BACKUP_PATH="" + +cleanup() { + if [ -f "$BINARY_PATH" ]; then + rm -f "$BINARY_PATH" 2>/dev/null + fi + # Also cleanup the updater plist + UPDATER_PLIST="/tmp/com.openframe.updater.plist" + if [ -f "$UPDATER_PLIST" ]; then + launchctl remove "com.openframe.updater" 2>/dev/null + rm -f "$UPDATER_PLIST" 2>/dev/null + fi +} + +rollback() { + if [ -n "$BACKUP_PATH" ] && [ -f "$BACKUP_PATH" ]; then + cp "$BACKUP_PATH" "$TARGET_EXE" 2>/dev/null + chmod 755 "$TARGET_EXE" 2>/dev/null + launchctl load "/Library/LaunchDaemons/${SERVICE_LABEL}.plist" 2>/dev/null + fi +} + +# Validate inputs +if [ ! -f "$BINARY_PATH" ]; then + exit 1 +fi + +if [ ! -f "$TARGET_EXE" ]; then + exit 1 +fi + +BINARY_SIZE=$(stat -f%z "$BINARY_PATH" 2>/dev/null || stat -c%s "$BINARY_PATH" 2>/dev/null) +if [ "$BINARY_SIZE" -lt 102400 ]; then + exit 1 +fi + +PLIST_PATH="/Library/LaunchDaemons/${SERVICE_LABEL}.plist" +if [ ! -f "$PLIST_PATH" ]; then + exit 1 +fi + +# Stop the service +if launchctl list "$SERVICE_LABEL" >/dev/null 2>&1; then + launchctl unload "$PLIST_PATH" 2>/dev/null +fi + +# Wait for service to fully stop +TIMEOUT=30 +ELAPSED=0 +while launchctl list "$SERVICE_LABEL" >/dev/null 2>&1 && [ $ELAPSED -lt $TIMEOUT ]; do + sleep 1 + ELAPSED=$((ELAPSED + 1)) +done + +if [ $ELAPSED -ge $TIMEOUT ]; then + launchctl load "$PLIST_PATH" 2>/dev/null + exit 1 +fi + +sleep 2 + +# Create backup +BACKUP_PATH="${TARGET_EXE}.backup.$(date +%Y%m%d%H%M%S)" +if ! cp "$TARGET_EXE" "$BACKUP_PATH"; then + launchctl load "$PLIST_PATH" 2>/dev/null + exit 1 +fi + +# Replace binary +if ! cp "$BINARY_PATH" "$TARGET_EXE"; then + rollback + cleanup + exit 1 +fi + +# Set executable permissions +if ! chmod 755 "$TARGET_EXE"; then + rollback + cleanup + exit 1 +fi + +# Mark update as completed +if [ -n "$UPDATE_STATE_PATH" ] && [ -f "$UPDATE_STATE_PATH" ]; then + sed -i '' 's/"phase"[[:space:]]*:[[:space:]]*"[^"]*"/"phase": "completed"/' "$UPDATE_STATE_PATH" 2>/dev/null +fi + +# Start service +if ! launchctl load "$PLIST_PATH"; then + rollback + cleanup + exit 1 +fi + +# Verify service started +sleep 3 +if ! launchctl list "$SERVICE_LABEL" >/dev/null 2>&1; then + rollback + cleanup + exit 1 +fi + + +# Cleanup (removes temp binary and updater plist) +cleanup +exit 0 +"#; diff --git a/openframe-agent-lib/src/platform/update_scripts/mod.rs b/openframe-agent-lib/src/platform/update_scripts/mod.rs new file mode 100644 index 000000000..6329866e6 --- /dev/null +++ b/openframe-agent-lib/src/platform/update_scripts/mod.rs @@ -0,0 +1,11 @@ +#[cfg(target_os = "windows")] +pub mod windows; + +#[cfg(target_os = "macos")] +pub mod macos; + +#[cfg(target_os = "windows")] +pub use windows::UPDATE_SCRIPT_WINDOWS; + +#[cfg(target_os = "macos")] +pub use macos::{UPDATE_SCRIPT_MACOS, UPDATER_PLIST_TEMPLATE}; diff --git a/openframe-agent-lib/src/platform/update_scripts/windows.rs b/openframe-agent-lib/src/platform/update_scripts/windows.rs new file mode 100644 index 000000000..d2c46d90a --- /dev/null +++ b/openframe-agent-lib/src/platform/update_scripts/windows.rs @@ -0,0 +1,124 @@ +//! Windows PowerShell update script for self-update functionality + +pub const UPDATE_SCRIPT_WINDOWS: &str = r#" +param( + [string]$ArchivePath, + [string]$ServiceName, + [string]$TargetExe, + [string]$UpdateStatePath +) + +$ErrorActionPreference = 'Stop' + +$BackupPath = $null +$TempExtract = $null + +try { + # Validate inputs + if (-not (Test-Path $ArchivePath)) { + throw "Archive file not found: $ArchivePath" + } + if (-not (Test-Path $TargetExe)) { + throw "Target executable not found: $TargetExe" + } + + $archiveSize = (Get-Item $ArchivePath).Length + if ($archiveSize -lt 100KB) { + throw "Archive too small ($archiveSize bytes), likely corrupted" + } + + # Stop the service + $service = Get-Service -Name $ServiceName -ErrorAction SilentlyContinue + if (-not $service) { + throw "Service not found: $ServiceName" + } + + if ($service.Status -ne 'Stopped') { + Stop-Service -Name $ServiceName -Force -ErrorAction Stop + } + + # Wait for service to fully stop + $timeout = 30 + $elapsed = 0 + while ((Get-Service -Name $ServiceName).Status -ne 'Stopped' -and $elapsed -lt $timeout) { + Start-Sleep -Seconds 1 + $elapsed++ + } + + if ($elapsed -ge $timeout) { + throw "Service did not stop within $timeout seconds" + } + + Start-Sleep -Seconds 2 + + # Create backup + $BackupPath = "$TargetExe.backup.$(Get-Date -Format 'yyyyMMddHHmmss')" + Copy-Item -Path $TargetExe -Destination $BackupPath -Force -ErrorAction Stop + + # Extract archive + $TempExtract = Join-Path $env:TEMP "openframe-update-$(New-Guid)" + Expand-Archive -Path $ArchivePath -DestinationPath $TempExtract -Force -ErrorAction Stop + + # Find new executable + $NewExe = Get-ChildItem -Path $TempExtract -Filter "*.exe" -Recurse | Select-Object -First 1 + + if (-not $NewExe) { + throw "No executable found in archive" + } + + if ($NewExe.Length -lt 100KB) { + throw "Extracted executable too small, likely corrupted" + } + + # Replace binary + Copy-Item -Path $NewExe.FullName -Destination $TargetExe -Force -ErrorAction Stop + + # Mark update as completed + if ($UpdateStatePath -and (Test-Path $UpdateStatePath)) { + try { + $stateContent = Get-Content -Path $UpdateStatePath -Raw | ConvertFrom-Json + $stateContent.phase = "completed" + $stateContent | ConvertTo-Json -Depth 10 | Set-Content -Path $UpdateStatePath -Force + } + catch { + # Ignore state update errors + } + } + + # Start service + Start-Service -Name $ServiceName -ErrorAction Stop + + # Verify service started + Start-Sleep -Seconds 3 + $service = Get-Service -Name $ServiceName -ErrorAction Stop + + if ($service.Status -ne 'Running') { + throw "Service failed to start" + } + + # Cleanup + Remove-Item -Path $ArchivePath -Force -ErrorAction SilentlyContinue + Remove-Item -Path $TempExtract -Recurse -Force -ErrorAction SilentlyContinue + + exit 0 +} +catch { + # Attempt rollback if backup exists + if ($BackupPath -and (Test-Path $BackupPath)) { + try { + Copy-Item -Path $BackupPath -Destination $TargetExe -Force -ErrorAction Stop + Start-Service -Name $ServiceName -ErrorAction SilentlyContinue + } + catch { + # Rollback failed + } + } + + # Cleanup temp files even on failure + if ($TempExtract -and (Test-Path $TempExtract)) { + Remove-Item -Path $TempExtract -Recurse -Force -ErrorAction SilentlyContinue + } + + exit 1 +} +"#; diff --git a/openframe-agent-lib/src/platform/updater_launcher/linux.rs b/openframe-agent-lib/src/platform/updater_launcher/linux.rs new file mode 100644 index 000000000..df466a082 --- /dev/null +++ b/openframe-agent-lib/src/platform/updater_launcher/linux.rs @@ -0,0 +1,13 @@ +use anyhow::{Result, anyhow}; +use tracing::info; + +use super::UpdaterParams; + +/// Launch updater on Linux +/// TODO: Implement using systemd +pub async fn launch_updater(_params: UpdaterParams) -> Result<()> { + info!("Launching Linux shell updater"); + + // TODO: Implement Linux updater with systemd + Err(anyhow!("Linux updater not yet implemented. Use systemd service restart instead.")) +} diff --git a/openframe-agent-lib/src/platform/updater_launcher/macos.rs b/openframe-agent-lib/src/platform/updater_launcher/macos.rs new file mode 100644 index 000000000..7a2ae017d --- /dev/null +++ b/openframe-agent-lib/src/platform/updater_launcher/macos.rs @@ -0,0 +1,71 @@ +use anyhow::{Context, Result, anyhow}; +use std::process::Command; +use std::os::unix::fs::PermissionsExt; +use tracing::info; +use uuid::Uuid; + +use super::UpdaterParams; +use crate::platform::update_scripts::{UPDATE_SCRIPT_MACOS, UPDATER_PLIST_TEMPLATE}; + +/// Launch bash updater script on macOS +/// Creates a temporary launchd job to ensure the script survives service stop +pub async fn launch_updater(params: UpdaterParams) -> Result<()> { + info!("Launching macOS bash updater"); + + let script_path = std::env::temp_dir().join(format!( + "openframe-updater-{}.sh", + Uuid::new_v4() + )); + + tokio::fs::write(&script_path, UPDATE_SCRIPT_MACOS).await + .context("Failed to write bash script")?; + + // Make script executable + let mut perms = std::fs::metadata(&script_path)?.permissions(); + perms.set_mode(0o755); + std::fs::set_permissions(&script_path, perms) + .context("Failed to set script executable permissions")?; + + info!("Bash script saved to: {}", script_path.display()); + + info!("Launching updater with: binary={}, service={}, target={}, state={}", + params.binary_path.display(), params.service_name, params.target_exe.display(), params.update_state_path); + + // Create a temporary plist to run the update script as a one-shot launchd job + // This ensures the script survives when our service is stopped + let plist_path = std::env::temp_dir().join("com.openframe.updater.plist"); + + // Remove any leftover updater job from a previous failed update + let _ = Command::new("launchctl") + .arg("remove") + .arg("com.openframe.updater") + .output(); + + let plist_content = UPDATER_PLIST_TEMPLATE + .replace("{SCRIPT_PATH}", &script_path.to_string_lossy()) + .replace("{BINARY_PATH}", ¶ms.binary_path.to_string_lossy()) + .replace("{SERVICE_LABEL}", ¶ms.service_name) + .replace("{TARGET_EXE}", ¶ms.target_exe.to_string_lossy()) + .replace("{UPDATE_STATE_PATH}", ¶ms.update_state_path); + + std::fs::write(&plist_path, &plist_content) + .context("Failed to write updater plist")?; + + info!("Updater plist created at: {}", plist_path.display()); + + // Load the plist to start the updater job + let output = Command::new("launchctl") + .arg("load") + .arg(&plist_path) + .output() + .context("Failed to load updater plist")?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(anyhow!("Failed to load updater plist: {}", stderr)); + } + + info!("macOS bash updater launched via launchd"); + + Ok(()) +} diff --git a/openframe-agent-lib/src/platform/updater_launcher/mod.rs b/openframe-agent-lib/src/platform/updater_launcher/mod.rs new file mode 100644 index 000000000..5ebd03f4d --- /dev/null +++ b/openframe-agent-lib/src/platform/updater_launcher/mod.rs @@ -0,0 +1,24 @@ +use std::path::PathBuf; + +/// Parameters needed to launch the updater +pub struct UpdaterParams { + pub binary_path: PathBuf, + pub target_exe: PathBuf, + pub service_name: String, + pub update_state_path: String, +} + +#[cfg(windows)] +mod windows; +#[cfg(windows)] +pub use windows::launch_updater; + +#[cfg(target_os = "macos")] +mod macos; +#[cfg(target_os = "macos")] +pub use macos::launch_updater; + +#[cfg(target_os = "linux")] +mod linux; +#[cfg(target_os = "linux")] +pub use linux::launch_updater; diff --git a/openframe-agent-lib/src/platform/updater_launcher/windows.rs b/openframe-agent-lib/src/platform/updater_launcher/windows.rs new file mode 100644 index 000000000..7c9faf8c8 --- /dev/null +++ b/openframe-agent-lib/src/platform/updater_launcher/windows.rs @@ -0,0 +1,45 @@ +use anyhow::{Context, Result, anyhow}; +use std::process::Command; +use std::os::windows::process::CommandExt; +use tracing::info; +use uuid::Uuid; + +use super::UpdaterParams; +use crate::platform::get_powershell_path; +use crate::platform::update_scripts::UPDATE_SCRIPT_WINDOWS; + +/// Launch PowerShell updater script on Windows +/// Uses CREATE_NO_WINDOW flag to run detached from console +pub async fn launch_updater(params: UpdaterParams) -> Result<()> { + info!("Launching Windows PowerShell updater"); + + // Save PowerShell script to temp file + let script_path = std::env::temp_dir().join(format!( + "openframe-updater-{}.ps1", + Uuid::new_v4() + )); + + tokio::fs::write(&script_path, UPDATE_SCRIPT_WINDOWS).await + .context("Failed to write PowerShell script")?; + + info!("PowerShell script saved to: {}", script_path.display()); + + let ps_path = get_powershell_path().map_err(|e| anyhow!(e))?; + info!("Using PowerShell: {}", ps_path); + + let child = Command::new(&ps_path) + .arg("-ExecutionPolicy").arg("Bypass") + .arg("-NoProfile") + .arg("-File").arg(&script_path) + .arg("-ArchivePath").arg(¶ms.binary_path) + .arg("-ServiceName").arg(¶ms.service_name) + .arg("-TargetExe").arg(¶ms.target_exe) + .arg("-UpdateStatePath").arg(¶ms.update_state_path) + .creation_flags(0x08000000) // CREATE_NO_WINDOW + .spawn() + .context("Failed to spawn PowerShell updater")?; + + info!("PowerShell updater launched (PID: {})", child.id()); + + Ok(()) +} diff --git a/openframe-agent-lib/src/platform/windows_cleanup.rs b/openframe-agent-lib/src/platform/windows_cleanup.rs new file mode 100644 index 000000000..cca7f9c09 --- /dev/null +++ b/openframe-agent-lib/src/platform/windows_cleanup.rs @@ -0,0 +1,273 @@ +use anyhow::{anyhow, Context, Result}; +use std::path::PathBuf; +use tracing::info; + +#[cfg(windows)] +use super::get_powershell_path; + +/// Generate a PowerShell script to cleanup the OpenFrame binary after process exit +/// +/// This script will: +/// 1. Wait for the current process to exit +/// 2. Force delete the binary file +/// 3. Remove empty parent directories (bin, then OpenFrame) +/// 4. Remove from system PATH +pub fn generate_binary_cleanup_script( + install_path: &PathBuf, + current_pid: u32, + bin_dir: Option<&PathBuf>, +) -> String { + let install_path_str = install_path.to_string_lossy().replace("\\", "\\\\"); + let bin_dir_str = bin_dir + .map(|p| p.to_string_lossy().replace("\\", "\\\\")) + .unwrap_or_default(); + + format!( + r#" +# OpenFrame Binary Cleanup Script +# This script cleans up the OpenFrame binary after the main process exits + +$ErrorActionPreference = "SilentlyContinue" +$ProcessId = {current_pid} +$BinaryPath = "{install_path_str}" +$BinDir = "{bin_dir_str}" + +# Wait for main process to exit (max 30 seconds with verification) +$waited = 0 +$maxWaitSeconds = 30 + +while ($waited -lt $maxWaitSeconds) {{ + try {{ + $process = Get-Process -Id $ProcessId -ErrorAction SilentlyContinue + if ($null -eq $process) {{ + break + }} + Start-Sleep -Milliseconds 500 + $waited += 0.5 + }} catch {{ + break + }} +}} + +# Verify process is really gone +if ($waited -ge $maxWaitSeconds) {{ + try {{ + Stop-Process -Id $ProcessId -Force -ErrorAction SilentlyContinue + Start-Sleep -Seconds 2 + }} catch {{ + # Process likely already gone + }} +}} + +# Final verification +try {{ + $process = Get-Process -Id $ProcessId -ErrorAction SilentlyContinue + if ($null -ne $process) {{ + exit 1 + }} +}} catch {{ + # Process is gone, which is what we want +}} + +# Force delete the binary with retry logic +if (Test-Path $BinaryPath) {{ + $removed = $false + $maxRetries = 5 + + for ($attempt = 1; $attempt -le $maxRetries; $attempt++) {{ + try {{ + Remove-Item -Path $BinaryPath -Force -ErrorAction Stop + $removed = $true + break + }} catch {{ + if ($attempt -lt $maxRetries) {{ + $waitSeconds = [math]::Min([math]::Pow(2, $attempt - 1), 8) + Start-Sleep -Seconds $waitSeconds + }} + }} + }} + + # If standard removal failed, try with takeown and icacls + if (-not $removed -and (Test-Path $BinaryPath)) {{ + try {{ + takeown /F $BinaryPath /A 2>&1 | Out-Null + icacls $BinaryPath /grant Everyone:F /T /C /Q 2>&1 | Out-Null + Start-Sleep -Milliseconds 500 + Remove-Item -Path $BinaryPath -Force -ErrorAction Stop + $removed = $true + }} catch {{ + # Silent fail + }} + }} +}} + +# Remove empty bin directory with retry +if (($BinDir -ne "") -and (Test-Path $BinDir)) {{ + try {{ + $items = Get-ChildItem $BinDir -ErrorAction SilentlyContinue + if ($items.Count -eq 0) {{ + $binRemoved = $false + + for ($attempt = 1; $attempt -le 3; $attempt++) {{ + try {{ + Remove-Item -Path $BinDir -Force -ErrorAction Stop + $binRemoved = $true + break + }} catch {{ + if ($attempt -lt 3) {{ + Start-Sleep -Seconds 1 + }} + }} + }} + + # Try to remove OpenFrame parent directory if also empty + if ($binRemoved) {{ + $parentDir = Split-Path -Parent $BinDir + if (Test-Path $parentDir) {{ + $items = Get-ChildItem $parentDir -ErrorAction SilentlyContinue + if ($items.Count -eq 0) {{ + for ($attempt = 1; $attempt -le 3; $attempt++) {{ + try {{ + Remove-Item -Path $parentDir -Force -ErrorAction Stop + break + }} catch {{ + if ($attempt -lt 3) {{ + Start-Sleep -Seconds 1 + }} + }} + }} + }} + }} + }} + }} + }} catch {{ + # Silent fail + }} +}} + +# Remove from PATH +if ($BinDir -ne "") {{ + try {{ + $regPath = "HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager\Environment" + $currentPath = (Get-ItemProperty -Path $regPath -Name "Path").Path + $pathEntries = $currentPath -split ";" + $newPath = ($pathEntries | Where-Object {{ $_ -ne $BinDir }}) -join ";" + + if ($currentPath -ne $newPath) {{ + Set-ItemProperty -Path $regPath -Name "Path" -Value $newPath -ErrorAction Stop + + # Broadcast environment change + Add-Type -TypeDefinition @" + using System; + using System.Runtime.InteropServices; + public class Win32 {{ + [DllImport("user32.dll", SetLastError = true, CharSet = CharSet.Auto)] + public static extern IntPtr SendMessageTimeout( + IntPtr hWnd, uint Msg, UIntPtr wParam, string lParam, + uint fuFlags, uint uTimeout, out UIntPtr lpdwResult); + }} +"@ + $HWND_BROADCAST = [IntPtr]0xffff + $WM_SETTINGCHANGE = 0x1a + $result = [UIntPtr]::Zero + [Win32]::SendMessageTimeout($HWND_BROADCAST, $WM_SETTINGCHANGE, [UIntPtr]::Zero, "Environment", 2, 5000, [ref]$result) | Out-Null + }} + }} catch {{ + # Silent fail + }} +}} +exit 0 +"#, + current_pid = current_pid, + install_path_str = install_path_str, + bin_dir_str = bin_dir_str + ) +} + +/// Create and execute the binary cleanup script +/// +/// This will create a temporary PowerShell script and execute it in the background. +/// The script will wait for the current process to exit and then clean up the binary. +pub fn execute_binary_cleanup_script( + install_path: &PathBuf, + bin_dir: Option<&PathBuf>, +) -> Result<()> { + use std::fs; + use std::io::Write; + use std::process::Command; + + // Get current process ID + let current_pid = std::process::id(); + + info!("Creating binary cleanup script for PID: {}", current_pid); + + // Generate unique script name + let script_name = format!("openframe_binary_cleanup_{}.ps1", uuid::Uuid::new_v4()); + let temp_dir = std::env::temp_dir(); + let script_path = temp_dir.join(&script_name); + + info!("Cleanup script path: {}", script_path.display()); + + // Generate script content + let script_content = generate_binary_cleanup_script(install_path, current_pid, bin_dir); + + // Write script to file + let mut file = fs::File::create(&script_path) + .context("Failed to create binary cleanup script file")?; + + file.write_all(script_content.as_bytes()) + .context("Failed to write binary cleanup script content")?; + + info!("Starting binary cleanup script in background..."); + + // Execute PowerShell script in the background (detached, no window, no output) + use std::process::Stdio; + + #[cfg(target_os = "windows")] + { + use std::os::windows::process::CommandExt; + const CREATE_NO_WINDOW: u32 = 0x08000000; + + let ps_path = get_powershell_path().map_err(|e| anyhow!(e))?; + info!("Using PowerShell: {}", ps_path); + + Command::new(&ps_path) + .args(&[ + "-NoProfile", + "-WindowStyle", + "Hidden", + "-ExecutionPolicy", + "Bypass", + "-File", + &script_path.to_string_lossy(), + ]) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .creation_flags(CREATE_NO_WINDOW) + .spawn() + .context("Failed to spawn binary cleanup script")?; + } + + #[cfg(not(target_os = "windows"))] + { + Command::new("powershell.exe") + .args(&[ + "-NoProfile", + "-WindowStyle", + "Hidden", + "-ExecutionPolicy", + "Bypass", + "-File", + &script_path.to_string_lossy(), + ]) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .context("Failed to spawn binary cleanup script")?; + } + + info!("Binary cleanup script started successfully"); + Ok(()) +} diff --git a/openframe-agent-lib/src/service.rs b/openframe-agent-lib/src/service.rs new file mode 100644 index 000000000..776a6fc25 --- /dev/null +++ b/openframe-agent-lib/src/service.rs @@ -0,0 +1,550 @@ +use anyhow::{Context, Result}; +use std::path::PathBuf; +use tokio::runtime::Runtime; +use tracing::{error, info, warn}; + +use crate::platform::permissions::{Capability, PermissionUtils}; +use crate::service_adapter::{CrossPlatformServiceManager, ServiceConfig}; +use crate::{platform::DirectoryManager, Client}; +use crate::installation_initial_config_service::{InstallationInitialConfigService, InstallConfigParams}; + +#[cfg(windows)] +use windows_service::{ + define_windows_service, service_dispatcher, + service::{ServiceControl, ServiceControlAccept, ServiceExitCode, ServiceState, ServiceStatus, ServiceType}, + service_control_handler::{self, ServiceControlHandlerResult, ServiceStatusHandle}, +}; + +const SERVICE_NAME: &str = "client"; +const DISPLAY_NAME: &str = "OpenFrame Client Service"; +const DESCRIPTION: &str = "OpenFrame client service for remote management and monitoring"; + +// Full service identifier used by all platforms +// Format: "com.openframe.{SERVICE_NAME}" -> "com.openframe.client" +pub const FULL_SERVICE_NAME: &str = "com.openframe.client"; + +// Define the Windows service entry point +#[cfg(windows)] +define_windows_service!(ffi_service_main, windows_service_main); + +/// Windows service main function - called by SCM +#[cfg(windows)] +fn windows_service_main(_args: Vec) { + // Create shutdown signal channel + let (shutdown_tx, shutdown_rx) = std::sync::mpsc::channel::<()>(); + let shutdown_tx = Arc::new(std::sync::Mutex::new(Some(shutdown_tx))); + + // Register service control handler with PROPER stop handling + let status_handle = match service_control_handler::register(FULL_SERVICE_NAME, { + let shutdown_tx = Arc::clone(&shutdown_tx); + move |control_event| { + match control_event { + ServiceControl::Stop | ServiceControl::Shutdown => { + info!("Received stop/shutdown signal from Windows SCM"); + + // Send shutdown signal + if let Some(tx) = shutdown_tx.lock().unwrap().take() { + let _ = tx.send(()); + } + + ServiceControlHandlerResult::NoError + } + ServiceControl::Interrogate => { + ServiceControlHandlerResult::NoError + } + _ => ServiceControlHandlerResult::NotImplemented + } + } + }) { + Ok(handle) => handle, + Err(e) => { + eprintln!("Failed to register service control handler: {:?}", e); + return; + } + }; + + // Report that the service is running + let _ = set_service_status(&status_handle, ServiceState::Running); + + // Create a Tokio runtime and run the service core + let rt = match Runtime::new() { + Ok(runtime) => runtime, + Err(e) => { + eprintln!("Failed to create Tokio runtime: {:?}", e); + let _ = set_service_status(&status_handle, ServiceState::Stopped); + return; + } + }; + + // Run service with shutdown signal + let result = rt.block_on(async { + // Spawn service core + let service_handle = tokio::spawn(Service::run()); + + // Wait for either service completion or shutdown signal + tokio::select! { + result = service_handle => { + info!("Service core completed"); + result.unwrap_or_else(|e| Err(anyhow::anyhow!("Service panicked: {}", e))) + } + _ = tokio::task::spawn_blocking(move || shutdown_rx.recv()) => { + info!("Shutdown signal received, stopping service..."); + Ok(()) + } + } + }); + + if let Err(e) = result { + eprintln!("Service core failed: {:?}", e); + let _ = set_service_status(&status_handle, ServiceState::Stopped); + } else { + info!("Service stopped gracefully"); + let _ = set_service_status(&status_handle, ServiceState::Stopped); + } +} + +/// Helper function to set service status +#[cfg(windows)] +fn set_service_status(status_handle: &ServiceStatusHandle, state: ServiceState) -> Result<()> { + let status = ServiceStatus { + service_type: ServiceType::OWN_PROCESS, + current_state: state, + controls_accepted: if state == ServiceState::Running { + ServiceControlAccept::STOP | ServiceControlAccept::SHUTDOWN + } else { + ServiceControlAccept::empty() + }, + exit_code: ServiceExitCode::Win32(0), + checkpoint: 0, + wait_hint: std::time::Duration::from_secs(5), + process_id: None, + }; + + status_handle.set_service_status(status) + .context("Failed to set service status") +} + +#[derive(Default)] +pub struct Service; + +impl Service { + pub fn new() -> Self { + Self + } + + /// Check if the service is already installed on the system + pub fn is_installed() -> bool { + #[cfg(target_os = "windows")] + { + use std::process::Command; + + // Check if Windows service exists using sc query + let output = Command::new("sc") + .args(&["query", FULL_SERVICE_NAME]) + .output(); + + match output { + Ok(output) => output.status.success(), + Err(_) => false, + } + } + + #[cfg(target_os = "macos")] + { + // Check if launchd plist exists + // Service manager creates plist as: /Library/LaunchDaemons/{service_label}.plist + let plist_path = std::path::PathBuf::from(format!( + "/Library/LaunchDaemons/{}.plist", + FULL_SERVICE_NAME + )); + plist_path.exists() + } + + #[cfg(target_os = "linux")] + { + // Check if systemd service exists + // Service manager creates unit as: /etc/systemd/system/{service_label}.service + let service_path = std::path::PathBuf::from(format!( + "/etc/systemd/system/{}.service", + FULL_SERVICE_NAME + )); + service_path.exists() + } + } + + /// Install the service on the current platform + pub async fn install(params: InstallConfigParams) -> Result<()> { + // Check if we have admin privileges + if !PermissionUtils::is_admin() { + error!("Service installation requires admin/root privileges"); + return Err(anyhow::anyhow!( + "Admin/root privileges required for service installation" + )); + } + + if Self::is_installed() { + info!("Existing Installation Detected\n"); + info!("An existing OpenFrame installation was found\n"); + info!("To proceed with the new installation, the old version must be removed\n"); + info!("Uninstalling existing installation..."); + + let installed_binary_path = Self::get_install_location(); + + if !installed_binary_path.exists() { + warn!("Installed binary not found at expected location: {}", installed_binary_path.display()); + info!("Proceeding with installation anyway..."); + } else { + info!("Launching uninstall process: {}", installed_binary_path.display()); + + use tokio::process::Command; + + let status = Command::new(&installed_binary_path) + .arg("uninstall") + .status() + .await + .context("Failed to launch uninstall process")?; + + if !status.success() { + warn!("Uninstall process returned non-zero exit code: {:?}", status.code()); + info!("Continuing with installation anyway..."); + } else { + info!("Uninstall process completed successfully"); + } + + // Wait additional time for cleanup script to complete + info!("Waiting for cleanup to complete..."); + tokio::time::sleep(std::time::Duration::from_secs(3)).await; + } + + info!("Continuing with new installation...\n"); + } + + info!("Installing OpenFrame service"); + let dir_manager = DirectoryManager::new(); + dir_manager + .perform_health_check() + .map_err(|e| anyhow::anyhow!("Directory health check failed: {}", e))?; + + // Build and persist initial configuration before registering OS service + let installation_initial_config_service = InstallationInitialConfigService::new(dir_manager.clone()) + .context("Failed to initialize InstallationInitialConfigService")?; + + installation_initial_config_service + .build_and_save(params) + .context("Failed to process initial configuration during service installation")?; + + // Get the current executable path + let current_exe_path = std::env::current_exe().context("Failed to get current executable path")?; + + // Determine the standard installation location for the binary + let install_path = Self::get_install_location(); + + // Copy the binary to the installation location if it's not already there + if current_exe_path != install_path { + info!("Installing OpenFrame binary to: {}", install_path.display()); + + // On Windows, create the OpenFrame application directory + // On Unix, /usr/local/bin should already exist (system directory) + #[cfg(target_os = "windows")] + { + if let Some(parent) = install_path.parent() { + std::fs::create_dir_all(parent) + .with_context(|| format!("Failed to create directory: {}", parent.display()))?; + } + } + + // Copy the binary + std::fs::copy(¤t_exe_path, &install_path) + .with_context(|| format!("Failed to copy binary to {}", install_path.display()))?; + + // Set executable permissions on Unix + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mut perms = std::fs::metadata(&install_path)?.permissions(); + perms.set_mode(0o755); // rwxr-xr-x + std::fs::set_permissions(&install_path, perms) + .with_context(|| format!("Failed to set executable permissions on {}", install_path.display()))?; + } + + info!("Binary installed successfully. You can now use 'openframe' command from anywhere."); + + // Windows: добавляем bin директорию в PATH + #[cfg(target_os = "windows")] + { + if let Some(bin_dir) = install_path.parent() { + info!("Adding {} to system PATH", bin_dir.display()); + Self::add_to_windows_path(bin_dir) + .context("Failed to add to PATH")?; + + info!("⚠️ Please restart your terminal to use 'openframe-client' command"); + } + } + } else { + info!("Binary is already in the standard location: {}", install_path.display()); + } + + // Use the installation path for the service registration + let exec_path = install_path; + + // Determine platform-specific user and group values + let (user_name, group_name) = match std::env::consts::OS { + "windows" => (Some("LocalSystem".to_string()), None), + "macos" => (Some("root".to_string()), Some("wheel".to_string())), + "linux" => (Some("root".to_string()), Some("root".to_string())), + _ => (None, None), + }; + + // Create a full configuration for the service with all enhanced options + let config = ServiceConfig { + name: SERVICE_NAME.to_string(), + display_name: DISPLAY_NAME.to_string(), + description: DESCRIPTION.to_string(), + exec_path, + run_at_load: true, + keep_alive: true, + restart_on_crash: true, + restart_throttle_seconds: 10, + working_directory: Some(dir_manager.app_support_dir().to_path_buf()), + stdout_path: Some(dir_manager.logs_dir().join("daemon_output.log")), + stderr_path: Some(dir_manager.logs_dir().join("daemon_error.log")), + user_name, + group_name, + file_limit: Some(4096), + exit_timeout_seconds: Some(10), + is_interactive: true, + ..ServiceConfig::default() + }; + + // Create the service manager with our enhanced configuration + let service = CrossPlatformServiceManager::with_config(config); + + // Call the cross-platform service manager to install + service.install().context("Failed to install service")?; + + info!("OpenFrame service installed successfully"); + Ok(()) + } + + /// Uninstall the service on the current platform + pub async fn uninstall() -> Result<()> { + // Check if we have admin privileges + if !PermissionUtils::is_admin() { + error!("Service uninstallation requires admin/root privileges"); + return Err(anyhow::anyhow!( + "Admin/root privileges required for service uninstallation" + )); + } + + info!("Uninstalling OpenFrame service"); + + let dir_manager = DirectoryManager::new(); + let install_path = Self::get_install_location(); + + // Call platform-specific uninstall implementation + #[cfg(target_os = "windows")] + { + crate::platform::uninstall::uninstall_windows(&dir_manager, &install_path).await + } + + #[cfg(target_os = "macos")] + { + crate::platform::uninstall::uninstall_macos(&dir_manager, &install_path).await + } + } + + /// Run the service core logic + pub async fn run() -> Result<()> { + // Common code for all platforms + info!("Starting OpenFrame service core"); + + // Initialize directory manager based on environment + let dir_manager = if std::env::var("OPENFRAME_DEV_MODE").is_ok() { + info!("Service running in development mode, using user directories"); + DirectoryManager::for_development() + } else { + DirectoryManager::new() + }; + + // Check if we have capability to access required resources + let _can_read_logs = PermissionUtils::has_capability(Capability::ReadSystemLogs); + let can_write_logs = PermissionUtils::has_capability(Capability::WriteSystemLogs); + + if !can_write_logs { + warn!("Process doesn't have privileges to write to system logs"); + } + + // Perform health check before starting + if let Err(e) = dir_manager.perform_health_check() { + error!("Directory health check failed: {:#}", e); + return Err(e.into()); + } + + // Initialize the client + let client = Client::new()?; + + + // Start the client + client.start().await + } + + /// Get the standard installation location for the OpenFrame binary + /// This is a location in the system PATH where the binary will be accessible globally + fn get_install_location() -> PathBuf { + #[cfg(target_os = "macos")] + { + PathBuf::from("/usr/local/bin/openframe-client") + } + + #[cfg(target_os = "linux")] + { + PathBuf::from("/usr/local/bin/openframe-client") + } + + #[cfg(target_os = "windows")] + { + let program_files = std::env::var("ProgramFiles") + .unwrap_or_else(|_| "C:\\Program Files".to_string()); + PathBuf::from(program_files) + .join("OpenFrame") + .join("bin") + .join("openframe-client.exe") + } + } + + /// Run as a service on the current platform + pub fn run_as_service() -> Result<()> { + // Check if we have necessary capabilities for running as a service + if !PermissionUtils::has_capability(Capability::ManageServices) + && !PermissionUtils::has_capability(Capability::WriteSystemDirectories) + { + // Log warning but continue - we might be running as a specialized service account + warn!("Process doesn't have full administrative privileges"); + } + + // Log which platform we're running on + let platform = match std::env::consts::OS { + "windows" => "Windows Service", + "macos" => "macOS LaunchDaemon", + "linux" => "Linux systemd", + _ => "Unknown platform", + }; + + info!("Running as {} service", platform); + + // Windows: use service dispatcher to properly initialize as a service + #[cfg(windows)] + { + info!("Starting Windows service dispatcher"); + // This call blocks and never returns while the service is running + // The actual service logic runs in windows_service_main() + service_dispatcher::start(FULL_SERVICE_NAME, ffi_service_main) + .context("Failed to start service dispatcher")?; + return Ok(()); + } + + // For Unix-like platforms (macOS, Linux), run directly with async runtime + #[cfg(not(windows))] + { + let rt = Runtime::new().context("Failed to create Tokio runtime")?; + rt.block_on(Self::run()) + } + } + + /// Add a directory to the Windows system PATH + #[cfg(target_os = "windows")] + fn add_to_windows_path(dir: &std::path::Path) -> Result<()> { + use winreg::enums::*; + use winreg::RegKey; + + let hklm = RegKey::predef(HKEY_LOCAL_MACHINE); + let env = hklm.open_subkey_with_flags( + "SYSTEM\\CurrentControlSet\\Control\\Session Manager\\Environment", + KEY_READ | KEY_WRITE, + ).context("Failed to open registry key - admin rights required")?; + + let current_path: String = env.get_value("Path") + .context("Failed to read PATH from registry")?; + + let dir_str = dir.to_string_lossy(); + + // Проверяем, не добавлена ли уже + if current_path.split(';').any(|p| p.trim().eq_ignore_ascii_case(dir_str.trim())) { + info!("Directory already in PATH: {}", dir_str); + return Ok(()); + } + + // Добавляем в PATH + let new_path = if current_path.ends_with(';') { + format!("{}{}", current_path, dir_str) + } else { + format!("{};{}", current_path, dir_str) + }; + + env.set_value("Path", &new_path) + .context("Failed to write PATH to registry")?; + + // Уведомляем систему об изменении переменных окружения + Self::broadcast_environment_change()?; + + info!("✓ Added {} to system PATH", dir_str); + Ok(()) + } + + /// Remove a directory from the Windows system PATH + #[cfg(target_os = "windows")] + fn remove_from_windows_path(dir: &std::path::Path) -> Result<()> { + use winreg::enums::*; + use winreg::RegKey; + + let hklm = RegKey::predef(HKEY_LOCAL_MACHINE); + let env = hklm.open_subkey_with_flags( + "SYSTEM\\CurrentControlSet\\Control\\Session Manager\\Environment", + KEY_READ | KEY_WRITE, + ).context("Failed to open registry key - admin rights required")?; + + let current_path: String = env.get_value("Path") + .context("Failed to read PATH from registry")?; + + let dir_str = dir.to_string_lossy(); + + // Удаляем директорию из PATH + let new_path: Vec<&str> = current_path + .split(';') + .filter(|p| !p.trim().eq_ignore_ascii_case(dir_str.trim())) + .collect(); + + let new_path = new_path.join(";"); + + env.set_value("Path", &new_path) + .context("Failed to write PATH to registry")?; + + Self::broadcast_environment_change()?; + + info!("✓ Removed {} from system PATH", dir_str); + Ok(()) + } + + /// Broadcast environment change notification to Windows + #[cfg(target_os = "windows")] + fn broadcast_environment_change() -> Result<()> { + use windows::Win32::UI::WindowsAndMessaging::*; + use windows::Win32::Foundation::*; + use windows::core::PCWSTR; + + unsafe { + let env_str: Vec = "Environment\0".encode_utf16().collect(); + SendMessageTimeoutW( + HWND_BROADCAST, + WM_SETTINGCHANGE, + WPARAM(0), + LPARAM(env_str.as_ptr() as isize), + SMTO_ABORTIFHUNG, + 5000, + None, + ); + } + + Ok(()) + } +} diff --git a/openframe-agent-lib/src/service_adapter.rs b/openframe-agent-lib/src/service_adapter.rs new file mode 100644 index 000000000..84b8f1b10 --- /dev/null +++ b/openframe-agent-lib/src/service_adapter.rs @@ -0,0 +1,595 @@ +use anyhow::{Context, Result}; +use plist::Dictionary; +use service_manager::{ + ServiceInstallCtx, ServiceLabel, ServiceManager, ServiceStartCtx, ServiceStopCtx, + ServiceUninstallCtx, +}; +use std::ffi::OsString; +use std::path::PathBuf; +use std::str::FromStr; +use tracing::{debug, info, warn}; + +#[derive(Debug, Clone)] +pub struct ServiceConfig { + // Basic service information + pub name: String, + pub display_name: String, + pub description: String, + pub exec_path: PathBuf, + + // Process control + pub run_at_load: bool, + pub keep_alive: bool, + pub restart_on_crash: bool, + pub restart_throttle_seconds: u32, + + // Environment + pub working_directory: Option, + pub environment_vars: Vec<(String, String)>, + + // Logging + pub stdout_path: Option, + pub stderr_path: Option, + + // Identity + pub user_name: Option, + pub group_name: Option, + + // Resource control + pub file_limit: Option, + pub exit_timeout_seconds: Option, + + // Process type - maps to Interactive on macOS + pub is_interactive: bool, +} + +impl Default for ServiceConfig { + fn default() -> Self { + Self { + name: "".to_string(), + display_name: "".to_string(), + description: "".to_string(), + exec_path: PathBuf::new(), + run_at_load: true, + keep_alive: true, + restart_on_crash: true, + restart_throttle_seconds: 10, + working_directory: None, + environment_vars: vec![], + stdout_path: None, + stderr_path: None, + user_name: None, + group_name: None, + file_limit: None, + exit_timeout_seconds: None, + is_interactive: true, + } + } +} + +pub struct CrossPlatformServiceManager { + pub config: ServiceConfig, +} + +impl CrossPlatformServiceManager { + pub fn new(name: &str, display_name: &str, description: &str, exec_path: PathBuf) -> Self { + Self { + config: ServiceConfig { + name: name.to_string(), + display_name: display_name.to_string(), + description: description.to_string(), + exec_path, + ..ServiceConfig::default() + }, + } + } + + pub fn with_config(config: ServiceConfig) -> Self { + Self { config } + } + + pub fn set_stdout_path(&mut self, path: PathBuf) -> &mut Self { + self.config.stdout_path = Some(path); + self + } + + pub fn set_stderr_path(&mut self, path: PathBuf) -> &mut Self { + self.config.stderr_path = Some(path); + self + } + + pub fn set_working_directory(&mut self, path: PathBuf) -> &mut Self { + self.config.working_directory = Some(path); + self + } + + pub fn set_user(&mut self, user: &str) -> &mut Self { + self.config.user_name = Some(user.to_string()); + self + } + + pub fn set_group(&mut self, group: &str) -> &mut Self { + self.config.group_name = Some(group.to_string()); + self + } + + pub fn set_restart_throttle(&mut self, seconds: u32) -> &mut Self { + self.config.restart_throttle_seconds = seconds; + self + } + + pub fn set_file_limit(&mut self, limit: u32) -> &mut Self { + self.config.file_limit = Some(limit); + self + } + + pub fn set_exit_timeout(&mut self, timeout: u32) -> &mut Self { + self.config.exit_timeout_seconds = Some(timeout); + self + } + + pub fn install(&self) -> Result<()> { + // Create a service label - this is what the service manager uses to identify the service + let label = ServiceLabel::from_str(&format!( + "com.openframe.{}", + self.config.name.to_lowercase() + )) + .context("Failed to create service label")?; + + // Get the native service manager for this platform + let manager = ::native() + .context("Failed to detect native service management platform")?; + + // Set working directory to specified one or default + let working_dir = self + .config + .working_directory + .clone() + .unwrap_or_else(|| self.get_app_support_dir()); + + debug!( + "Setting service working directory to: {}", + working_dir.display() + ); + + // Get environment variables to pass to the service + let environment = self.config.environment_vars.clone(); + + // Create the installation context with full configuration + let mut ctx = ServiceInstallCtx { + label: label.clone(), + program: self.config.exec_path.clone(), + args: vec![OsString::from("run-as-service")], + contents: None, + username: self.get_service_username(), + working_directory: Some(working_dir), + environment: Some(environment), + autostart: self.config.run_at_load, + disable_restart_on_failure: !self.config.restart_on_crash, + }; + + // Apply platform-specific configuration + self.apply_platform_specific_config(&mut ctx); + + // Create any needed directories for logs + self.create_platform_specific_files()?; + + // Install the service using the platform's native service manager + info!("Installing service with full configuration via CrossPlatformServiceManager"); + manager.install(ctx).context("Failed to install service")?; + + // After installation, start the service to ensure it's running + self.start()?; + + Ok(()) + } + + pub fn uninstall(&self) -> Result<()> { + // Create a service label + let label = ServiceLabel::from_str(&format!( + "com.openframe.{}", + self.config.name.to_lowercase() + )) + .context("Failed to create service label")?; + + // Get the native service manager for this platform + let manager = ::native() + .context("Failed to detect native service management platform")?; + + // First try to stop the service if it's running + info!("Stopping service..."); + if let Err(e) = self.stop() { + warn!("Could not stop service (might already be stopped): {}", e); + } + + // Wait for the service process to fully terminate + #[cfg(target_os = "windows")] + { + info!("Waiting for service process to fully terminate..."); + if let Err(e) = self.wait_for_service_process_to_stop(60) { + warn!("Service process did not stop cleanly: {}", e); + } + } + + // Create the uninstallation context + let ctx = ServiceUninstallCtx { label }; + + // Remove platform-specific files + self.remove_platform_specific_files(); + + // Uninstall the service + info!("Uninstalling service via CrossPlatformServiceManager"); + + #[cfg(target_os = "windows")] + { + match manager.uninstall(ctx) { + Ok(_) => {} + Err(e) => { + let error_msg = e.to_string(); + // Ignore "already marked for deletion" or "doesn't exist" + if error_msg.contains("1072") { + info!("Service already marked for deletion, considering it uninstalled"); + } else if error_msg.contains("1060") { + info!("Service does not exist, considering it uninstalled"); + } else { + return Err(e).context("Failed to uninstall service"); + } + } + } + } + + #[cfg(not(target_os = "windows"))] + { + manager + .uninstall(ctx) + .context("Failed to uninstall service")?; + } + + Ok(()) + } + + pub fn start(&self) -> Result<()> { + // Create a service label + let label = ServiceLabel::from_str(&format!( + "com.openframe.{}", + self.config.name.to_lowercase() + )) + .context("Failed to create service label")?; + + // Get the native service manager for this platform + let manager = ::native() + .context("Failed to detect native service management platform")?; + + // Create the start context + let ctx = ServiceStartCtx { label }; + + // Start the service + info!("Starting service via CrossPlatformServiceManager"); + manager.start(ctx).context("Failed to start service")?; + + Ok(()) + } + + pub fn stop(&self) -> Result<()> { + // Create a service label + let label = ServiceLabel::from_str(&format!( + "com.openframe.{}", + self.config.name.to_lowercase() + )) + .context("Failed to create service label")?; + + // Get the native service manager for this platform + let manager = ::native() + .context("Failed to detect native service management platform")?; + + // Create the stop context + let ctx = ServiceStopCtx { label }; + + // Stop the service + info!("Stopping service via CrossPlatformServiceManager"); + manager.stop(ctx).context("Failed to stop service")?; + + Ok(()) + } + + fn apply_platform_specific_config(&self, ctx: &mut ServiceInstallCtx) { + #[cfg(target_os = "macos")] + { + // For macOS, we need to create a proper plist using the plist crate + let mut dict = Dictionary::new(); + + // The Label is required and should match our service label + dict.insert( + "Label".into(), + plist::Value::String(format!("com.openframe.{}", self.config.name.to_lowercase())), + ); + + // Program and arguments are required + // Note: We omit Program and just use ProgramArguments according to the example + let args = vec![ + plist::Value::String(self.config.exec_path.to_string_lossy().to_string()), + plist::Value::String("run-as-service".to_string()), + ]; + dict.insert("ProgramArguments".into(), plist::Value::Array(args)); + + // Basic service configuration + dict.insert( + "RunAtLoad".into(), + plist::Value::Boolean(self.config.run_at_load), + ); + + // KeepAlive as a dictionary with SuccessfulExit and Crashed keys + let mut keep_alive_dict = Dictionary::new(); + keep_alive_dict.insert("SuccessfulExit".into(), plist::Value::Boolean(false)); + keep_alive_dict.insert("Crashed".into(), plist::Value::Boolean(true)); + dict.insert( + "KeepAlive".into(), + plist::Value::Dictionary(keep_alive_dict), + ); + + // Add stdout/stderr paths if configured + if let Some(stdout_path) = &self.config.stdout_path { + dict.insert( + "StandardOutPath".into(), + plist::Value::String(stdout_path.to_string_lossy().to_string()), + ); + } + + if let Some(stderr_path) = &self.config.stderr_path { + dict.insert( + "StandardErrorPath".into(), + plist::Value::String(stderr_path.to_string_lossy().to_string()), + ); + } + + // Add resource limits if configured + if let Some(limit) = self.config.file_limit { + let mut limits_dict = Dictionary::new(); + limits_dict.insert("NumberOfFiles".into(), plist::Value::Integer(limit.into())); + dict.insert( + "SoftResourceLimits".into(), + plist::Value::Dictionary(limits_dict), + ); + } + + // Add process type if specified + if self.config.is_interactive { + dict.insert( + "ProcessType".into(), + plist::Value::String("Interactive".to_string()), + ); + } + + // Handle restart settings + if self.config.restart_on_crash { + dict.insert( + "ThrottleInterval".into(), + plist::Value::Integer(self.config.restart_throttle_seconds.into()), + ); + } + + // Add ExitTimeOut if configured + if let Some(timeout) = self.config.exit_timeout_seconds { + dict.insert("ExitTimeOut".into(), plist::Value::Integer(timeout.into())); + } else { + // Add default ExitTimeOut of 10 seconds + dict.insert("ExitTimeOut".into(), 10.into()); + } + + // Add AbandonProcessGroup + dict.insert("AbandonProcessGroup".into(), plist::Value::Boolean(false)); + + // Add specific user/group if provided + if let Some(username) = &self.config.user_name { + dict.insert("UserName".into(), plist::Value::String(username.clone())); + } + + if let Some(group_name) = &self.config.group_name { + dict.insert("GroupName".into(), plist::Value::String(group_name.clone())); + } + + // Set working directory if provided + if let Some(working_dir) = &self.config.working_directory { + dict.insert( + "WorkingDirectory".into(), + plist::Value::String(working_dir.to_string_lossy().to_string()), + ); + } + + // Convert plist dictionary to XML string + if !dict.is_empty() { + let value = plist::Value::Dictionary(dict); + let mut xml = Vec::new(); + match plist::to_writer_xml(&mut xml, &value) { + Ok(_) => match String::from_utf8(xml) { + Ok(plist_xml) => { + debug!("Setting macOS plist configuration: {}", plist_xml); + ctx.contents = Some(plist_xml); + } + Err(e) => { + warn!("Failed to convert plist XML to string: {:#}", e); + } + }, + Err(e) => { + warn!( + "Failed to serialize macOS service options to plist: {:#}", + e + ); + } + } + } + } + + #[cfg(target_os = "windows")] + { + // Windows-specific settings would be applied here + // Windows service manager doesn't support all the same options + } + + #[cfg(all(unix, not(target_os = "macos")))] + { + let mut advanced_options = HashMap::new(); + + // Add stdout/stderr paths if configured + if let Some(stdout_path) = &self.config.stdout_path { + advanced_options.insert("StandardOutput", "file".to_string()); + advanced_options.insert( + "StandardOutputPath", + stdout_path.to_string_lossy().to_string(), + ); + } + + if let Some(stderr_path) = &self.config.stderr_path { + advanced_options.insert("StandardError", "file".to_string()); + advanced_options.insert( + "StandardErrorPath", + stderr_path.to_string_lossy().to_string(), + ); + } + + // Add resource limits if configured + if let Some(limit) = self.config.file_limit { + advanced_options.insert("LimitNOFILE", limit.to_string()); + } + + // Handle restart settings + if self.config.restart_on_crash { + advanced_options.insert("Restart", "on-failure".to_string()); + advanced_options.insert( + "RestartSec", + self.config.restart_throttle_seconds.to_string(), + ); + } + + // Serialize the advanced options to JSON Value and pass as contents + if !advanced_options.is_empty() { + match serde_json::to_string(&advanced_options) { + Ok(json_string) => { + debug!("Setting Linux advanced options: {}", json_string); + ctx.contents = Some(json_string); + } + Err(e) => { + warn!("Failed to serialize Linux service options: {:#}", e); + } + } + } + } + } + + fn create_platform_specific_files(&self) -> Result<()> { + // Create any required directories for log files + if let Some(stdout_path) = &self.config.stdout_path { + if let Some(parent) = stdout_path.parent() { + std::fs::create_dir_all(parent).ok(); + } + } + + if let Some(stderr_path) = &self.config.stderr_path { + if let Some(parent) = stderr_path.parent() { + std::fs::create_dir_all(parent).ok(); + } + } + + Ok(()) + } + + fn remove_platform_specific_files(&self) { + // Nothing to do here - service-manager will handle cleanup + } + + fn get_app_support_dir(&self) -> PathBuf { + #[cfg(target_os = "macos")] + { + PathBuf::from("/Library/Application Support/OpenFrame") + } + + #[cfg(target_os = "windows")] + { + let programdata = + std::env::var("PROGRAMDATA").unwrap_or_else(|_| "C:\\ProgramData".to_string()); + PathBuf::from(programdata).join("OpenFrame") + } + + #[cfg(all(unix, not(target_os = "macos")))] + { + PathBuf::from("/var/lib/openframe") + } + } + + fn get_service_username(&self) -> Option { + if let Some(username) = &self.config.user_name { + return Some(username.clone()); + } + + #[cfg(target_os = "macos")] + { + Some("root".to_string()) + } + + #[cfg(target_os = "windows")] + { + Some("LocalSystem".to_string()) + } + + #[cfg(all(unix, not(target_os = "macos")))] + { + Some("root".to_string()) + } + } + + /// Wait for the service process to actually stop (Windows-specific) + #[cfg(target_os = "windows")] + fn wait_for_service_process_to_stop(&self, timeout_seconds: u64) -> Result<()> { + use std::thread::sleep; + use std::time::{Duration, Instant}; + + let service_name = format!("com.openframe.{}", self.config.name.to_lowercase()); + let start = Instant::now(); + let timeout = Duration::from_secs(timeout_seconds); + + info!( + "Waiting up to {} seconds for service '{}' to stop...", + timeout_seconds, service_name + ); + + // Poll the service status until it's stopped or timeout + while start.elapsed() < timeout { + // Check if the service process still exists + let service_running = Self::is_service_process_running(&service_name); + + if !service_running { + info!("Service process stopped successfully"); + return Ok(()); + } + + // Wait a bit before checking again + sleep(Duration::from_millis(500)); + } + + Err(anyhow::anyhow!( + "Service process did not stop within {} seconds", + timeout_seconds + )) + } + + /// Check if a Windows service process is still running + #[cfg(target_os = "windows")] + fn is_service_process_running(service_name: &str) -> bool { + use std::process::Command; + + // Use sc query to check service status + let output = Command::new("sc").args(&["query", service_name]).output(); + + match output { + Ok(output) => { + let stdout = String::from_utf8_lossy(&output.stdout); + // If service is STOPPED or doesn't exist, it's not running + !stdout.contains("STOPPED") && output.status.success() + } + Err(_) => { + // If we can't check, assume it's not running + false + } + } + } +} diff --git a/openframe-agent-lib/src/services/agent_auth_service.rs b/openframe-agent-lib/src/services/agent_auth_service.rs new file mode 100644 index 000000000..78c37d018 --- /dev/null +++ b/openframe-agent-lib/src/services/agent_auth_service.rs @@ -0,0 +1,99 @@ +use anyhow::{Context, Result}; +use tracing::info; + +use crate::clients::AuthClient; +use crate::services::agent_configuration_service::AgentConfigurationService; +use crate::models::AgentTokenResponse; +use crate::services::shared_token_service::SharedTokenService; + +#[derive(Clone)] +pub struct AgentAuthService { + auth_client: AuthClient, + config_service: AgentConfigurationService, + shared_token_service: SharedTokenService, +} + +impl AgentAuthService { + pub fn new( + auth_client: AuthClient, + config_service: AgentConfigurationService, + shared_token_service: SharedTokenService, + ) -> Self { + Self { + auth_client, + config_service, + shared_token_service, + } + } + + pub async fn authenticate_initial(&self) -> Result { + let (client_id, client_secret) = self.config_service.get_client_credentials().await?; + let token_response = self.auth_client.authenticate_with_secret(client_id, client_secret).await?; + + self.save_tokens_to_config(&token_response).await?; + + Ok(token_response) + } + + pub async fn reauthenticate(&self) -> Result { + // Try refresh token authentication first + if let Ok(token_response) = self.try_refresh_token_authentication().await { + return Ok(token_response); + } + + // Fallback to client credentials authentication + info!("Use client credentials to authenticate user"); + self.authenticate_with_client_credentials().await + } + + async fn try_refresh_token_authentication(&self) -> Result { + let refresh_token = self.config_service.get_refresh_token().await?; + + match self.auth_client.authenticate_with_refresh_token(refresh_token).await { + Ok(token_response) => { + info!("Authenticated with refresh token"); + self.save_tokens_to_config(&token_response).await?; + info!("Successfully authenticated using refresh token"); + Ok(token_response) + } + Err(err) => { + let err_msg = err.to_string(); + // TODO: refactore in scope of fallback task to use errors with context or result with status code + if err_msg.contains("401") || err_msg.contains("403") { + info!("Refresh token rejected with 401/403. Will try client credentials"); + Err(err) // Return error to trigger fallback + } else { + Err(err.context("Refresh token flow failed")) + } + } + } + } + + async fn authenticate_with_client_credentials(&self) -> Result { + let (client_id, client_secret) = self.config_service.get_client_credentials().await?; + + let token_response = self + .auth_client + .authenticate_with_secret(client_id, client_secret) + .await + .context("Failed to authenticate using client credentials")?; + + self.save_tokens_to_config(&token_response).await?; + + Ok(token_response) + } + + async fn save_tokens_to_config(&self, token_response: &AgentTokenResponse) -> Result<()> { + self.config_service.update_tokens( + token_response.access_token.clone(), + token_response.refresh_token.clone() + ).await + .context("Failed to update configuration with new tokens")?; + + self.shared_token_service + .update(token_response.access_token.clone()) + .context("Failed to update shared token")?; + + Ok(()) + } +} \ No newline at end of file diff --git a/openframe-agent-lib/src/services/agent_configuration_service.rs b/openframe-agent-lib/src/services/agent_configuration_service.rs new file mode 100644 index 000000000..758cfcc3f --- /dev/null +++ b/openframe-agent-lib/src/services/agent_configuration_service.rs @@ -0,0 +1,94 @@ +use anyhow::{Context, Result}; +use std::fs; +use std::path::PathBuf; + +use crate::models::AgentConfiguration; +use crate::platform::directories::DirectoryManager; + +#[derive(Clone)] +pub struct AgentConfigurationService { + config_file_path: PathBuf +} + +impl AgentConfigurationService { + pub fn new(directory_manager: DirectoryManager) -> Result { + let config_file_path = directory_manager.secured_dir().join("agent_config.json"); + + directory_manager.ensure_directories() + .with_context(|| "Failed to ensure secured directory exists")?; + + Ok(Self { + config_file_path + }) + } + + pub async fn save_registration_data(&self, machine_id: String, client_id: String, client_secret: String) -> Result<()> { + let mut config = self.get()?; + config.machine_id = machine_id; + config.client_id = client_id; + config.client_secret = client_secret; + + self.save(&config).await?; + + Ok(()) + } + + pub async fn update_tokens(&self, access_token: String, refresh_token: String) -> Result<()> { + let mut config = self.get()?; + config.access_token = access_token; + config.refresh_token = refresh_token; + + self.save(&config).await?; + + Ok(()) + } + + pub async fn get_machine_id(&self) -> Result { + let config = self.get()?; + Ok(config.machine_id.clone()) + } + + pub async fn get_client_credentials(&self) -> Result<(String, String)> { + let config = self.get()?; + Ok(( + config.client_id.clone(), + config.client_secret.clone(), + )) + } + + pub async fn get_access_token(&self) -> Result { + let config = self.get()?; + Ok(config.access_token.clone()) + } + + pub async fn get_refresh_token(&self) -> Result { + let config = self.get()?; + Ok(config.refresh_token.clone()) + } + + fn get(&self) -> Result { + if !self.config_file_path.exists() { + return Ok(AgentConfiguration::default()); + } + + let json_content = fs::read_to_string(&self.config_file_path) + .with_context(|| format!("Failed to read config file: {:?}", self.config_file_path))?; + + let config: AgentConfiguration = serde_json::from_str(&json_content) + .context("Failed to deserialize agent configuration from JSON")?; + + Ok(config) + } + + async fn save(&self, config: &AgentConfiguration) -> Result<()> { + let json_content = serde_json::to_string_pretty(config) + .context("Failed to serialize agent configuration to JSON")?; + + fs::write(&self.config_file_path, json_content) + .with_context(|| format!("Failed to write config file: {:?}", self.config_file_path))?; + + Ok(()) + } +} + + \ No newline at end of file diff --git a/openframe-agent-lib/src/services/agent_registration_service.rs b/openframe-agent-lib/src/services/agent_registration_service.rs new file mode 100644 index 000000000..d394844ce --- /dev/null +++ b/openframe-agent-lib/src/services/agent_registration_service.rs @@ -0,0 +1,82 @@ +use crate::clients::RegistrationClient; +use crate::models::{AgentRegistrationRequest, AgentRegistrationResponse}; +use crate::services::agent_configuration_service::AgentConfigurationService; +use crate::services::device_data_fetcher::DeviceDataFetcher; +use crate::services::InitialConfigurationService; +use anyhow::{Context, Result}; +use std::env; + +#[derive(Clone)] +pub struct AgentRegistrationService { + registration_client: RegistrationClient, + device_data_fetcher: DeviceDataFetcher, + config_service: AgentConfigurationService, + initial_configuration_service: InitialConfigurationService, +} + +impl AgentRegistrationService { + pub fn new( + registration_client: RegistrationClient, + device_data_fetcher: DeviceDataFetcher, + config_service: AgentConfigurationService, + initial_configuration_service: InitialConfigurationService, + ) -> Self { + Self { + registration_client, + device_data_fetcher, + config_service, + initial_configuration_service, + } + } + + pub async fn register_agent(&self) -> Result { + let initial_key = self.initial_configuration_service.get_initial_key()?; + let registration_request = self.build_registration_request()?; + + let response = self + .registration_client + .register(&initial_key, registration_request) + .await + .context("Failed to register agent")?; + + self.config_service + .save_registration_data( + response.machine_id.clone(), + response.client_id.clone(), + response.client_secret.clone(), + ) + .await + .context("Failed to save registration data")?; + + // TODO: make job for retry perspective + if env::var("OPENFRAME_DEV_MODE").is_err() { + self.initial_configuration_service + .clear_initial_key() + .context("Failed to clear initial key")?; + } + + Ok(response) + } + + fn build_registration_request(&self) -> Result { + let hostname = self.device_data_fetcher.get_hostname().unwrap_or_default(); + let agent_version = self + .device_data_fetcher + .get_agent_version() + .unwrap_or_default(); + let os_type = self.device_data_fetcher.get_os_type(); + let organization_id = self + .initial_configuration_service + .get_org_id() + .unwrap_or_default(); + + let request = AgentRegistrationRequest { + hostname, + agent_version, + organization_id, + os_type, + }; + + Ok(request) + } +} diff --git a/openframe-agent-lib/src/services/device_data_fetcher.rs b/openframe-agent-lib/src/services/device_data_fetcher.rs new file mode 100644 index 000000000..2c9115ce2 --- /dev/null +++ b/openframe-agent-lib/src/services/device_data_fetcher.rs @@ -0,0 +1,37 @@ +use tracing::{info, warn}; + +#[derive(Clone, Default)] +pub struct DeviceDataFetcher; + +impl DeviceDataFetcher { + pub fn new() -> Self { + Self + } + + pub fn get_hostname(&self) -> Option { + match hostname::get() { + Ok(hostname) => { + let hostname_str = hostname.to_string_lossy().to_string(); + Some(hostname_str) + } + Err(e) => { + warn!("Failed to get hostname: {:#}", e); + None + } + } + } + + pub fn get_agent_version(&self) -> Option { + let version = env!("CARGO_PKG_VERSION").to_string(); + info!("Agent version: {}", version); + Some(version) + } + + pub fn get_os_type(&self) -> String { + if cfg!(target_os = "windows") { + "WINDOWS".to_string() + } else { + "MAC_OS".to_string() + } + } +} \ No newline at end of file diff --git a/openframe-agent-lib/src/services/encryption_service.rs b/openframe-agent-lib/src/services/encryption_service.rs new file mode 100644 index 000000000..3a9f5f21e --- /dev/null +++ b/openframe-agent-lib/src/services/encryption_service.rs @@ -0,0 +1,36 @@ +use aes_gcm::{ + aead::{Aead, KeyInit, OsRng, generic_array::GenericArray, rand_core::RngCore}, + Aes256Gcm, +}; +use anyhow::Result; +use base64::{Engine as _, engine::general_purpose}; + +#[derive(Clone, Default)] +pub struct EncryptionService; + +impl EncryptionService { + // TODO: use generated key + const KEY: &'static str = "12345678901234567890123456789012"; + + pub fn new() -> Self { + Self + } + + pub fn encrypt(&self, data: &str) -> Result { + let key = Aes256Gcm::new_from_slice(Self::KEY.as_bytes()) + .map_err(|e| anyhow::anyhow!("Failed to create encryption key: {}", e))?; + + let mut nonce_bytes = [0u8; 12]; + OsRng.fill_bytes(&mut nonce_bytes); + let nonce = GenericArray::from_slice(&nonce_bytes); + + let ciphertext = key.encrypt(nonce, data.as_bytes()) + .map_err(|e| anyhow::anyhow!("Failed to encrypt data: {}", e))?; + + let mut combined = nonce_bytes.to_vec(); + combined.extend_from_slice(&ciphertext); + + let base64_encoded = general_purpose::STANDARD.encode(combined); + Ok(base64_encoded) + } +} \ No newline at end of file diff --git a/openframe-agent-lib/src/services/github_download_service.rs b/openframe-agent-lib/src/services/github_download_service.rs new file mode 100644 index 000000000..86fde03d5 --- /dev/null +++ b/openframe-agent-lib/src/services/github_download_service.rs @@ -0,0 +1,267 @@ +use anyhow::{Context, Result, anyhow}; +use tracing::{info, warn}; +use crate::models::download_configuration::DownloadConfiguration; +use crate::config::update_config::{ + MAX_DOWNLOAD_RETRIES, + DOWNLOAD_TIMEOUT_SECS, + MIN_BINARY_SIZE_BYTES, +}; +use reqwest::Client; +use bytes::Bytes; +use std::io::Cursor; +use tokio::time::Duration; + +#[derive(Clone)] +pub struct GithubDownloadService { + http_client: Client, +} + +impl GithubDownloadService { + pub fn new(http_client: Client) -> Self { + Self { http_client } + } + + /// Downloads and extracts agent binary from the given download configuration + /// Returns the binary bytes ready to be written to disk + pub async fn download_and_extract(&self, config: &DownloadConfiguration) -> Result { + info!("Downloading from: {}", config.link); + + // Download the archive with retry + let archive_bytes = self.download_with_retry(&config.link).await + .with_context(|| format!("Failed to download from: {}", config.link))?; + + info!("Downloaded {} bytes", archive_bytes.len()); + + // Validate archive size + if archive_bytes.len() < MIN_BINARY_SIZE_BYTES as usize { + return Err(anyhow!( + "Downloaded file too small ({} bytes), minimum expected: {} bytes", + archive_bytes.len(), + MIN_BINARY_SIZE_BYTES + )); + } + + // Extract based on file extension + info!("Archive file_name: '{}', agent_file_name: '{}'", config.file_name, config.agent_file_name); + + let binary_bytes = if config.file_name.ends_with(".zip") { + info!("Detected ZIP format, extracting..."); + self.extract_from_zip(archive_bytes, &config.agent_file_name) + .with_context(|| "Failed to extract from ZIP archive")? + } else if config.file_name.ends_with(".tar.gz") || config.file_name.ends_with(".tgz") { + info!("Detected tar.gz format, extracting..."); + self.extract_from_tar_gz(archive_bytes, &config.agent_file_name) + .with_context(|| "Failed to extract from tar.gz archive")? + } else { + return Err(anyhow!("Unsupported archive format: {}", config.file_name)); + }; + + // Validate extracted binary size + if binary_bytes.len() < MIN_BINARY_SIZE_BYTES as usize { + return Err(anyhow!( + "Extracted binary too small ({} bytes), minimum expected: {} bytes", + binary_bytes.len(), + MIN_BINARY_SIZE_BYTES + )); + } + + info!("Extracted binary: {} ({} bytes)", config.agent_file_name, binary_bytes.len()); + + Ok(binary_bytes) + } + + /// Download with retry logic and timeout + /// Falls back to jsDelivr CDN if GitHub returns 429 (rate limit) + async fn download_with_retry(&self, url: &str) -> Result { + let mut last_error = None; + + for attempt in 1..=MAX_DOWNLOAD_RETRIES { + info!("Download attempt {}/{} for: {}", attempt, MAX_DOWNLOAD_RETRIES, url); + + match tokio::time::timeout( + Duration::from_secs(DOWNLOAD_TIMEOUT_SECS), + self.download(url) + ).await { + Ok(Ok(bytes)) => { + info!("Download successful on attempt {}", attempt); + return Ok(bytes); + } + Ok(Err(e)) => { + // Check if this is a rate limit error (HTTP 429) + if e.to_string().contains("429") { + warn!("GitHub rate limit (429) detected on attempt {}", attempt); + warn!("Attempting fallback to jsDelivr CDN..."); + + // Convert GitHub URL to jsDelivr CDN URL + let cdn_url = self.github_to_cdn_url(url); + info!("CDN URL: {}", cdn_url); + + // Try downloading from CDN + match tokio::time::timeout( + Duration::from_secs(DOWNLOAD_TIMEOUT_SECS), + self.download(&cdn_url) + ).await { + Ok(Ok(bytes)) => { + info!("Successfully downloaded from jsDelivr CDN"); + return Ok(bytes); + } + Ok(Err(cdn_err)) => { + warn!("CDN fallback also failed: {:#}", cdn_err); + return Err(anyhow!( + "GitHub rate limit (429) and CDN fallback failed. GitHub: {:#}, CDN: {:#}", + e, cdn_err + )); + } + Err(_) => { + warn!("CDN fallback timed out"); + return Err(anyhow!( + "GitHub rate limit (429) and CDN fallback timed out" + )); + } + } + } + + warn!("Download attempt {} failed: {:#}", attempt, e); + last_error = Some(e); + } + Err(_) => { + let timeout_err = anyhow!("Download timeout after {} seconds", DOWNLOAD_TIMEOUT_SECS); + warn!("Download attempt {} timed out", attempt); + last_error = Some(timeout_err); + } + } + + // Wait before retry (except on last attempt) + if attempt < MAX_DOWNLOAD_RETRIES { + let delay_secs = attempt * 2; // 2, 4, 6 seconds + info!("Retrying in {} seconds...", delay_secs); + tokio::time::sleep(Duration::from_secs(delay_secs as u64)).await; + } + } + + Err(last_error.unwrap_or_else(|| anyhow!("Download failed after {} attempts", MAX_DOWNLOAD_RETRIES))) + } + + /// Convert GitHub release URL to jsDelivr CDN URL + /// Example: + /// GitHub: https://github.com/owner/repo/releases/download/v1.0/file.zip + /// jsDelivr: https://cdn.jsdelivr.net/gh/owner/repo@v1.0/file.zip + fn github_to_cdn_url(&self, github_url: &str) -> String { + github_url + .replace("github.com/", "cdn.jsdelivr.net/gh/") + .replace("/releases/download/", "@") + } + + /// Downloads file from URL and returns bytes + async fn download(&self, url: &str) -> Result { + let response = self.http_client + .get(url) + .send() + .await + .context("Failed to send download request")?; + + if !response.status().is_success() { + return Err(anyhow!( + "Download failed with status: {} - URL: {}", + response.status(), + url + )); + } + + let bytes = response.bytes().await + .context("Failed to read response bytes")?; + + Ok(bytes) + } + + /// Extracts a file from ZIP archive + #[cfg(target_os = "windows")] + fn extract_from_zip(&self, archive_bytes: Bytes, target_filename: &str) -> Result { + use zip::ZipArchive; + + let cursor = Cursor::new(archive_bytes); + let mut archive = ZipArchive::new(cursor) + .context("Failed to read ZIP archive")?; + + // Search for the target file in the archive + for i in 0..archive.len() { + let mut file = archive.by_index(i) + .context("Failed to read ZIP entry")?; + + let file_name = file.name().to_string(); + + // Check if this is the target file (case-insensitive, check basename) + if file_name.to_lowercase().ends_with(&target_filename.to_lowercase()) { + info!("Found target file: {}", file_name); + + let mut buffer = Vec::new(); + std::io::copy(&mut file, &mut buffer) + .context("Failed to read file from ZIP")?; + + return Ok(Bytes::from(buffer)); + } + } + + Err(anyhow!("File '{}' not found in ZIP archive", target_filename)) + } + + /// Placeholder for non-Windows platforms + #[cfg(not(target_os = "windows"))] + fn extract_from_zip(&self, _archive_bytes: Bytes, target_filename: &str) -> Result { + Err(anyhow!("ZIP extraction not supported on this platform. Expected tar.gz for {}", target_filename)) + } + + /// Extracts a file from tar.gz archive + #[cfg(not(target_os = "windows"))] + fn extract_from_tar_gz(&self, archive_bytes: Bytes, target_filename: &str) -> Result { + use flate2::read::GzDecoder; + use tar::Archive; + + info!("Extracting {} from tar.gz archive ({} bytes)", target_filename, archive_bytes.len()); + + let cursor = Cursor::new(archive_bytes); + let decoder = GzDecoder::new(cursor); + let mut archive = Archive::new(decoder); + + // Search for the target file in the archive + for entry_result in archive.entries().context("Failed to read tar entries")? { + let mut entry = entry_result.context("Failed to read tar entry")?; + + let path = entry.path().context("Failed to get entry path")?; + let file_name = path.to_string_lossy().to_string(); + let entry_size = entry.size(); + info!("Found file in tar.gz: {} ({} bytes)", file_name, entry_size); + + let basename = path.file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_default(); + + if basename.eq_ignore_ascii_case(target_filename) && !basename.starts_with("._") { + info!("Matched target file: {} ({} bytes)", file_name, entry_size); + + let mut buffer = Vec::new(); + std::io::copy(&mut entry, &mut buffer) + .context("Failed to read file from tar.gz")?; + + info!("Read {} bytes from entry", buffer.len()); + return Ok(Bytes::from(buffer)); + } + } + + Err(anyhow!("File '{}' not found in tar.gz archive", target_filename)) + } + + /// Placeholder for Windows platform + #[cfg(target_os = "windows")] + fn extract_from_tar_gz(&self, _archive_bytes: Bytes, target_filename: &str) -> Result { + Err(anyhow!("tar.gz extraction not supported on Windows. Expected ZIP for {}", target_filename)) + } + + /// Finds the appropriate download configuration for the current OS + pub fn find_config_for_current_os(configs: &[DownloadConfiguration]) -> Result<&DownloadConfiguration> { + configs.iter() + .find(|c| c.matches_current_os()) + .ok_or_else(|| anyhow!("No download configuration found for current OS")) + } +} + diff --git a/openframe-agent-lib/src/services/initial_authentication_processor.rs b/openframe-agent-lib/src/services/initial_authentication_processor.rs new file mode 100644 index 000000000..65d83a815 --- /dev/null +++ b/openframe-agent-lib/src/services/initial_authentication_processor.rs @@ -0,0 +1,58 @@ +use anyhow::{Context, Result}; +use tokio::time::{sleep, Duration}; +use tracing::{info, warn}; + +use crate::services::AgentAuthService; +use crate::services::agent_configuration_service::AgentConfigurationService; + +#[derive(Clone)] +pub struct InitialAuthenticationProcessor { + auth_service: AgentAuthService, + config_service: AgentConfigurationService, +} + +impl InitialAuthenticationProcessor { + pub fn new( + auth_service: AgentAuthService, + config_service: AgentConfigurationService, + ) -> Self { + Self { + auth_service, + config_service, + } + } + + pub async fn process(&self) -> Result<()> { + let access_token = self.config_service.get_access_token().await?; + if !access_token.is_empty() { + info!( + "Existing access_token detected. Skipping initial authentication." + ); + return Ok(()); + } + + info!("No access_token found – starting authentication loop"); + loop { + match self.attempt_authentication().await { + Ok(_) => { + info!("Initial authentication succeeded"); + return Ok(()); + } + Err(e) => { + warn!( + "Authentication attempt failed: {}. Retrying in 60 seconds…", + e + ); + // TODO: Add exponential backoff + sleep(Duration::from_secs(60)).await; + } + } + } + } + + async fn attempt_authentication(&self) -> Result<()> { + self.auth_service.authenticate_initial().await + .context("Authentication service init returned an error")?; + Ok(()) + } +} \ No newline at end of file diff --git a/openframe-agent-lib/src/services/initial_configuration_service.rs b/openframe-agent-lib/src/services/initial_configuration_service.rs new file mode 100644 index 000000000..81300691c --- /dev/null +++ b/openframe-agent-lib/src/services/initial_configuration_service.rs @@ -0,0 +1,79 @@ +use anyhow::{Context, Result}; +use std::fs; +use std::path::PathBuf; + +use crate::models::InitialConfiguration; +use crate::platform::directories::DirectoryManager; + +#[derive(Clone)] +pub struct InitialConfigurationService { + config_file_path: PathBuf +} + +impl InitialConfigurationService { + pub fn new(directory_manager: DirectoryManager) -> Result { + let config_file_path = directory_manager.secured_dir().join("initial_config.json"); + + directory_manager.ensure_directories() + .with_context(|| "Failed to ensure secured directory exists")?; + + Ok(Self { + config_file_path + }) + } + + pub fn get_initial_key(&self) -> Result { + let config = self.get()?; + Ok(config.initial_key.clone()) + } + + pub fn get_server_url(&self) -> Result { + let config = self.get()?; + Ok(config.server_host.clone()) + } + + pub fn is_local_mode(&self) -> Result { + let config = self.get()?; + Ok(config.local_mode) + } + + pub fn get_org_id(&self) -> Result { + let config = self.get()?; + Ok(config.org_id.clone()) + } + + pub fn get_local_ca_cert_path(&self) -> Result { + let config = self.get()?; + Ok(config.local_ca_cert_path.clone()) + } + + fn get(&self) -> Result { + if !self.config_file_path.exists() { + return Err(anyhow::anyhow!("Initial configuration file does not exist")); + } + + let json_content = fs::read_to_string(&self.config_file_path) + .with_context(|| format!("Failed to read initial config file: {:?}", self.config_file_path))?; + + let config: InitialConfiguration = serde_json::from_str(&json_content) + .context("Failed to deserialize initial configuration from JSON")?; + + Ok(config) + } + + pub fn clear_initial_key(&self) -> Result<()> { + let mut config = self.get()?; + config.initial_key = String::new(); + self.save(&config) + .context("Failed to save initial configuration to file")?; + Ok(()) + } + + pub fn save(&self, config: &InitialConfiguration) -> Result<()> { + let config_json = serde_json::to_string_pretty(config) + .context("Failed to serialize initial configuration to JSON")?; + fs::write(&self.config_file_path, config_json) + .with_context(|| format!("Failed to write initial configuration file: {:?}", self.config_file_path))?; + Ok(()) + } +} diff --git a/openframe-agent-lib/src/services/installed_agent_message_publisher.rs b/openframe-agent-lib/src/services/installed_agent_message_publisher.rs new file mode 100644 index 000000000..b22c848d9 --- /dev/null +++ b/openframe-agent-lib/src/services/installed_agent_message_publisher.rs @@ -0,0 +1,34 @@ +use anyhow::Context; +use crate::models::InstalledAgentMessage; +use crate::services::nats_message_publisher::NatsMessagePublisher; + +#[derive(Clone)] +pub struct InstalledAgentMessagePublisher { + nats_message_publisher: NatsMessagePublisher, +} + +impl InstalledAgentMessagePublisher { + + pub fn new(nats_message_publisher: NatsMessagePublisher) -> Self { + Self { nats_message_publisher } + } + + pub async fn publish(&self, machine_id: String, agent_type: String, version: String) -> anyhow::Result<()> { + let topic = Self::build_topic_name(machine_id); + let message = Self::build_message(agent_type, version); + self.nats_message_publisher.publish(&topic, message).await + .context(format!("Failed to publish installed agent message to topic: {}", topic)) + } + + fn build_topic_name(machine_id: String) -> String { + format!("machine.{}.installed-agent", machine_id) + } + + fn build_message(agent_type: String, version: String) -> InstalledAgentMessage { + InstalledAgentMessage { + agent_type, + version, + } + } +} + diff --git a/openframe-agent-lib/src/services/installed_tools_service.rs b/openframe-agent-lib/src/services/installed_tools_service.rs new file mode 100644 index 000000000..1bfb9bb90 --- /dev/null +++ b/openframe-agent-lib/src/services/installed_tools_service.rs @@ -0,0 +1,71 @@ +use anyhow::{Context, Result}; +use std::fs; +use std::path::PathBuf; +use crate::models::InstalledTool; +use crate::platform::directories::DirectoryManager; + +#[derive(Clone)] +pub struct InstalledToolsService { + file_path: PathBuf, +} + +impl InstalledToolsService { + pub fn new(directory_manager: DirectoryManager) -> Result { + let path = directory_manager.secured_dir().join("installed_tools.json"); + directory_manager + .ensure_directories() + .with_context(|| "Failed to ensure secured directory exists")?; + Ok(Self { file_path: path }) + } + + pub async fn save(&self, tool: InstalledTool) -> Result<()> { + let mut tools = self.get_all().await?; + + if let Some(existing) = tools.iter_mut().find(|t| t.tool_agent_id == tool.tool_agent_id) { + *existing = tool; + } else { + tools.push(tool); + } + + self.persist(&tools).await + } + + pub async fn get_by_tool_agent_id(&self, tool_id: &str) -> Result> { + let tools = self.get_all().await?; + Ok(tools.into_iter().find(|t| t.tool_agent_id == tool_id)) + } + + pub async fn get_all(&self) -> Result> { + if !self.file_path.exists() { + return Ok(Vec::new()); + } + + let json = fs::read_to_string(&self.file_path) + .with_context(|| format!("Failed to read installed tools file: {:?}", self.file_path))?; + let tools: Vec = serde_json::from_str(&json) + .context("Failed to deserialize installed tools from JSON")?; + Ok(tools) + } + + /// Delete an installed tool by its tool_agent_id + pub async fn delete_by_tool_agent_id(&self, tool_agent_id: &str) -> Result { + let mut tools = self.get_all().await?; + let initial_len = tools.len(); + tools.retain(|t| t.tool_agent_id != tool_agent_id); + + if tools.len() != initial_len { + self.persist(&tools).await?; + Ok(true) + } else { + Ok(false) + } + } + + async fn persist(&self, tools: &[InstalledTool]) -> Result<()> { + let json = serde_json::to_string_pretty(tools) + .context("Failed to serialize installed tools to JSON")?; + fs::write(&self.file_path, json) + .with_context(|| format!("Failed to write installed tools file: {:?}", self.file_path))?; + Ok(()) + } +} diff --git a/openframe-agent-lib/src/services/local_tls_config_provider.rs b/openframe-agent-lib/src/services/local_tls_config_provider.rs new file mode 100644 index 000000000..ad3a5ed9f --- /dev/null +++ b/openframe-agent-lib/src/services/local_tls_config_provider.rs @@ -0,0 +1,71 @@ +use anyhow::{anyhow, Context, Result}; +use std::path::PathBuf; +use std::fs; +use std::io::Cursor; +use tracing::info; +use async_nats::rustls::{ClientConfig, RootCertStore}; +use crate::services::InitialConfigurationService; + +#[derive(Clone)] +pub struct LocalTlsConfigProvider { + initial_configuration_service: InitialConfigurationService, +} + +impl LocalTlsConfigProvider { + pub fn new(initial_configuration_service: InitialConfigurationService) -> Self { + Self { initial_configuration_service } + } + + pub fn create_tls_config(&self) -> Result { + info!("Creating development TLS configuration with mkcert certificate..."); + + // Get certificate path + let cert_path = self.get_certificate_path()?; + + info!("Using development certificate: {}", cert_path); + + let cert_data = fs::read(&cert_path) + .with_context(|| format!("Failed to read CA certificate from {}", cert_path))?; + + let mut cursor = Cursor::new(cert_data); + let certs = rustls_pemfile::certs(&mut cursor) + .context("Failed to parse certificate")?; + + let mut root_store = RootCertStore::empty(); + for cert in certs { + root_store.add(cert.into()) + .context("Failed to add CA certificate to root store")?; + } + + let config = ClientConfig::builder() + .with_root_certificates(root_store) + .with_no_client_auth(); + + Ok(config) + } + + fn get_certificate_path(&self) -> Result { + info!("Resolving dev CA path from initial configuration..."); + + let saved_path = self.initial_configuration_service + .get_local_ca_cert_path() + .context("Failed to read local CA cert path from initial configuration")?; + + if saved_path.is_empty() { + return Err(anyhow!("local_ca_cert_path is not set in initial configuration")); + } + + let path = PathBuf::from(&saved_path); + if !path.exists() { + return Err(anyhow!( + "local_ca_cert_path points to non-existent file: {}", + saved_path + )); + } + + info!("Using dev CA path from initial configuration: {}", saved_path); + Ok(saved_path) + } +} + + diff --git a/openframe-agent-lib/src/services/machine_heartbeat_publisher.rs b/openframe-agent-lib/src/services/machine_heartbeat_publisher.rs new file mode 100644 index 000000000..b58477e9b --- /dev/null +++ b/openframe-agent-lib/src/services/machine_heartbeat_publisher.rs @@ -0,0 +1,37 @@ +use crate::models::MachineHeartbeatMessage; +use crate::services::nats_message_publisher::NatsMessagePublisher; +use crate::services::agent_configuration_service::AgentConfigurationService; +use anyhow::Result; +use tracing::info; + +#[derive(Clone)] +pub struct MachineHeartbeatPublisher { + nats_publisher: NatsMessagePublisher, + config_service: AgentConfigurationService, +} + +impl MachineHeartbeatPublisher { + pub fn new( + nats_publisher: NatsMessagePublisher, + config_service: AgentConfigurationService, + ) -> Self { + Self { + nats_publisher, + config_service, + } + } + + pub async fn publish_heartbeat(&self) -> Result<()> { + let machine_id = self.config_service.get_machine_id().await?; + + let heartbeat_message = MachineHeartbeatMessage::new(); + let message_json = serde_json::to_string(&heartbeat_message)?; + + let topic = format!("machine.{}.heartbeat", machine_id); + + self.nats_publisher.publish(&topic, &message_json).await?; + + info!("Sent heartbeat for machine: {}", machine_id); + Ok(()) + } +} \ No newline at end of file diff --git a/openframe-agent-lib/src/services/machine_heartbeat_run_manager.rs b/openframe-agent-lib/src/services/machine_heartbeat_run_manager.rs new file mode 100644 index 000000000..ba6c4c7d7 --- /dev/null +++ b/openframe-agent-lib/src/services/machine_heartbeat_run_manager.rs @@ -0,0 +1,32 @@ +use crate::services::machine_heartbeat_publisher::MachineHeartbeatPublisher; +use tokio::time::{interval, Duration}; +use tracing::{error, info}; + +#[derive(Clone)] +pub struct MachineHeartbeatRunManager { + publisher: MachineHeartbeatPublisher, +} + +impl MachineHeartbeatRunManager { + pub fn new(publisher: MachineHeartbeatPublisher) -> Self { + Self { publisher } + } + + pub fn start(&self) { + let publisher = self.publisher.clone(); + + info!("Starting machine heartbeat run manager"); + + tokio::spawn(async move { + let mut interval = interval(Duration::from_secs(60)); // 1 minute + + loop { + interval.tick().await; + + if let Err(e) = publisher.publish_heartbeat().await { + error!("Failed to send heartbeat: {}", e); + } + } + }); + } +} diff --git a/openframe-agent-lib/src/services/mod.rs b/openframe-agent-lib/src/services/mod.rs new file mode 100644 index 000000000..ad8a0b7d9 --- /dev/null +++ b/openframe-agent-lib/src/services/mod.rs @@ -0,0 +1,62 @@ +pub mod agent_auth_service; +pub mod agent_configuration_service; +pub mod initial_configuration_service; +pub mod agent_registration_service; +pub mod local_tls_config_provider; +pub mod encryption_service; +pub mod shared_token_service; +pub mod tool_installation_service; +pub mod tool_uninstall_service; +pub mod tool_kill_service; +pub mod tool_command_params_resolver; +pub mod tool_url_params_resolver; +pub mod tool_connection_message_publisher; +pub mod installed_agent_message_publisher; +pub mod nats_connection_manager; +pub mod nats_message_publisher; +pub mod device_data_fetcher; +pub mod initial_authentication_processor; +pub mod registration_processor; +pub mod installed_tools_service; +pub mod tool_run_manager; +pub mod tool_connection_processing_manager; +pub mod tool_connection_service; +pub mod openframe_client_update_service; +pub mod tool_agent_update_service; +pub mod openframe_client_info_service; +pub mod machine_heartbeat_publisher; +pub mod machine_heartbeat_run_manager; +pub mod github_download_service; +pub mod update_state_service; +pub mod update_cleanup_service; +pub mod update_handler_service; + +pub use agent_auth_service::AgentAuthService; +pub use agent_configuration_service::AgentConfigurationService; +pub use initial_configuration_service::InitialConfigurationService; +pub use agent_registration_service::AgentRegistrationService; +pub use local_tls_config_provider::LocalTlsConfigProvider; +pub use encryption_service::EncryptionService; +pub use shared_token_service::SharedTokenService; +pub use tool_installation_service::ToolInstallationService; +pub use tool_uninstall_service::ToolUninstallService; +pub use tool_kill_service::ToolKillService; +pub use tool_command_params_resolver::ToolCommandParamsResolver; +pub use tool_url_params_resolver::ToolUrlParamsResolver; +pub use tool_connection_message_publisher::ToolConnectionMessagePublisher; +pub use installed_agent_message_publisher::InstalledAgentMessagePublisher; +pub use nats_connection_manager::NatsConnectionManager; +pub use nats_message_publisher::NatsMessagePublisher; +pub use installed_tools_service::InstalledToolsService; +pub use tool_run_manager::ToolRunManager; +pub use tool_connection_processing_manager::ToolConnectionProcessingManager; +pub use tool_connection_service::ToolConnectionService; +pub use github_download_service::GithubDownloadService; +pub use openframe_client_update_service::OpenFrameClientUpdateService; +pub use tool_agent_update_service::ToolAgentUpdateService; +pub use openframe_client_info_service::OpenFrameClientInfoService; +pub use machine_heartbeat_publisher::MachineHeartbeatPublisher; +pub use machine_heartbeat_run_manager::MachineHeartbeatRunManager; +pub use update_state_service::UpdateStateService; +pub use update_cleanup_service::UpdateCleanupService; +pub use update_handler_service::UpdateHandlerService; \ No newline at end of file diff --git a/openframe-agent-lib/src/services/nats_connection_manager.rs b/openframe-agent-lib/src/services/nats_connection_manager.rs new file mode 100644 index 000000000..c9641832f --- /dev/null +++ b/openframe-agent-lib/src/services/nats_connection_manager.rs @@ -0,0 +1,138 @@ +use anyhow::{Context, Result}; +use async_nats::Client; +use tokio::sync::RwLock; +use tracing::info; +use crate::services::agent_configuration_service::AgentConfigurationService; +use crate::services::local_tls_config_provider::LocalTlsConfigProvider; +use std::sync::Arc; +use log::error; +use crate::services::{AgentAuthService, InitialConfigurationService}; + +#[derive(Clone)] +pub struct NatsConnectionManager { + client: Arc>>>, + nats_server_url: String, + config_service: AgentConfigurationService, + tls_config_provider: LocalTlsConfigProvider, + initial_configuration_service: InitialConfigurationService, + auth_service: AgentAuthService +} + +impl NatsConnectionManager { + + const NATS_DEVICE_USER: &'static str = "machine"; + const NATS_DEVICE_PASSWORD: &'static str = ""; + + pub fn new( + nats_server_url: String, + config_service: AgentConfigurationService, + initial_configuration_service: InitialConfigurationService, + auth_service: AgentAuthService, + tls_config_provider: LocalTlsConfigProvider, + ) -> Self { + Self { + client: Arc::new(RwLock::new(None)), + nats_server_url: nats_server_url.to_string(), + config_service, + tls_config_provider, + initial_configuration_service, + auth_service + } + } + + pub async fn connect(&self) -> Result<()> { + info!("Connecting to NATS server"); + + let connection_url = self.build_nats_connection_url().await?; + let machine_id = self.config_service.get_machine_id().await?; + + // Cloned dependencies for auth callback + let auth_service = self.auth_service.clone(); + let config_service = self.config_service.clone(); + let nats_server_url = self.nats_server_url.clone(); + + // TODO: token fallback and connection retry + let mut connect_options = async_nats::ConnectOptions::new() + .name(machine_id) + .user_and_password(Self::NATS_DEVICE_USER.to_string(), Self::NATS_DEVICE_PASSWORD.to_string()) + .retry_on_initial_connect() + .reconnect_delay_callback(|_attempt| { + std::time::Duration::from_secs(5) + }) + .ping_interval(std::time::Duration::from_secs(10)) + .event_callback(|event| async move { + info!("Nats event: {:?}", event); + }) + .auth_url_callback( + move |()| { + info!("Starting reauthentication"); + let auth_service = auth_service.clone(); + let config_service = config_service.clone(); + let nats_server_url = nats_server_url.clone(); + + async move { + Self::perform_reauthentication_and_build_url(auth_service, config_service, nats_server_url).await + } + } + ); + + // Only add TLS config in development mode + if self.initial_configuration_service.is_local_mode()? { + let tls_config = self.tls_config_provider.create_tls_config() + .context("Failed to create development TLS configuration")?; + connect_options = connect_options.tls_client_config(tls_config); + } + + let client = connect_options + .connect(&connection_url) + .await + .context("Failed to connect to NATS server")?; + + *self.client.write().await = Some(Arc::new(client)); + + Ok(()) + } + + async fn perform_reauthentication_and_build_url( + auth_service: AgentAuthService, + config_service: AgentConfigurationService, + nats_server_url: String, + ) -> std::result::Result { + info!("Auth URL callback triggered - performing reauthentication"); + + match auth_service.reauthenticate().await { + Ok(_) => { + info!("Reauthentication successful in auth_url_callback"); + + match config_service.get_access_token().await { + Ok(token) => { + let new_url = format!("{}/ws/nats?authorization={}", nats_server_url, token); + info!("Built new NATS URL with fresh token"); + Ok(new_url) + } + Err(e) => { + error!("Failed to get access token after reauthentication: {}", e); + Err(async_nats::AuthError::new(format!("Failed to get token: {}", e))) + } + } + } + Err(e) => { + error!("Reauthentication failed in auth_url_callback: {}", e); + Err(async_nats::AuthError::new(format!("Reauthentication failed: {}", e))) + } + } + } + + async fn build_nats_connection_url(&self) -> Result { + let token = self.config_service.get_access_token().await?; + let host = &self.nats_server_url; + Ok(format!("{}/ws/nats?authorization={}", host, token)) + } + + pub async fn get_client(&self) -> Result> { + let guard = self.client.read().await; + guard + .clone() + .context("NATS client is not initialized. Call connect() first.") + } +} \ No newline at end of file diff --git a/openframe-agent-lib/src/services/nats_message_publisher.rs b/openframe-agent-lib/src/services/nats_message_publisher.rs new file mode 100644 index 000000000..ea623b445 --- /dev/null +++ b/openframe-agent-lib/src/services/nats_message_publisher.rs @@ -0,0 +1,26 @@ +use crate::services::nats_connection_manager::NatsConnectionManager; +use serde::Serialize; +use anyhow::{Result, Context}; + +#[derive(Clone)] +pub struct NatsMessagePublisher { + nats_connection_manager: NatsConnectionManager, +} + +impl NatsMessagePublisher { + pub fn new(nats_connection_manager: NatsConnectionManager) -> Self { + Self { nats_connection_manager } + } + + pub async fn publish(&self, subject: &str, payload: T) -> Result<()> { + let payload_json = serde_json::to_string(&payload).context("Failed to serialize payload")?; + + let client = self.nats_connection_manager + .get_client() + .await?; + + client.publish(subject.to_string(), payload_json.into()).await + .context("Failed to publish message to NATS")?; + Ok(()) + } +} \ No newline at end of file diff --git a/openframe-agent-lib/src/services/openframe_client_info_service.rs b/openframe-agent-lib/src/services/openframe_client_info_service.rs new file mode 100644 index 000000000..0ee137d38 --- /dev/null +++ b/openframe-agent-lib/src/services/openframe_client_info_service.rs @@ -0,0 +1,81 @@ +use anyhow::{Context, Result}; +use std::fs; +use std::path::PathBuf; +use tracing::{info, debug}; +use crate::models::openframe_client_info::OpenFrameClientInfo; +use crate::platform::directories::DirectoryManager; + +#[derive(Clone)] +pub struct OpenFrameClientInfoService { + info_file_path: PathBuf, +} + +impl OpenFrameClientInfoService { + pub fn new(directory_manager: DirectoryManager) -> Result { + let info_file_path = directory_manager.secured_dir().join("openframe_client_info.json"); + + directory_manager.ensure_directories() + .with_context(|| "Failed to ensure secured directory exists")?; + + Ok(Self { + info_file_path + }) + } + + pub async fn get(&self) -> Result { + if !self.info_file_path.exists() { + debug!("OpenFrame client info file doesn't exist, returning default"); + return Ok(OpenFrameClientInfo::default()); + } + + let json_content = fs::read_to_string(&self.info_file_path) + .with_context(|| format!("Failed to read client info file: {:?}", self.info_file_path))?; + + let info: OpenFrameClientInfo = serde_json::from_str(&json_content) + .context("Failed to deserialize OpenFrame client info from JSON")?; + + Ok(info) + } + + pub async fn save(&self, info: &OpenFrameClientInfo) -> Result<()> { + let json_content = serde_json::to_string_pretty(info) + .context("Failed to serialize OpenFrame client info to JSON")?; + + fs::write(&self.info_file_path, json_content) + .with_context(|| format!("Failed to write client info file: {:?}", self.info_file_path))?; + + debug!("Saved OpenFrame client info to: {:?}", self.info_file_path); + Ok(()) + } + + pub async fn update_version(&self, new_version: String) -> Result<()> { + let mut info = self.get().await?; + info.current_version = new_version.clone(); + info.last_updated = Some(chrono::Utc::now().to_rfc3339()); + + self.save(&info).await?; + info!("Updated OpenFrame client version to: {}", new_version); + + Ok(()) + } + + pub async fn set_update_status(&self, status: crate::models::openframe_client_info::ClientUpdateStatus, target_version: Option) -> Result<()> { + let mut info = self.get().await?; + info.status = status; + info.target_version = target_version; + info.last_update_check = Some(chrono::Utc::now().to_rfc3339()); + + self.save(&info).await?; + + Ok(()) + } + + pub async fn set_binary_path(&self, binary_path: String) -> Result<()> { + let mut info = self.get().await?; + info.binary_path = binary_path; + + self.save(&info).await?; + + Ok(()) + } +} diff --git a/openframe-agent-lib/src/services/openframe_client_update_service.rs b/openframe-agent-lib/src/services/openframe_client_update_service.rs new file mode 100644 index 000000000..5d74a2ee4 --- /dev/null +++ b/openframe-agent-lib/src/services/openframe_client_update_service.rs @@ -0,0 +1,268 @@ +use anyhow::{Context, Result, anyhow}; +use tracing::{info, warn, error}; +use crate::models::openframe_client_update_message::OpenFrameClientUpdateMessage; +use crate::models::openframe_client_info::ClientUpdateStatus; +use crate::models::update_state::{UpdateState, UpdatePhase}; +use crate::service::FULL_SERVICE_NAME; +use crate::services::openframe_client_info_service::OpenFrameClientInfoService; +use crate::services::github_download_service::GithubDownloadService; +use crate::services::update_state_service::UpdateStateService; +use crate::platform::updater_launcher::{self, UpdaterParams}; +use std::path::PathBuf; +use uuid::Uuid; +use std::sync::Arc; +use tokio::sync::Mutex; +use semver::Version; + +#[derive(Clone)] +pub struct OpenFrameClientUpdateService { + client_info_service: OpenFrameClientInfoService, + github_download_service: GithubDownloadService, + update_state_service: UpdateStateService, + /// Mutex to prevent concurrent updates (race condition protection) + update_in_progress: Arc>, +} + +impl OpenFrameClientUpdateService { + pub fn new( + client_info_service: OpenFrameClientInfoService, + github_download_service: GithubDownloadService, + update_state_service: UpdateStateService, + ) -> Self { + Self { + client_info_service, + github_download_service, + update_state_service, + update_in_progress: Arc::new(Mutex::new(false)), + } + } + + pub async fn process_update(&self, message: OpenFrameClientUpdateMessage) -> Result<()> { + let requested_version = message.version.trim(); + info!("Received update request for version: {}", requested_version); + + // 1. Check if update is already in progress (race condition protection) + { + let mut update_lock = self.update_in_progress.lock().await; + if *update_lock { + warn!("Update already in progress, ignoring duplicate request for version: {}", requested_version); + return Err(anyhow!("Update already in progress")); + } + // Set flag to indicate update is starting + *update_lock = true; + info!("Acquired update lock for version: {}", requested_version); + } + + // Ensure lock is released on error or completion + let update_result = self.process_update_internal(message).await; + + // Release lock + { + let mut update_lock = self.update_in_progress.lock().await; + *update_lock = false; + info!("Released update lock"); + } + + update_result + } + + /// Internal update processing with version validation and safety checks + async fn process_update_internal(&self, message: OpenFrameClientUpdateMessage) -> Result<()> { + let requested_version = message.version.trim(); + + // 2. Validate version format + if !Self::is_valid_version(requested_version) { + error!("Invalid version format: {}", requested_version); + return Err(anyhow!("Invalid version format: {}", requested_version)); + } + + // 3. Parse requested version with semver to ensure valid format + Self::parse_version(requested_version) + .with_context(|| format!("Failed to parse requested version: {}", requested_version))?; + + // 4. Log current version for informational purposes + let client_info = self.client_info_service.get().await + .context("Failed to get current client info")?; + + if !client_info.current_version.is_empty() { + info!( + "Updating from version {} to {}", + client_info.current_version, requested_version + ); + } else { + info!("No current version set, installing version: {}", requested_version); + } + + // 5. Create update state for tracking + let mut update_state = UpdateState::new(requested_version.to_string()); + self.update_state_service.save(&update_state).await + .context("Failed to save initial update state")?; + + // 6. Set update status to updating + self.client_info_service + .set_update_status(ClientUpdateStatus::Updating, Some(requested_version.to_string())) + .await + .context("Failed to set update status")?; + + info!("Starting update to version {}", requested_version); + + // Execute update with status rollback + let update_result = self.execute_update(&message, &mut update_state).await; + + // Handle errors: set status to Failed (cleanup already done in execute_update) + if let Err(ref e) = update_result { + error!("Update failed: {:#}", e); + + // Set status to Failed + if let Err(status_err) = self.client_info_service + .set_update_status(ClientUpdateStatus::Failed, Some(requested_version.to_string())) + .await + { + error!("Failed to set update status to Failed: {:#}", status_err); + } + + info!("Update failed, NATS will retry"); + } + + update_result + } + + /// Execute the actual update process + async fn execute_update(&self, message: &OpenFrameClientUpdateMessage, update_state: &mut UpdateState) -> Result<()> { + // 1. Find the appropriate download configuration for current OS + let download_config = GithubDownloadService::find_config_for_current_os(&message.download_configurations) + .context("Failed to find download configuration for current OS")?; + + info!("Using download configuration for OS: {}", download_config.os); + + // 2. Download and extract binary using GithubDownloadService + update_state.set_phase(UpdatePhase::Downloading); + self.update_state_service.save(update_state).await?; + + let binary_bytes = match self.github_download_service + .download_and_extract(download_config) + .await + { + Ok(bytes) => bytes, + Err(e) => { + error!("Download failed: {:#}", e); + // Clear update state - download failed, nothing to cleanup + self.update_state_service.clear().await?; + return Err(e.context("Failed to download and extract update")); + } + }; + + info!("Binary downloaded and extracted ({} bytes)", binary_bytes.len()); + + // 3. Extract binary + update_state.set_phase(UpdatePhase::Extracting); + self.update_state_service.save(update_state).await?; + + // 4. Save binary to a temp archive for the updater + // Note: The updater expects a ZIP, so we create one with the binary + update_state.set_phase(UpdatePhase::PreparingUpdater); + self.update_state_service.save(update_state).await?; + + let archive_path = match self.create_temp_archive(&binary_bytes, &download_config.agent_file_name).await { + Ok(path) => path, + Err(e) => { + error!("Failed to create archive: {:#}", e); + // Clear update state - archive creation failed + self.update_state_service.clear().await?; + return Err(e.context("Failed to create temporary archive")); + } + }; + + info!("Temporary archive created: {}", archive_path.display()); + + // 5. Launch update process (platform-specific) + update_state.set_phase(UpdatePhase::UpdaterLaunched); + self.update_state_service.save(update_state).await?; + + let current_exe = std::env::current_exe() + .context("Failed to get current executable path")?; + + let params = UpdaterParams { + binary_path: archive_path.clone(), + target_exe: current_exe, + service_name: FULL_SERVICE_NAME.to_string(), + update_state_path: self.update_state_service.get_state_file_path(), + }; + + let launch_result = updater_launcher::launch_updater(params).await; + + // If launch failed, cleanup archive and state + if let Err(e) = launch_result { + error!("Failed to launch updater: {:#}", e); + // Cleanup archive + if let Err(cleanup_err) = std::fs::remove_file(&archive_path) { + warn!("Failed to remove archive after launch failure: {}", cleanup_err); + } + // Clear update state + self.update_state_service.clear().await?; + return Err(e); + } + + // Update script will stop the service, so everything after this won't execute + // NATS notification will be sent from recovery service after restart + info!("Update process launched, service will be stopped by update script"); + Ok(()) + } + + /// Creates a temporary ZIP archive containing the binary for the updater script + #[cfg(windows)] + async fn create_temp_archive(&self, binary_bytes: &[u8], binary_name: &str) -> Result { + use std::io::Write; + use zip::write::{FileOptions, ZipWriter}; + + let temp_dir = std::env::temp_dir(); + let archive_path = temp_dir.join(format!("openframe-update-{}.zip", Uuid::new_v4())); + + let file = std::fs::File::create(&archive_path) + .context("Failed to create temporary ZIP file")?; + + let mut zip = ZipWriter::new(file); + let options = FileOptions::default() + .compression_method(zip::CompressionMethod::Deflated); + + zip.start_file(binary_name, options) + .context("Failed to start file in ZIP")?; + + zip.write_all(binary_bytes) + .context("Failed to write binary to ZIP")?; + + zip.finish() + .context("Failed to finalize ZIP archive")?; + + Ok(archive_path) + } + + /// On Unix, we can directly write the binary (no ZIP needed) + #[cfg(unix)] + async fn create_temp_archive(&self, binary_bytes: &[u8], binary_name: &str) -> Result { + let temp_dir = std::env::temp_dir(); + let binary_path = temp_dir.join(format!("openframe-update-{}-{}", Uuid::new_v4(), binary_name)); + + tokio::fs::write(&binary_path, binary_bytes).await + .context("Failed to write binary file")?; + + Ok(binary_path) + } + + /// Parse version string into semver Version + /// Supports formats like: "1.2.3", "v1.2.3", "1.2.3-beta", "1.2.3+build" + fn parse_version(version: &str) -> Result { + // Remove 'v' prefix if present + let version = version.trim().trim_start_matches('v'); + + Version::parse(version) + .with_context(|| format!("Failed to parse version: {}", version)) + } + + /// Validate version format (basic semver check) + fn is_valid_version(version: &str) -> bool { + !version.is_empty() + && version.chars().next().map(|c| c.is_ascii_digit() || c == 'v').unwrap_or(false) + && version.trim_start_matches('v').chars().all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '-' || c == '+') + } +} diff --git a/openframe-agent-lib/src/services/registration_processor.rs b/openframe-agent-lib/src/services/registration_processor.rs new file mode 100644 index 000000000..d16d07600 --- /dev/null +++ b/openframe-agent-lib/src/services/registration_processor.rs @@ -0,0 +1,58 @@ +use anyhow::{Context, Result}; +use tokio::time::{sleep, Duration}; +use tracing::{error, info}; + +use crate::services::AgentRegistrationService; +use crate::services::agent_configuration_service::AgentConfigurationService; +use crate::models::AgentRegistrationResponse; + +#[derive(Clone)] +pub struct RegistrationProcessor { + registration_service: AgentRegistrationService, + config_service: AgentConfigurationService, +} + +impl RegistrationProcessor { + pub fn new( + registration_service: AgentRegistrationService, + config_service: AgentConfigurationService, + ) -> Self { + Self { + registration_service, + config_service, + } + } + + pub async fn process(&self) -> Result<()> { + let machine_id = self.config_service.get_machine_id().await?; + if !machine_id.is_empty() { + info!( + "Existing machine_id detected ({}). Skipping registration.", + machine_id + ); + return Ok(()); + } + + info!("No machine_id found – starting registration loop"); + loop { + match self.attempt_registration().await { + Ok(_) => { + info!("Registration succeeded"); + return Ok(()); + } + Err(e) => { + error!("Registration attempt failed. Retrying in 60 seconds…: {:#}", e); + // TODO: Add exponential backoff + sleep(Duration::from_secs(60)).await; + } + } + } + } + + async fn attempt_registration(&self) -> Result { + self.registration_service + .register_agent() + .await + .context("Registration service returned an error") + } +} \ No newline at end of file diff --git a/openframe-agent-lib/src/services/shared_token_service.rs b/openframe-agent-lib/src/services/shared_token_service.rs new file mode 100644 index 000000000..733ca0e03 --- /dev/null +++ b/openframe-agent-lib/src/services/shared_token_service.rs @@ -0,0 +1,32 @@ +use std::fs; +use anyhow::Result; +use crate::platform::directories::DirectoryManager; +use crate::services::EncryptionService; + +#[derive(Clone)] +pub struct SharedTokenService { + dir_manager: DirectoryManager, + encryption_service: EncryptionService, +} + +impl SharedTokenService { + pub fn new(dir_manager: DirectoryManager, encryption_service: EncryptionService) -> Self { + Self { + dir_manager, + encryption_service, + } + } + + pub fn update(&self, token: String) -> Result<()> { + let config_dir = self.dir_manager.secured_dir(); + let token_file_path = config_dir.join("shared_token.enc"); + + if let Some(parent) = token_file_path.parent() { + fs::create_dir_all(parent)?; + } + + let encrypted_token = self.encryption_service.encrypt(&token)?; + fs::write(token_file_path, encrypted_token)?; + Ok(()) + } +} \ No newline at end of file diff --git a/openframe-agent-lib/src/services/tool_agent_update_service.rs b/openframe-agent-lib/src/services/tool_agent_update_service.rs new file mode 100644 index 000000000..8717644b0 --- /dev/null +++ b/openframe-agent-lib/src/services/tool_agent_update_service.rs @@ -0,0 +1,184 @@ +use crate::clients::tool_agent_file_client::ToolAgentFileClient; +use tracing::{info, debug, warn}; +use anyhow::{Context, Result}; +use crate::models::tool_agent_update_message::ToolAgentUpdateMessage; +use crate::services::InstalledToolsService; +use crate::services::ToolKillService; +use crate::services::GithubDownloadService; +use crate::services::InstalledAgentMessagePublisher; +use crate::services::agent_configuration_service::AgentConfigurationService; +use crate::services::tool_run_manager::ToolRunManager; +use crate::platform::DirectoryManager; +use tokio::fs::File; +use tokio::io::AsyncWriteExt; +use tokio::fs; +#[cfg(target_family = "unix")] +use std::os::unix::fs::PermissionsExt; + +#[derive(Clone)] +pub struct ToolAgentUpdateService { + github_download_service: GithubDownloadService, + tool_agent_file_client: ToolAgentFileClient, + installed_tools_service: InstalledToolsService, + tool_kill_service: ToolKillService, + tool_run_manager: ToolRunManager, + directory_manager: DirectoryManager, + config_service: AgentConfigurationService, + installed_agent_publisher: InstalledAgentMessagePublisher, +} + +impl ToolAgentUpdateService { + pub fn new( + github_download_service: GithubDownloadService, + tool_agent_file_client: ToolAgentFileClient, + installed_tools_service: InstalledToolsService, + tool_kill_service: ToolKillService, + tool_run_manager: ToolRunManager, + directory_manager: DirectoryManager, + config_service: AgentConfigurationService, + installed_agent_publisher: InstalledAgentMessagePublisher, + ) -> Self { + // Ensure directories exist + directory_manager + .ensure_directories() + .with_context(|| "Failed to ensure secured directory exists") + .unwrap(); + + Self { + github_download_service, + tool_agent_file_client, + installed_tools_service, + tool_kill_service, + tool_run_manager, + directory_manager, + config_service, + installed_agent_publisher, + } + } + + pub async fn process_update(&self, message: ToolAgentUpdateMessage) -> Result<()> { + let tool_agent_id = &message.tool_agent_id; + let new_version = &message.version; + + info!("Processing tool agent update for tool: {} to version: {}", tool_agent_id, new_version); + + // Check if tool is installed + let mut installed_tool = match self.installed_tools_service.get_by_tool_agent_id(tool_agent_id).await? { + Some(tool) => tool, + None => { + warn!("Tool {} is not installed, skipping update", tool_agent_id); + return Ok(()); + } + }; + + // Check if version is different + if installed_tool.version == *new_version { + info!("Tool {} is already at version {}, no update needed", tool_agent_id, new_version); + return Ok(()); + } + + info!("Updating tool {} from version {} to {}", tool_agent_id, installed_tool.version, new_version); + + self.tool_run_manager.mark_updating(tool_agent_id).await; + + let result = self.do_update(tool_agent_id, new_version, &message, &mut installed_tool).await; + + self.tool_run_manager.clear_updating(tool_agent_id).await; + + result + } + + async fn do_update( + &self, + tool_agent_id: &str, + new_version: &str, + message: &ToolAgentUpdateMessage, + installed_tool: &mut crate::models::installed_tool::InstalledTool, + ) -> Result<()> { + // Get tool directory path + let base_folder_path = self.directory_manager.app_support_dir(); + let tool_folder_path = base_folder_path.join(tool_agent_id); + let agent_file_path = tool_folder_path.join("agent"); + let backup_file_path = tool_folder_path.join("agent.backup"); + + info!("Stopping tool process for update: {}", tool_agent_id); + self.tool_kill_service.stop_tool(tool_agent_id).await + .with_context(|| format!("Failed to stop tool process for: {}", tool_agent_id))?; + + if agent_file_path.exists() { + info!("Backing up current agent binary for tool: {}", tool_agent_id); + fs::copy(&agent_file_path, &backup_file_path) + .await + .with_context(|| format!("Failed to backup agent binary for tool: {}", tool_agent_id))?; + } + + info!("Downloading new agent binary for tool: {} version: {}", tool_agent_id, new_version); + let new_agent_bytes = if !message.download_configurations.is_empty() { + // Use GithubDownloadService with download configurations + info!("Using download configurations to update tool agent"); + let download_config = GithubDownloadService::find_config_for_current_os(&message.download_configurations) + .with_context(|| format!("Failed to find download configuration for current OS for tool: {}", tool_agent_id))?; + + self.github_download_service + .download_and_extract(download_config) + .await + .with_context(|| format!("Failed to download and extract tool agent update for: {}", tool_agent_id))? + } else { + // Fall back to legacy method (Artifactory) + info!("Using legacy method to update tool agent"); + self.tool_agent_file_client + .get_tool_agent_file(tool_agent_id.to_string()) + .await + .with_context(|| format!("Failed to download new agent binary for tool: {}", tool_agent_id))? + }; + + File::create(&agent_file_path) + .await? + .write_all(&new_agent_bytes) + .await + .with_context(|| format!("Failed to write new agent binary for tool: {}", tool_agent_id))?; + + // Set executable permissions + #[cfg(target_family = "unix")] + { + let mut perms = fs::metadata(&agent_file_path).await?.permissions(); + perms.set_mode(0o755); + fs::set_permissions(&agent_file_path, perms) + .await + .with_context(|| format!("Failed to chmod +x {}", agent_file_path.display()))?; + } + + info!("New agent binary written for tool: {}", tool_agent_id); + + installed_tool.version = new_version.to_string(); + self.installed_tools_service.save(installed_tool.clone()).await + .with_context(|| format!("Failed to update installed tool record for: {}", tool_agent_id))?; + + if backup_file_path.exists() { + fs::remove_file(&backup_file_path) + .await + .with_context(|| format!("Failed to remove backup file for tool: {}", tool_agent_id))?; + debug!("Removed backup file for tool: {}", tool_agent_id); + } + + info!("Tool agent update completed for tool: {} to version: {}", tool_agent_id, new_version); + + // Publish installed agent message + info!("Publishing installed agent message for updated tool: {}", tool_agent_id); + match self.config_service.get_machine_id().await { + Ok(machine_id) => { + if let Err(e) = self.installed_agent_publisher + .publish(machine_id, tool_agent_id.to_string(), new_version.to_string()) + .await + { + warn!("Failed to publish installed agent message for {}: {:#}", tool_agent_id, e); + } + } + Err(e) => { + warn!("Failed to get machine_id for installed agent message: {:#}", e); + } + } + + Ok(()) + } +} \ No newline at end of file diff --git a/openframe-agent-lib/src/services/tool_command_params_resolver.rs b/openframe-agent-lib/src/services/tool_command_params_resolver.rs new file mode 100644 index 000000000..10cf15165 --- /dev/null +++ b/openframe-agent-lib/src/services/tool_command_params_resolver.rs @@ -0,0 +1,64 @@ +use anyhow::Result; +use regex::Regex; +use std::sync::LazyLock; +use crate::platform::DirectoryManager; +use crate::services::InitialConfigurationService; + +/// Regex for matching assets path placeholders like ${client.assetsPath.osquery} +static ASSETS_PATH_REGEX: LazyLock = LazyLock::new(|| { + Regex::new(r"\$\{client\.assetPath\.([^}]+)\}").unwrap() +}); + +#[derive(Clone)] +pub struct ToolCommandParamsResolver { + pub directory_manager: DirectoryManager, + pub initial_configuration_service: InitialConfigurationService, +} + +impl ToolCommandParamsResolver { + const SERVER_URL_PLACEHOLDER: &'static str = "${client.serverUrl}"; + const OPENFRAME_SECRET_PLACEHOLDER: &'static str = "${client.openframeSecret}"; + const OPENFRAME_TOKEN_PATH_PLACEHOLDER: &'static str = "${client.openframeTokenPath}"; + + pub fn new(directory_manager: DirectoryManager, initial_configuration_service: InitialConfigurationService) -> Self { + Self { + directory_manager, + initial_configuration_service + } + } + + pub fn process(&self, tool_agent_id: &str, command_args: Vec) -> Result> { + let server_url = format!("https://{}", self.initial_configuration_service.get_server_url()?); + let token_path = self.build_token_path(); + + Ok(command_args + .into_iter() + // Resolve standard placeholders + .map(|arg| { + arg.replace(Self::SERVER_URL_PLACEHOLDER, &server_url) + .replace(Self::OPENFRAME_SECRET_PLACEHOLDER, "12345678901234567890123456789012") + .replace(Self::OPENFRAME_TOKEN_PATH_PLACEHOLDER, &token_path) + }) + // Resolve dynamic asset path placeholders + .map(|arg| self.process_assets_placeholders(&arg, tool_agent_id)) + .collect()) + } + + fn build_token_path(&self) -> String { + self.directory_manager + .secured_dir() + .join("shared_token.enc") + .to_string_lossy() + .to_string() + } + + fn process_assets_placeholders(&self, arg: &str, tool_agent_id: &str) -> String { + ASSETS_PATH_REGEX.replace_all(arg, |caps: ®ex::Captures| { + let asset_name = &caps[1]; + self.directory_manager + .get_asset_path(tool_agent_id, asset_name, true) // Assets referenced in commands are typically executable + .to_string_lossy() + .into_owned() + }).to_string() + } +} diff --git a/openframe-agent-lib/src/services/tool_connection_message_publisher.rs b/openframe-agent-lib/src/services/tool_connection_message_publisher.rs new file mode 100644 index 000000000..88f4fc21a --- /dev/null +++ b/openframe-agent-lib/src/services/tool_connection_message_publisher.rs @@ -0,0 +1,34 @@ +use anyhow::Context; +use crate::models::ToolConnectionMessage; +use crate::services::nats_message_publisher::NatsMessagePublisher; + +#[derive(Clone)] +pub struct ToolConnectionMessagePublisher { + nats_message_publisher: NatsMessagePublisher, +} + +impl ToolConnectionMessagePublisher { + + pub fn new(nats_message_publisher: NatsMessagePublisher) -> Self { + Self { nats_message_publisher } + } + + pub async fn publish(&self, machine_id: String, agent_tool_id: String, tool_type: String) -> anyhow::Result<()> { + let topic = Self::build_topic_name(machine_id); + let message = Self::build_message(agent_tool_id, tool_type); + self.nats_message_publisher.publish(&topic, message).await + .context(format!("Failed to publish tool connection message to topic: {}", topic)) + // TODO: wait for ack and publish again if failed + } + + fn build_topic_name(machine_id: String) -> String { + format!("machine.{}.tool-connection", machine_id) + } + + fn build_message(agent_tool_id: String, tool_type: String) -> ToolConnectionMessage { + ToolConnectionMessage { + agent_tool_id, + tool_type, + } + } +} \ No newline at end of file diff --git a/openframe-agent-lib/src/services/tool_connection_processing_manager.rs b/openframe-agent-lib/src/services/tool_connection_processing_manager.rs new file mode 100644 index 000000000..98ac1f27a --- /dev/null +++ b/openframe-agent-lib/src/services/tool_connection_processing_manager.rs @@ -0,0 +1,265 @@ +use anyhow::{Context, Result}; +use tracing::{info, error}; +use tokio::process::Command; +use tokio::time::{sleep, timeout}; +use std::time::Duration; +use std::collections::HashSet; +use std::sync::Arc; +use tokio::sync::RwLock; + +use crate::models::installed_tool::InstalledTool; +use crate::models::ToolConnection; +use crate::services::installed_tools_service::InstalledToolsService; +use crate::services::tool_command_params_resolver::ToolCommandParamsResolver; +use crate::services::tool_connection_message_publisher::ToolConnectionMessagePublisher; +use crate::services::agent_configuration_service::AgentConfigurationService; +use crate::services::tool_connection_service::ToolConnectionService; + +const RETRY_DELAY_SECONDS: u64 = 15; + +// TODO: refactor class +#[derive(Clone)] +pub struct ToolConnectionProcessingManager { + installed_tools_service: InstalledToolsService, + params_processor: ToolCommandParamsResolver, + tool_connection_publisher: ToolConnectionMessagePublisher, + config_service: AgentConfigurationService, + tool_connection_service: ToolConnectionService, + running_tools: Arc>>, +} + +impl ToolConnectionProcessingManager { + pub fn new( + installed_tools_service: InstalledToolsService, + params_processor: ToolCommandParamsResolver, + tool_connection_publisher: ToolConnectionMessagePublisher, + config_service: AgentConfigurationService, + tool_connection_service: ToolConnectionService, + ) -> Self { + Self { + installed_tools_service, + params_processor, + tool_connection_publisher, + config_service, + tool_connection_service, + running_tools: Arc::new(RwLock::new(HashSet::new())), + } + } + + pub async fn run(&self) -> Result<()> { + info!("Starting tool connection processing manager"); + + let tools = self + .installed_tools_service + .get_all() + .await + .context("Failed to retrieve installed tools list")?; + + if tools.is_empty() { + info!("No installed tools found – nothing to process for connection"); + return Ok(()); + } + + for tool in tools { + if self.tool_connection_service.exists_by_tool_agent_id(&tool.tool_agent_id).await? { + info!( + "Tool connection for tool {} already exists - skipping", + tool.tool_id + ); + return Ok(()); + } + + if self.try_mark_running(&tool.tool_id).await { + info!("Processing tool connection for {}", tool.tool_id); + self.process_tool(tool).await?; + } else { + info!("Connection processing for tool {} is already running - skipping", tool.tool_id); + } + } + + Ok(()) + } + + pub async fn run_new_tool(&self, installed_tool: InstalledTool) -> Result<()> { + if self.tool_connection_service.exists_by_tool_agent_id(&installed_tool.tool_agent_id).await? { + info!( + "Tool connection for tool {} already exists - skipping", + installed_tool.tool_id + ); + return Ok(()); + } + + if !self.try_mark_running(&installed_tool.tool_id).await { + info!( + "Connection processing for tool {} is already running - skipping", + installed_tool.tool_id + ); + return Ok(()); + } + + info!( + "Processing tool connection for newly installed tool {}", + installed_tool.tool_id + ); + self.process_tool(installed_tool).await + } + + async fn try_mark_running(&self, tool_id: &str) -> bool { + let mut set = self.running_tools.write().await; + if set.contains(tool_id) { + false + } else { + set.insert(tool_id.to_string()); + true + } + } + + pub async fn clear_running_tool(&self, tool_id: &str) { + let mut set = self.running_tools.write().await; + set.remove(tool_id); + } + + async fn process_tool(&self, tool: InstalledTool) -> Result<()> { + let params_processor = self.params_processor.clone(); + let config_service = self.config_service.clone(); + let tool_connection_publisher = self.tool_connection_publisher.clone(); + let tool_connection_service = self.tool_connection_service.clone(); + + tokio::spawn(async move { + loop { + // If tool_agent_id_command_args is empty, use empty string as agent_tool_id + let agent_tool_id = if tool.tool_agent_id_command_args.is_empty() { + info!( + tool_id = %tool.tool_id, + "No agentId command configured - using empty agent_tool_id" + ); + String::new() + } else { + // Resolve placeholders for tool_agent_id_command_args (gets agent_tool_id from command output) + let processed_args = match params_processor.process( + &tool.tool_agent_id, + tool.tool_agent_id_command_args.clone(), + ) { + Ok(args) => args, + Err(e) => { + error!( + "Failed to resolve tool {} agent_tool_id_command args: {:#}", + tool.tool_id, + e + ); + sleep(Duration::from_secs(RETRY_DELAY_SECONDS)).await; + continue; + } + }; + + info!( + "Run tool {} agentId command (to get agent_tool_id) with args: {:?}", + tool.tool_id, + processed_args + ); + + // Build executable path using directory manager + let command_path = params_processor.directory_manager + .get_agent_path(&tool.tool_agent_id) + .to_string_lossy() + .to_string(); + + info!("Running..."); + // Execute command with a 15-second timeout and capture output + let command_future = Command::new(&command_path).args(&processed_args).output(); + let output = match timeout(Duration::from_secs(15), command_future).await { + // Command finished within timeout + Ok(Ok(out)) => { + info!("Command completed successfully: {}", String::from_utf8_lossy(&out.stdout)); + out + } + // Command returned an error before timeout + Ok(Err(e)) => { + error!("Failed to execute agentId command: {:#} – retrying", e); + sleep(Duration::from_secs(RETRY_DELAY_SECONDS)).await; + continue; + } + // Timeout expired + Err(_) => { + error!("agentId command timed out after 15 seconds – retrying"); + sleep(Duration::from_secs(RETRY_DELAY_SECONDS)).await; + continue; + } + }; + + info!("Checking success"); + + if output.status.success() { + let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); + info!(tool_id = %tool.tool_id, result = %stdout, "agentId command completed successfully"); + + // Parse agent_tool_id from command output + if !stdout.is_empty() { + // TODO: add mechanism to verify that it's correct agent id + stdout // Use the command output as agent_tool_id + } else { + info!( + tool_id = %tool.tool_id, + "agentId command returned empty output - retrying in {} seconds", + RETRY_DELAY_SECONDS + ); + sleep(Duration::from_secs(RETRY_DELAY_SECONDS)).await; + continue; + } + } else { + let stderr = String::from_utf8_lossy(&output.stderr); + let stdout = String::from_utf8_lossy(&output.stdout); + error!( + tool_id = %tool.tool_id, + exit_status = %output.status, + "agentId command failed - stdout: {} stderr: {}. Retrying in {} seconds", + stdout, + stderr, + RETRY_DELAY_SECONDS + ); + sleep(Duration::from_secs(RETRY_DELAY_SECONDS)).await; + continue; + } + }; + + // Publish tool connection message + match config_service.get_machine_id().await { + Ok(machine_id) => { + if let Err(e) = tool_connection_publisher + .publish(machine_id, agent_tool_id.clone(), tool.tool_type.clone()) + .await + { + error!(tool_id = %tool.tool_id, error = %e, "Failed to publish tool connection message"); + // Retry publishing on next cycle + sleep(Duration::from_secs(RETRY_DELAY_SECONDS)).await; + continue; + } + + if let Err(e) = tool_connection_service.save(ToolConnection { + tool_agent_id: tool.tool_agent_id.clone(), + agent_tool_id: agent_tool_id.clone(), + published: true, + }).await { + error!(tool_id = %tool.tool_id, error = %e, "Failed to save tool connection record"); + sleep(Duration::from_secs(RETRY_DELAY_SECONDS)).await; + continue; + } + + info!(tool_id = %tool.tool_id, agent_tool_id = %agent_tool_id, "Tool connection message published successfully and saved"); + // Stop processing after successful publish + break; + } + Err(e) => { + error!("Failed to get machine_id: {:#}", e); + sleep(Duration::from_secs(RETRY_DELAY_SECONDS)).await; + continue; + } + } + } + }); + + Ok(()) + } +} + + diff --git a/openframe-agent-lib/src/services/tool_connection_service.rs b/openframe-agent-lib/src/services/tool_connection_service.rs new file mode 100644 index 000000000..2eb2a0064 --- /dev/null +++ b/openframe-agent-lib/src/services/tool_connection_service.rs @@ -0,0 +1,74 @@ +use anyhow::{Context, Result}; +use std::fs; +use std::path::PathBuf; + +use crate::models::tool_connection::ToolConnection; +use crate::platform::directories::DirectoryManager; + +#[derive(Clone)] +pub struct ToolConnectionService { + file_path: PathBuf, +} + +impl ToolConnectionService { + /// Creates new service storing data in secured directory file `tool_connections.json` + pub fn new(directory_manager: DirectoryManager) -> Result { + let path = directory_manager.secured_dir().join("tool_connections.json"); + directory_manager + .ensure_directories() + .with_context(|| "Failed to ensure secured directory exists")?; + Ok(Self { file_path: path }) + } + + /// Save (upsert) connection + pub async fn save(&self, connection: ToolConnection) -> Result<()> { + let mut list = self.get_all().await?; + + if let Some(existing) = list.iter_mut().find(|c| c.tool_agent_id == connection.tool_agent_id) { + *existing = connection; + } else { + list.push(connection); + } + + self.persist(&list).await + } + + /// Check if a connection exists for given tool_agent_id + pub async fn exists_by_tool_agent_id(&self, id: &str) -> Result { + let list = self.get_all().await?; + Ok(list.iter().any(|c| c.tool_agent_id == id)) + } + + pub async fn get_all(&self) -> Result> { + if !self.file_path.exists() { + return Ok(Vec::new()); + } + let json = fs::read_to_string(&self.file_path) + .with_context(|| format!("Failed to read tool connections file: {:?}", self.file_path))?; + let list: Vec = serde_json::from_str(&json) + .context("Failed to deserialize tool connections from JSON")?; + Ok(list) + } + + /// Delete a tool connection by its tool_agent_id + pub async fn delete_by_tool_agent_id(&self, tool_agent_id: &str) -> Result { + let mut list = self.get_all().await?; + let initial_len = list.len(); + list.retain(|c| c.tool_agent_id != tool_agent_id); + + if list.len() != initial_len { + self.persist(&list).await?; + Ok(true) + } else { + Ok(false) + } + } + + async fn persist(&self, list: &[ToolConnection]) -> Result<()> { + let json = serde_json::to_string_pretty(list) + .context("Failed to serialize tool connections to JSON")?; + fs::write(&self.file_path, json) + .with_context(|| format!("Failed to write tool connections file: {:?}", self.file_path))?; + Ok(()) + } +} diff --git a/openframe-agent-lib/src/services/tool_installation_service.rs b/openframe-agent-lib/src/services/tool_installation_service.rs new file mode 100644 index 000000000..69c9bf3c0 --- /dev/null +++ b/openframe-agent-lib/src/services/tool_installation_service.rs @@ -0,0 +1,472 @@ +use crate::clients::tool_agent_file_client::ToolAgentFileClient; +use crate::clients::tool_api_client::ToolApiClient; +use crate::models::installed_tool::ToolStatus; +use crate::models::tool_installation_message::AssetSource; +use crate::models::InstalledTool; +use crate::models::ToolInstallationMessage; +#[cfg(target_os = "windows")] +use crate::platform::file_lock::log_file_lock_info; +use crate::platform::DirectoryManager; +use crate::services::agent_configuration_service::AgentConfigurationService; +use crate::services::tool_connection_processing_manager::ToolConnectionProcessingManager; +use crate::services::tool_connection_service::ToolConnectionService; +use crate::services::tool_kill_service::ToolKillService; +use crate::services::tool_run_manager::ToolRunManager; +use crate::services::GithubDownloadService; +use crate::services::InstalledAgentMessagePublisher; +use crate::services::InstalledToolsService; +use crate::services::ToolCommandParamsResolver; +use crate::services::ToolUrlParamsResolver; +use anyhow::{Context, Result}; +#[cfg(target_family = "unix")] +use std::os::unix::fs::PermissionsExt; +use std::path::Path; +use tokio::fs; +use tokio::fs::File; +use tokio::io::AsyncWriteExt; +use tokio::process::Command; +use tracing::{debug, info, warn}; + +#[derive(Clone)] +pub struct ToolInstallationService { + github_download_service: GithubDownloadService, + tool_agent_file_client: ToolAgentFileClient, + tool_api_client: ToolApiClient, + command_params_resolver: ToolCommandParamsResolver, + url_params_resolver: ToolUrlParamsResolver, + installed_tools_service: InstalledToolsService, + directory_manager: DirectoryManager, + tool_run_manager: ToolRunManager, + tool_connection_processing_manager: ToolConnectionProcessingManager, + config_service: AgentConfigurationService, + installed_agent_publisher: InstalledAgentMessagePublisher, + tool_kill_service: ToolKillService, + tool_connection_service: ToolConnectionService, +} + +impl ToolInstallationService { + pub fn new( + github_download_service: GithubDownloadService, + tool_agent_file_client: ToolAgentFileClient, + tool_api_client: ToolApiClient, + command_params_resolver: ToolCommandParamsResolver, + url_params_resolver: ToolUrlParamsResolver, + installed_tools_service: InstalledToolsService, + directory_manager: DirectoryManager, + tool_run_manager: ToolRunManager, + tool_connection_processing_manager: ToolConnectionProcessingManager, + config_service: AgentConfigurationService, + installed_agent_publisher: InstalledAgentMessagePublisher, + tool_connection_service: ToolConnectionService, + ) -> Self { + // Ensure directories exist + directory_manager + .ensure_directories() + .with_context(|| "Failed to ensure secured directory exists") + .unwrap(); + + Self { + github_download_service, + tool_agent_file_client, + tool_api_client, + command_params_resolver, + url_params_resolver, + installed_tools_service, + directory_manager, + tool_run_manager, + tool_connection_processing_manager, + config_service, + installed_agent_publisher, + tool_kill_service: ToolKillService::new(), + tool_connection_service, + } + } + + pub async fn install(&self, tool_installation_message: ToolInstallationMessage) -> Result<()> { + let tool_agent_id = &tool_installation_message.tool_agent_id; + info!( + "Installing tool {} with version {}", + tool_agent_id, tool_installation_message.version + ); + + let version_clone = tool_installation_message.version.clone(); + let run_args_clone = tool_installation_message.run_command_args.clone(); + let reinstall = tool_installation_message.reinstall; + // Create tool-specific directory + let base_folder_path = self.directory_manager.app_support_dir(); + let tool_folder_path = base_folder_path.join(tool_agent_id); + + // Check if tool is already installed + if let Some(installed_tool) = self + .installed_tools_service + .get_by_tool_agent_id(tool_agent_id) + .await? + { + if reinstall { + info!( + "Reinstalling tool {} with version {}", + tool_agent_id, version_clone + ); + + // Stop the tool process if it's running + info!("Stopping existing tool process for {}", tool_agent_id); + if let Err(e) = self.tool_kill_service.stop_tool(tool_agent_id).await { + warn!("Failed to stop tool process: {:#}", e); + // Continue with uninstallation even if process kill fails + } + + // Wait for process to fully terminate + tokio::time::sleep(tokio::time::Duration::from_secs(2)).await; + + info!( + "Removing existing tool directory: {}", + tool_folder_path.display() + ); + if tool_folder_path.exists() { + fs::remove_dir_all(&tool_folder_path) + .await + .with_context(|| { + format!( + "Failed to remove existing tool directory: {}", + tool_folder_path.display() + ) + })?; + } + + tokio::time::sleep(tokio::time::Duration::from_secs(1)).await; + + // Delete from both services + info!("Removing tool {} from services", tool_agent_id); + if let Err(e) = self + .tool_connection_service + .delete_by_tool_agent_id(tool_agent_id) + .await + { + warn!("Failed to remove tool connection: {:#}", e); + } + if let Err(e) = self + .installed_tools_service + .delete_by_tool_agent_id(tool_agent_id) + .await + { + warn!("Failed to remove from installed tools: {:#}", e); + } + + // Clear from both manager tracking sets to allow tool restart after reinstall + self.tool_connection_processing_manager + .clear_running_tool(&installed_tool.tool_id) + .await; + self.tool_run_manager + .clear_running_tool(&installed_tool.tool_agent_id) + .await; + + info!( + "Previous installation of tool {} was uninstalled", + tool_agent_id + ); + } else { + info!( + "Tool {} is already installed with version {}, skipping installation", + tool_agent_id, installed_tool.version + ); + return Ok(()); + } + } + + // Ensure tool-specific directory exists + fs::create_dir_all(&tool_folder_path) + .await + .with_context(|| { + format!( + "Failed to create tool directory: {}", + tool_folder_path.display() + ) + })?; + + let file_path = self.directory_manager.get_agent_path(tool_agent_id); + + // Check if agent file already exists + if file_path.exists() { + info!( + "Agent file for tool {} already exists at {}, skipping download", + tool_agent_id, + file_path.display() + ); + } else { + // Download main tool agent file + let tool_agent_file_bytes = if let Some(ref download_configs) = + tool_installation_message.download_configurations + { + // Use GithubDownloadService with download configurations + info!("Using download configurations to download tool agent"); + let download_config = + GithubDownloadService::find_config_for_current_os(download_configs) + .with_context(|| { + format!( + "Failed to find download configuration for current OS for tool: {}", + tool_agent_id + ) + })?; + + self.github_download_service + .download_and_extract(download_config) + .await + .with_context(|| { + format!( + "Failed to download and extract tool agent for: {}", + tool_agent_id + ) + })? + } else { + // Fall back to legacy method (Artifactory) + info!("Using legacy method to download tool agent"); + self.tool_agent_file_client + .get_tool_agent_file(tool_agent_id.clone()) + .await + .with_context(|| { + format!("Failed to download tool agent file for: {}", tool_agent_id) + })? + }; + + // Save directly and set permissions (always executable) + File::create(&file_path) + .await? + .write_all(&tool_agent_file_bytes) + .await?; + + // Set file permissions to executable + self.set_executable_permissions(&file_path) + .await + .with_context(|| { + format!( + "Failed to set executable permissions for {}", + file_path.display() + ) + })?; + + info!( + "Agent file for tool {} downloaded and saved to {}", + tool_agent_id, + file_path.display() + ); + } + + // Download and save assets + if let Some(ref assets) = tool_installation_message.assets { + for asset in assets { + // Use the executable field from the asset + let is_executable = asset.executable; + let asset_path = self.directory_manager.get_asset_path( + tool_agent_id, + &asset.local_filename, + is_executable, + ); + + // Check if asset file already exists + if asset_path.exists() { + info!( + "Asset {} for tool {} already exists at {}, skipping download", + asset.id, + tool_agent_id, + asset_path.display() + ); + continue; + } + + let asset_bytes = match asset.source { + AssetSource::Artifactory => { + info!("Downloading artifactory asset: {}", asset.id); + self.tool_agent_file_client + .get_tool_agent_file(asset.id.clone()) + .await + .with_context(|| { + format!("Failed to download artifactory asset: {}", asset.id) + })? + } + AssetSource::ToolApi => { + let path = asset.path.as_deref().with_context(|| { + format!("No uri path for tool {} asset {}", tool_agent_id, asset.id) + })?; + info!( + "Downloading tool API asset: {} with original path: {}", + asset.id, path + ); + + // Resolve URL parameters in the path + let resolved_path = + self.url_params_resolver.process(path).with_context(|| { + format!("Failed to resolve URL parameters for asset: {}", asset.id) + })?; + info!("Resolved path: {}", resolved_path); + + let tool_id = tool_installation_message.tool_id.clone(); + self.tool_api_client + .get_tool_asset(tool_id, resolved_path) + .await + .with_context(|| { + format!("Failed to download tool API asset: {}", asset.id) + })? + } + }; + + File::create(&asset_path) + .await? + .write_all(&asset_bytes) + .await?; + + // Set file permissions to executable only for executable assets + if is_executable { + self.set_executable_permissions(&asset_path) + .await + .with_context(|| { + format!( + "Failed to set executable permissions for asset {}", + asset_path.display() + ) + })?; + } + + info!("Asset {} saved to: {}", asset.id, asset_path.display()); + } + } else { + info!("No assets to download for tool: {}", tool_agent_id); + } + + // TODO: there's risk that tool have been installed but data haven't been sent + // there should be mechanism of pre check if tool have been installed(some command) + // Also, logic should prevent race conditions if installation stuck + // Run installation command if provided + if tool_installation_message + .installation_command_args + .is_some() + { + info!( + "Start run tool installation command for tool {}", + tool_agent_id + ); + let installation_command_args = self + .command_params_resolver + .process( + tool_agent_id, + tool_installation_message.installation_command_args.unwrap(), + ) + .context("Failed to process installation command params")?; + debug!("Processed args: {:?}", installation_command_args); + + let mut cmd = Command::new(&file_path); + cmd.args(&installation_command_args); + + let output = cmd + .output() + .await + .context("Failed to execute installation command for tool")?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + let stdout = String::from_utf8_lossy(&output.stdout); + return Err(anyhow::anyhow!( + "Installation command failed with status: {}\nstdout: {}\nstderr: {}", + output.status, + stdout, + stderr + )); + } + + let stdout = String::from_utf8_lossy(&output.stdout); + info!( + "Installation command executed successfully for tool {}\nstdout: {}", + tool_agent_id, stdout + ); + } else { + info!( + "No installation command args provided for tool: {} - skip installation", + tool_agent_id + ); + } + + // Persist installed tool information + let installed_tool = InstalledTool { + tool_agent_id: tool_agent_id.clone(), + tool_id: tool_installation_message.tool_id.clone(), + tool_type: tool_installation_message.tool_type.clone(), + version: version_clone.clone(), + session_type: tool_installation_message + .session_type + .clone() + .unwrap_or(crate::models::SessionType::Service), + run_command_args: run_args_clone, + tool_agent_id_command_args: tool_installation_message + .tool_agent_id_command_args + .unwrap_or_default(), + uninstallation_command_args: tool_installation_message.uninstallation_command_args, + status: ToolStatus::Installed, + }; + + self.installed_tools_service + .save(installed_tool.clone()) + .await + .context("Failed to save installed tool")?; + + // Run the tool after successful installation + info!( + "Running tool {} after successful installation", + tool_agent_id + ); + self.tool_run_manager + .run_new_tool(installed_tool.clone()) + .await + .context("Failed to run tool after installation")?; + + // Start tool connection processing for newly installed tool + info!( + "Processing connection for tool {} after installation", + tool_agent_id + ); + self.tool_connection_processing_manager + .run_new_tool(installed_tool.clone()) + .await + .context("Failed to process tool connection after installation")?; + + // Publish installed agent message + info!( + "Publishing installed agent message for tool: {}", + tool_agent_id + ); + match self.config_service.get_machine_id().await { + Ok(machine_id) => { + if let Err(e) = self + .installed_agent_publisher + .publish(machine_id, tool_agent_id.clone(), version_clone.clone()) + .await + { + warn!( + "Failed to publish installed agent message for {}: {:#}", + tool_agent_id, e + ); + // Don't fail installation if publishing fails + } + } + Err(e) => { + warn!( + "Failed to get machine_id for installed agent message: {:#}", + e + ); + // Don't fail installation if publishing fails + } + } + + Ok(()) + } + + /// Sets executable permissions for a file on both Unix and Windows platforms + async fn set_executable_permissions(&self, file_path: &Path) -> Result<()> { + #[cfg(target_family = "unix")] + { + let mut perms = fs::metadata(file_path).await?.permissions(); + perms.set_mode(0o755); + fs::set_permissions(file_path, perms).await?; + } + + Ok(()) + } +} diff --git a/openframe-agent-lib/src/services/tool_kill_service.rs b/openframe-agent-lib/src/services/tool_kill_service.rs new file mode 100644 index 000000000..de5a362b2 --- /dev/null +++ b/openframe-agent-lib/src/services/tool_kill_service.rs @@ -0,0 +1,279 @@ +use anyhow::Result; +use sysinfo::{Pid, Signal, System}; +use tokio::time::{sleep, Duration}; +use tracing::{error, info, warn}; + +/// Service responsible for stopping/killing tool processes +#[derive(Clone, Default)] +pub struct ToolKillService; + +/// Configuration for process termination +const GRACEFUL_SHUTDOWN_TIMEOUT_SECS: u64 = 5; +const FORCE_KILL_TIMEOUT_SECS: u64 = 3; +const MAX_KILL_RETRIES: u32 = 3; +const PROCESS_CHECK_INTERVAL_MS: u64 = 500; + +impl ToolKillService { + pub fn new() -> Self { + Self + } + + /// Stop a tool process by tool ID + /// + /// This method will search for any running processes that match the tool's + /// command pattern and attempt to terminate them gracefully, falling back + /// to force kill if necessary. + pub async fn stop_tool(&self, tool_id: &str) -> Result<()> { + let pattern = Self::build_tool_cmd_pattern(tool_id); + self.stop_processes_by_pattern(&pattern, &format!("tool: {}", tool_id)) + .await + } + + /// Stop an asset process by asset ID and tool ID + /// + /// This method will search for any running processes that match the asset's + /// command pattern and attempt to terminate them gracefully, falling back + /// to force kill if necessary. + pub async fn stop_asset(&self, asset_id: &str, tool_id: &str) -> Result<()> { + let pattern = Self::build_asset_cmd_pattern(asset_id, tool_id); + self.stop_processes_by_pattern( + &pattern, + &format!("asset: {} (tool: {})", asset_id, tool_id), + ) + .await + } + + /// Generic method to stop processes matching a command pattern + /// + /// This method will search for any running processes that match the given + /// pattern and attempt to terminate them gracefully with retries and verification. + async fn stop_processes_by_pattern(&self, pattern: &str, description: &str) -> Result<()> { + info!("Attempting to stop {}", description); + info!("Using pattern to stop: {}", pattern); + + let mut sys = System::new_all(); + sys.refresh_all(); + + let mut pids_to_stop = Vec::new(); + + // Find all matching processes + for (pid, process) in sys.processes() { + let cmd_items = process.cmd(); + let cmdline = cmd_items.join(" ").to_lowercase(); + + if cmdline.contains(pattern) { + info!("Found process for {} with pid {}", description, pid); + pids_to_stop.push(*pid); + } + } + + if pids_to_stop.is_empty() { + info!("No running processes found for {}", description); + return Ok(()); + } + + info!( + "Found {} process(es) to stop for {}", + pids_to_stop.len(), + description + ); + + // Stop each process with retries + for pid in pids_to_stop { + self.stop_process_with_retry(pid, description).await?; + } + + info!("All processes stopped successfully for {}", description); + Ok(()) + } + + /// Stop a single process with retry logic and verification + /// + /// Attempts graceful termination first, waits for process to exit, then falls back + /// to force kill with retries if necessary. + async fn stop_process_with_retry(&self, pid: Pid, description: &str) -> Result<()> { + info!("Stopping process {} for {}", pid, description); + + // Try graceful termination first + if self.try_graceful_stop(pid, description).await? { + return Ok(()); + } + + // Graceful stop failed, try force kill with retries + for attempt in 1..=MAX_KILL_RETRIES { + info!( + "Force kill attempt {}/{} for process {} ({})", + attempt, MAX_KILL_RETRIES, pid, description + ); + + if self.try_force_kill(pid, description).await? { + return Ok(()); + } + + if attempt < MAX_KILL_RETRIES { + warn!( + "Force kill attempt {} failed for process {} ({}), retrying...", + attempt, pid, description + ); + sleep(Duration::from_secs(1)).await; + } + } + + error!( + "Failed to stop process {} ({}) after {} attempts", + pid, description, MAX_KILL_RETRIES + ); + Err(anyhow::anyhow!( + "Failed to stop process {} ({}) after {} attempts", + pid, + description, + MAX_KILL_RETRIES + )) + } + + /// Try graceful termination and wait for process to exit + async fn try_graceful_stop(&self, pid: Pid, description: &str) -> Result { + let mut sys = System::new_all(); + sys.refresh_all(); + + if let Some(process) = sys.process(pid) { + info!( + "Sending graceful termination signal to process {} ({})", + pid, description + ); + + if !process.kill() { + warn!( + "Failed to send graceful termination signal to process {} ({})", + pid, description + ); + return Ok(false); + } + + // Wait for process to exit + if self + .wait_for_process_exit(pid, GRACEFUL_SHUTDOWN_TIMEOUT_SECS) + .await + { + info!("Process {} ({}) terminated gracefully", pid, description); + return Ok(true); + } + + warn!( + "Process {} ({}) did not exit within {} seconds after graceful signal", + pid, description, GRACEFUL_SHUTDOWN_TIMEOUT_SECS + ); + } + + Ok(false) + } + + /// Try force kill and wait for process to exit + async fn try_force_kill(&self, pid: Pid, description: &str) -> Result { + let mut sys = System::new_all(); + sys.refresh_all(); + + if let Some(process) = sys.process(pid) { + info!( + "Sending force kill signal to process {} ({})", + pid, description + ); + + match process.kill_with(Signal::Kill) { + Some(true) => { + info!( + "Force kill signal sent to process {} ({})", + pid, description + ); + } + Some(false) => { + warn!( + "Force kill signal failed for process {} ({})", + pid, description + ); + return Ok(false); + } + None => { + error!( + "Failed to send force kill signal to process {} ({})", + pid, description + ); + return Ok(false); + } + } + + // Wait for process to exit + if self + .wait_for_process_exit(pid, FORCE_KILL_TIMEOUT_SECS) + .await + { + info!("Process {} ({}) terminated by force kill", pid, description); + return Ok(true); + } + + warn!( + "Process {} ({}) still running after force kill signal", + pid, description + ); + Ok(false) + } else { + // Process not found - it might have already exited + info!( + "Process {} ({}) not found, likely already exited", + pid, description + ); + Ok(true) + } + } + + /// Wait for a process to exit, checking periodically + /// + /// Returns true if process exited, false if timeout reached + async fn wait_for_process_exit(&self, pid: Pid, timeout_secs: u64) -> bool { + let max_checks = (timeout_secs * 1000) / PROCESS_CHECK_INTERVAL_MS; + + for check in 1..=max_checks { + sleep(Duration::from_millis(PROCESS_CHECK_INTERVAL_MS)).await; + + let mut sys = System::new_all(); + sys.refresh_all(); + + if sys.process(pid).is_none() { + info!( + "Process {} exited after {} ms", + pid, + check * PROCESS_CHECK_INTERVAL_MS + ); + return true; + } + } + + false + } + + /// Build the command pattern to match for a given tool ID + /// Pattern: {tool}\agent (Windows) or {tool}/agent (Unix) + fn build_tool_cmd_pattern(tool_id: &str) -> String { + #[cfg(target_os = "windows")] + { + format!("{}\\agent", tool_id).to_lowercase() + } + #[cfg(any(target_os = "macos", target_os = "linux"))] + { + format!("{}/agent", tool_id).to_lowercase() + } + } + + /// Build the command pattern to match for a given asset ID and tool ID + /// Pattern: \{tool}\{asset} (Windows) or /{tool}/{asset} (Unix) + fn build_asset_cmd_pattern(asset_id: &str, tool_id: &str) -> String { + #[cfg(target_os = "windows")] + { + format!("\\{}\\{}", tool_id, asset_id).to_lowercase() + } + #[cfg(any(target_os = "macos", target_os = "linux"))] + { + format!("/{}/{}", tool_id, asset_id).to_lowercase() + } + } +} diff --git a/openframe-agent-lib/src/services/tool_run_manager.rs b/openframe-agent-lib/src/services/tool_run_manager.rs new file mode 100644 index 000000000..be6ec2ae9 --- /dev/null +++ b/openframe-agent-lib/src/services/tool_run_manager.rs @@ -0,0 +1,633 @@ +use anyhow::{Context, Result}; +use tracing::{info, warn, error, debug}; +use std::process::Stdio; +use tokio::process::Command; +use tokio::time::sleep; +use std::time::Duration; +use std::collections::HashSet; +use std::sync::Arc; +use tokio::sync::RwLock; +use tokio::io::{AsyncBufReadExt, BufReader}; +use crate::models::installed_tool::InstalledTool; +use crate::services::installed_tools_service::InstalledToolsService; +use crate::services::tool_command_params_resolver::ToolCommandParamsResolver; +use crate::services::tool_kill_service::ToolKillService; + +#[cfg(windows)] +use std::ffi::OsStr; +#[cfg(windows)] +use std::os::windows::ffi::OsStrExt; +#[cfg(windows)] +use windows::{ + core::{PCWSTR, PWSTR}, + Win32::Foundation::*, + Win32::System::Threading::*, + Win32::System::RemoteDesktop::*, + Win32::UI::WindowsAndMessaging::SW_SHOW, + Win32::Security::*, +}; + +const RETRY_DELAY_SECONDS: u64 = 5; + +#[cfg(windows)] +fn to_wide(s: &str) -> Vec { + use std::iter::once; + OsStr::new(s).encode_wide().chain(once(0)).collect() +} + +#[cfg(windows)] +fn get_active_user_session() -> Option { + unsafe { + info!("=== Starting active user session detection ==="); + + // 1. Try to get Session Id of current process + let current_pid = GetCurrentProcessId(); + info!("Current process PID: {}", current_pid); + + let mut session_id = 0; + if ProcessIdToSessionId(current_pid, &mut session_id).is_ok() { + info!("Current process session ID: {}", session_id); + if session_id != 0 { + info!("Not running as service - using current process session ID: {}", session_id); + return Some(session_id); + } + info!("Session ID is 0 - running as service, need to find active user session"); + } else { + warn!("Failed to get current process session ID"); + } + + // 2. If session_id == 0 (service), enumerate all sessions to find active user + info!("Enumerating all Windows Terminal Services sessions..."); + let mut pp_session_info: *mut WTS_SESSION_INFOW = std::ptr::null_mut(); + let mut count: u32 = 0; + + if WTSEnumerateSessionsW( + WTS_CURRENT_SERVER_HANDLE, + 0, + 1, + &mut pp_session_info, + &mut count, + ).is_ok() + { + info!("Found {} total sessions", count); + let sessions = std::slice::from_raw_parts(pp_session_info, count as usize); + + // First, log ALL sessions for visibility + let mut active_sessions = Vec::new(); + + for (idx, session) in sessions.iter().enumerate() { + let session_name = if session.pWinStationName.is_null() { + String::from("(null)") + } else { + String::from_utf16_lossy( + std::slice::from_raw_parts( + session.pWinStationName.0, + wcslen(session.pWinStationName.0) + ) + ) + }; + + info!(" Session {}: ID={}, Name='{}', State={:?}", + idx, session.SessionId, session_name, session.State); + + // Collect all active sessions (State == 0 = WTSActive) + if session.State == WTSActive { + active_sessions.push((session.SessionId, session_name.clone())); + info!(" → Active session detected"); + } + } + + // Choose the best active session + if !active_sessions.is_empty() { + info!("Found {} active session(s)", active_sessions.len()); + + // Strategy: Prefer RDP sessions over Console, or use the highest session ID (most recent) + let best_session = active_sessions.iter() + .filter(|(id, name)| { + // Filter out session 0 (Services) and listen sessions + *id > 0 && !name.to_lowercase().contains("listen") + }) + .max_by_key(|(id, name)| { + // Prefer RDP sessions (rdp-tcp) over Console, then by highest ID + let is_rdp = name.to_lowercase().contains("rdp-tcp"); + let is_console = name.to_lowercase().contains("console"); + + // Priority: RDP > Console, then by session ID + if is_rdp && !name.to_lowercase().contains("listen") { + (2, *id) // Highest priority for active RDP sessions + } else if is_console { + (1, *id) // Medium priority for console + } else { + (0, *id) // Lowest priority for others + } + }); + + if let Some((id, name)) = best_session { + info!("Selected active user session: ID={}, Name='{}'", id, name); + WTSFreeMemory(pp_session_info as _); + return Some(*id); + } else { + warn!("Active sessions found but none suitable (filtered out session 0 and listen sessions)"); + } + } else { + warn!("No active (WTSActive) session found among {} sessions", count); + } + + WTSFreeMemory(pp_session_info as _); + } else { + error!("Failed to enumerate Windows Terminal Services sessions"); + } + + error!("=== Failed to find any active user session ==="); + None + } +} + +#[cfg(windows)] +fn wcslen(ptr: *const u16) -> usize { + let mut len = 0; + unsafe { + while *ptr.add(len) != 0 { + len += 1; + } + } + len +} + +#[cfg(windows)] +fn launch_process_in_console_session(command_path: &str, args: &[String]) -> Result<(u32, HANDLE)> { + unsafe { + let session_id = WTSGetActiveConsoleSessionId(); + info!("Physical console session ID: {}", session_id); + if session_id == u32::MAX { + anyhow::bail!("No active user session found"); + } + + let mut user_token = HANDLE(0); + if let Err(e) = WTSQueryUserToken(session_id, &mut user_token) { + anyhow::bail!("Failed to get user token for session {}: {:?}", session_id, e); + } + + // Build command line with arguments + let mut cmdline = command_path.to_string(); + for arg in args { + cmdline.push(' '); + // Quote argument if it contains spaces + if arg.contains(' ') { + cmdline.push('"'); + cmdline.push_str(arg); + cmdline.push('"'); + } else { + cmdline.push_str(arg); + } + } + + let mut si = STARTUPINFOW::default(); + si.cb = std::mem::size_of::() as u32; + let mut pi = PROCESS_INFORMATION::default(); + + let mut cmdline_wide = to_wide(&cmdline); + + // Use DETACHED_PROCESS | CREATE_NO_WINDOW to run without visible console + use windows::Win32::System::Threading::{DETACHED_PROCESS, CREATE_NO_WINDOW}; + + let result = CreateProcessAsUserW( + user_token, + PCWSTR(to_wide(command_path).as_ptr()), + PWSTR(cmdline_wide.as_mut_ptr()), + None, + None, + false, + DETACHED_PROCESS | CREATE_NO_WINDOW, + None, + None, + &si, + &mut pi, + ); + + let _ = CloseHandle(user_token); + + if let Err(e) = result { + anyhow::bail!("Failed to launch process in user session: {:?}", e); + } + + let pid = pi.dwProcessId; + let process_handle = pi.hProcess; + + // Close thread handle as we don't need it + let _ = CloseHandle(pi.hThread); + + info!("Process launched in user session, PID: {}", pid); + Ok((pid, process_handle)) + } +} + +#[cfg(windows)] +fn launch_process_in_user_session(command_path: &str, args: &[String]) -> Result<(u32, HANDLE)> { + unsafe { + let session_id = match get_active_user_session() { + Some(id) => { + info!("Successfully obtained active user session ID: {}", id); + id + } + None => { + anyhow::bail!("No active user session found"); + } + }; + + info!("Step 1: Querying user token for session {}", session_id); + let mut user_token = HANDLE(0); + if let Err(e) = WTSQueryUserToken(session_id, &mut user_token) { + error!("Failed to get user token for session {}: {:?}", session_id, e); + anyhow::bail!("Failed to get user token for session {}: {:?}", session_id, e); + } + + info!("Successfully obtained user token for session {} (handle: {:?})", session_id, user_token); + + // Duplicate token to get primary token (required for CreateProcessAsUserW) + info!("Step 2: Duplicating token to get primary token (required for CreateProcessAsUserW)"); + let mut primary_token = HANDLE(0); + if let Err(e) = DuplicateTokenEx( + user_token, + TOKEN_ALL_ACCESS, + None, + SECURITY_IMPERSONATION_LEVEL(2), // SecurityImpersonation + TokenPrimary, + &mut primary_token, + ) { + error!("Failed to duplicate token: {:?}", e); + let _ = CloseHandle(user_token); + anyhow::bail!("Failed to duplicate token for session {}: {:?}", session_id, e); + } + + let _ = CloseHandle(user_token); + info!("Successfully duplicated token to primary token (handle: {:?})", primary_token); + + // Build command line with full path in quotes + arguments + info!("Step 3: Building command line"); + let mut cmdline = format!("\"{}\"", command_path); + for arg in args { + cmdline.push(' '); + // Quote argument if it contains spaces + if arg.contains(' ') { + cmdline.push('"'); + cmdline.push_str(arg); + cmdline.push('"'); + } else { + cmdline.push_str(arg); + } + } + info!("Command line: {}", cmdline); + + info!("Step 4: Setting up STARTUPINFOW structure"); + let mut si = STARTUPINFOW::default(); + si.cb = std::mem::size_of::() as u32; + + // For GUI applications, set the desktop to winsta0\default + let desktop = to_wide("winsta0\\default"); + si.lpDesktop = PWSTR(desktop.as_ptr() as *mut u16); + si.dwFlags = windows::Win32::System::Threading::STARTF_USESHOWWINDOW; + si.wShowWindow = SW_SHOW.0 as u16; + info!(" Desktop: winsta0\\default"); + info!(" Show window: SW_SHOW"); + info!(" STARTUPINFOW size: {} bytes", si.cb); + + let mut pi = PROCESS_INFORMATION::default(); + + let mut cmdline_wide = to_wide(&cmdline); + + info!("Step 5: Calling CreateProcessAsUserW"); + info!(" lpApplicationName: NULL (using command line parsing)"); + info!(" lpCommandLine: {}", cmdline); + info!(" Creation flags: CREATE_NEW_PROCESS_GROUP"); + + // For GUI applications, use CREATE_NEW_PROCESS_GROUP for proper process isolation + use windows::Win32::System::Threading::CREATE_NEW_PROCESS_GROUP; + + // Try with lpApplicationName = NULL and full command line + let result = CreateProcessAsUserW( + primary_token, + PCWSTR::null(), // lpApplicationName = NULL + PWSTR(cmdline_wide.as_mut_ptr()), + None, + None, + false, + CREATE_NEW_PROCESS_GROUP, + None, + None, + &si, + &mut pi, + ); + + if let Err(e) = result { + // Fallback: try without desktop specification + error!("✗ CreateProcessAsUserW failed with desktop specification: {:?}", e); + warn!("Attempting fallback: retrying without desktop specification"); + + info!("Step 6: Fallback attempt - removing desktop specification"); + si.lpDesktop = PWSTR::null(); + info!(" Desktop: NULL (removed)"); + let mut cmdline_wide_retry = to_wide(&cmdline); + + let result_retry = CreateProcessAsUserW( + primary_token, + PCWSTR::null(), + PWSTR(cmdline_wide_retry.as_mut_ptr()), + None, + None, + false, + CREATE_NEW_PROCESS_GROUP, + None, + None, + &si, + &mut pi, + ); + + let _ = CloseHandle(primary_token); + + if let Err(e2) = result_retry { + error!("✗ CreateProcessAsUserW failed again without desktop specification: {:?}", e2); + error!("Both attempts to launch process failed"); + anyhow::bail!("Failed to launch process in user session: {:?}", e2); + } + + info!("Fallback successful - process launched without desktop specification"); + } else { + info!("CreateProcessAsUserW succeeded on first attempt"); + let _ = CloseHandle(primary_token); + } + + let pid = pi.dwProcessId; + let process_handle = pi.hProcess; + + info!("Step 7: Process created successfully"); + info!(" Process ID (PID): {}", pid); + info!(" Process handle: {:?}", process_handle); + info!(" Thread ID: {}", pi.dwThreadId); + info!(" Thread handle: {:?}", pi.hThread); + + // Close thread handle as we don't need it + let _ = CloseHandle(pi.hThread); + info!(" Closed thread handle (not needed for monitoring)"); + + info!("=== Process launched successfully in user session {} with PID {} ===", session_id, pid); + Ok((pid, process_handle)) + } +} + +#[derive(Clone)] +pub struct ToolRunManager { + installed_tools_service: InstalledToolsService, + params_processor: ToolCommandParamsResolver, + tool_kill_service: ToolKillService, + running_tools: Arc>>, + updating_tools: Arc>>, +} + +impl ToolRunManager { + pub fn new( + installed_tools_service: InstalledToolsService, + params_processor: ToolCommandParamsResolver, + tool_kill_service: ToolKillService, + ) -> Self { + Self { + installed_tools_service, + params_processor, + tool_kill_service, + running_tools: Arc::new(RwLock::new(HashSet::new())), + updating_tools: Arc::new(RwLock::new(HashSet::new())), + } + } + + pub async fn mark_updating(&self, tool_id: &str) { + self.updating_tools.write().await.insert(tool_id.to_string()); + info!("Tool {} marked as updating", tool_id); + } + + pub async fn clear_updating(&self, tool_id: &str) { + self.updating_tools.write().await.remove(tool_id); + info!("Tool {} update flag cleared", tool_id); + } + + pub async fn is_updating(&self, tool_id: &str) -> bool { + self.updating_tools.read().await.contains(tool_id) + } + + pub async fn run(&self) -> Result<()> { + info!("Starting tool run manager"); + + let tools = self + .installed_tools_service + .get_all() + .await + .context("Failed to retrieve installed tools list")?; + + if tools.is_empty() { + info!("No installed tools found – nothing to run"); + return Ok(()); + } + + for tool in tools { + if self.try_mark_running(&tool.tool_agent_id).await { + info!("Running tool {}", tool.tool_agent_id); + self.run_tool(tool).await?; + } else { + warn!("Tool {} is already running - skipping", tool.tool_agent_id); + } + } + + Ok(()) + } + + pub async fn run_new_tool(&self, installed_tool: InstalledTool) -> Result<()> { + if !self.try_mark_running(&installed_tool.tool_agent_id).await { + warn!("Tool {} is already running - skipping", installed_tool.tool_agent_id); + return Ok(()); + } + + info!("Running new single tool {}", installed_tool.tool_agent_id); + self.run_tool(installed_tool).await + } + + async fn try_mark_running(&self, tool_id: &str) -> bool { + let mut set = self.running_tools.write().await; + if set.contains(tool_id) { + false + } else { + set.insert(tool_id.to_string()); + true + } + } + + pub async fn clear_running_tool(&self, tool_id: &str) { + let mut set = self.running_tools.write().await; + set.remove(tool_id); + } + + async fn run_tool(&self, tool: InstalledTool) -> Result<()> { + self.tool_kill_service.stop_tool(&tool.tool_agent_id).await?; + + #[cfg(windows)] + let running_tools = self.running_tools.clone(); + + let updating_tools = self.updating_tools.clone(); + let params_processor = self.params_processor.clone(); + tokio::spawn({ + + async move { + loop { + while updating_tools.read().await.contains(&tool.tool_agent_id) { + info!(tool_id = %tool.tool_agent_id, "Tool is being updated, waiting..."); + sleep(Duration::from_secs(1)).await; + } + + // exchange args placeholders to real values + let processed_args = match params_processor.process(&tool.tool_agent_id, tool.run_command_args.clone()) { + Ok(args) => args, + Err(e) => { + error!("Failed to resolve tool {} run command args: {:#}", tool.tool_agent_id, e); + sleep(Duration::from_secs(RETRY_DELAY_SECONDS)).await; + continue; + } + }; + + debug!("Run tool {} with args: {:?}", tool.tool_agent_id, processed_args); + + // Build executable path (always uses app support directory) + let command_path = params_processor.directory_manager + .get_agent_path(&tool.tool_agent_id) + .to_string_lossy() + .to_string(); + + // On Windows, check session type to determine launch method + #[cfg(windows)] + { + use crate::models::SessionType; + + match tool.session_type { + SessionType::User => { + info!("Launching {} in USER session (GUI application)", tool.tool_agent_id); + match launch_process_in_user_session(&command_path, &processed_args) { + Ok((pid, process_handle)) => { + info!("{} launched successfully in USER session with PID: {}", tool.tool_agent_id, pid); + + // Wait for process to exit in blocking thread to avoid blocking async runtime + let exit_code = tokio::task::spawn_blocking(move || { + use windows::Win32::System::Threading::{WaitForSingleObject, INFINITE}; + + unsafe { + let _ = WaitForSingleObject(process_handle, INFINITE); + + // Get exit code + let mut exit_code: u32 = 0; + let _ = GetExitCodeProcess(process_handle, &mut exit_code); + let _ = CloseHandle(process_handle); + + exit_code + } + }).await.unwrap_or(1); + + warn!(tool_id = %tool.tool_agent_id, + "{} process exited with code {} - restarting in {} seconds", + tool.tool_agent_id, exit_code, RETRY_DELAY_SECONDS); + + sleep(Duration::from_secs(RETRY_DELAY_SECONDS)).await; + continue; + } + Err(e) => { + error!(tool_id = %tool.tool_agent_id, error = %e, + "Failed to launch {} in USER session - retrying in {} seconds", + tool.tool_agent_id, RETRY_DELAY_SECONDS); + sleep(Duration::from_secs(RETRY_DELAY_SECONDS)).await; + continue; + } + } + } + SessionType::Console => { + // Temporarily skipping console mode since in this mode only the mesh agent runs, + // which is now installed separately as a service. + info!(tool_id = %tool.tool_agent_id, "SessionType::Console - skipping launch"); + let mut set = running_tools.write().await; + set.remove(&tool.tool_agent_id); + return; + } + SessionType::Service => { + info!("Launching {} as SERVICE (standard spawn)", tool.tool_agent_id); + // Continue to standard spawn below + } + } + + // If we reached here and session_type is Service, continue to standard spawn + if tool.session_type != SessionType::Service { + // User or Console sessions are handled above and continue the loop + // This should never be reached, but just in case + continue; + } + } + + // For all other tools (or non-Windows), use standard spawn + let mut child = match Command::new(&command_path) + .args(&processed_args) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + { + Ok(child) => child, + Err(e) => { + error!(tool_id = %tool.tool_agent_id, error = %e, + "Failed to start tool process - retrying in {} seconds", RETRY_DELAY_SECONDS); + sleep(Duration::from_secs(RETRY_DELAY_SECONDS)).await; + continue; + } + }; + + // Capture stdout + if let Some(stdout) = child.stdout.take() { + let tool_id_clone = tool.tool_agent_id.clone(); + tokio::spawn(async move { + let reader = BufReader::new(stdout); + let mut lines = reader.lines(); + while let Ok(Some(line)) = lines.next_line().await { + info!(tool_id = %tool_id_clone, "[STDOUT] {}", line); + } + }); + } + + // Capture stderr + if let Some(stderr) = child.stderr.take() { + let tool_id_clone = tool.tool_agent_id.clone(); + tokio::spawn(async move { + let reader = BufReader::new(stderr); + let mut lines = reader.lines(); + while let Ok(Some(line)) = lines.next_line().await { + warn!(tool_id = %tool_id_clone, "[STDERR] {}", line); + } + }); + } + + match child.wait().await { + Ok(status) => { + if status.success() { + warn!(tool_id = %tool.tool_agent_id, + "Tool completed successfully but should keep running - restarting in {} seconds", + RETRY_DELAY_SECONDS); + sleep(Duration::from_secs(RETRY_DELAY_SECONDS)).await; + } else { + error!(tool_id = %tool.tool_agent_id, exit_status = %status, + "Tool failed with exit status - restarting in {} seconds", RETRY_DELAY_SECONDS); + sleep(Duration::from_secs(RETRY_DELAY_SECONDS)).await; + } + } + Err(e) => { + error!(tool_id = %tool.tool_agent_id, error = %e, + "Failed to wait for tool process - restarting in {} seconds: {:#}", RETRY_DELAY_SECONDS, e); + sleep(Duration::from_secs(RETRY_DELAY_SECONDS)).await; + } + } + } + } + }); + + Ok(()) + } +} diff --git a/openframe-agent-lib/src/services/tool_uninstall_service.rs b/openframe-agent-lib/src/services/tool_uninstall_service.rs new file mode 100644 index 000000000..6d38b57a6 --- /dev/null +++ b/openframe-agent-lib/src/services/tool_uninstall_service.rs @@ -0,0 +1,178 @@ +#[cfg(target_os = "windows")] +use crate::platform::file_lock::log_file_lock_info; +use crate::platform::DirectoryManager; +use crate::services::InstalledToolsService; +use crate::services::ToolCommandParamsResolver; +use crate::services::ToolKillService; +use anyhow::{Context, Result}; +use tokio::process::Command; +use tracing::{debug, info, warn}; + +#[derive(Clone)] +pub struct ToolUninstallService { + installed_tools_service: InstalledToolsService, + command_params_resolver: ToolCommandParamsResolver, + tool_kill_service: ToolKillService, + directory_manager: DirectoryManager, +} + +impl ToolUninstallService { + pub fn new( + installed_tools_service: InstalledToolsService, + command_params_resolver: ToolCommandParamsResolver, + tool_kill_service: ToolKillService, + directory_manager: DirectoryManager, + ) -> Self { + Self { + installed_tools_service, + command_params_resolver, + tool_kill_service, + directory_manager, + } + } + + /// Uninstall all installed tools by running their uninstallation commands + /// + /// This method will fail immediately if any tool fails to uninstall. + /// No partial success - either all tools are uninstalled or the operation fails. + pub async fn uninstall_all(&self) -> Result<()> { + info!("Starting uninstallation of all installed tools"); + + let installed_tools = self + .installed_tools_service + .get_all() + .await + .context("Failed to retrieve installed tools")?; + + if installed_tools.is_empty() { + info!("No installed tools found to uninstall"); + return Ok(()); + } + + info!( + "Found {} installed tools to uninstall", + installed_tools.len() + ); + + for tool in installed_tools { + info!("Processing uninstallation for tool: {}", tool.tool_agent_id); + + // Fail immediately if uninstallation fails + self.uninstall_tool(&tool) + .await + .with_context(|| format!("Failed to uninstall tool: {}", tool.tool_agent_id))?; + + info!("Successfully uninstalled tool: {}", tool.tool_agent_id); + } + + info!("All tools uninstalled successfully"); + Ok(()) + } + + /// Uninstall a single tool by running its uninstallation command + /// + /// Fails immediately if any step fails (stop process, run uninstall command, remove files) + async fn uninstall_tool(&self, tool: &crate::models::InstalledTool) -> Result<()> { + let tool_agent_id = &tool.tool_agent_id; + + // Stop the tool process before uninstalling - fail if we can't stop it + info!( + "Stopping tool process before uninstallation: {}", + tool_agent_id + ); + self.tool_kill_service + .stop_tool(tool_agent_id) + .await + .with_context(|| format!("Failed to stop tool process for: {}", tool_agent_id))?; + + // TODO: make this stop from fleet orbit side or using asset path + // Now it's dirty solution to stop osquery manually + if tool.tool_agent_id.to_lowercase().contains("fleet") { + info!("Stopping osqueryd for tool: {}", tool_agent_id); + self.tool_kill_service + .stop_asset("osqueryd", tool_agent_id) + .await + .with_context(|| format!("Failed to stop tool process for: {}", tool_agent_id))?; + info!("Successfully stopped osqueryd for tool: {}", tool_agent_id); + } else { + info!("Not stopping osqueryd for tool: {}", tool_agent_id); + } + + // Check if uninstallation command is provided + if tool.uninstallation_command_args.is_none() { + info!( + "No uninstallation command provided for tool: {}, skipping", + tool_agent_id + ); + return Ok(()); + } + + let uninstall_args = tool.uninstallation_command_args.as_ref().unwrap(); + + if uninstall_args.is_empty() { + info!( + "Empty uninstallation command for tool: {}, skipping", + tool_agent_id + ); + return Ok(()); + } + + // Process command parameters (replace placeholders) + let processed_args = self + .command_params_resolver + .process(tool_agent_id, uninstall_args.clone()) + .context("Failed to process uninstallation command parameters")?; + + debug!( + "Processed uninstallation args for {}: {:?}", + tool_agent_id, processed_args + ); + + // Get the tool agent executable path + let agent_path = self.directory_manager.get_agent_path(tool_agent_id); + + if !agent_path.exists() { + warn!( + "Tool agent executable not found at {}, skipping uninstallation command", + agent_path.display() + ); + return Ok(()); + } + + info!("Running uninstallation command for tool: {}", tool_agent_id); + + // Execute uninstallation command + let mut cmd = Command::new(&agent_path); + cmd.args(&processed_args); + + let output = cmd + .output() + .await + .context("Failed to execute uninstallation command")?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + let stdout = String::from_utf8_lossy(&output.stdout); + + // Fail immediately if uninstall command returns non-zero exit code + return Err(anyhow::anyhow!( + "Uninstallation command for {} exited with status: {}\nstdout: {}\nstderr: {}", + tool_agent_id, + output.status, + stdout, + stderr + )); + } + + let stdout = String::from_utf8_lossy(&output.stdout); + info!( + "Uninstallation command executed successfully for tool: {}\nstdout: {}", + tool_agent_id, stdout + ); + + // Note: Tool-specific directory cleanup is handled automatically when the main + // OpenFrame application is uninstalled, as it's within the app support directory + + Ok(()) + } +} diff --git a/openframe-agent-lib/src/services/tool_url_params_resolver.rs b/openframe-agent-lib/src/services/tool_url_params_resolver.rs new file mode 100644 index 000000000..ec43039c1 --- /dev/null +++ b/openframe-agent-lib/src/services/tool_url_params_resolver.rs @@ -0,0 +1,25 @@ +use anyhow::Result; +use crate::services::InitialConfigurationService; + +#[derive(Clone)] +pub struct ToolUrlParamsResolver { + pub initial_configuration_service: InitialConfigurationService, +} + +impl ToolUrlParamsResolver { + const SERVER_URL_PLACEHOLDER: &'static str = "${client.serverUrl}"; + + pub fn new(initial_configuration_service: InitialConfigurationService) -> Self { + Self { + initial_configuration_service + } + } + + pub fn process(&self, url_path: &str) -> Result { + let server_url = self.initial_configuration_service.get_server_url()?; + + Ok(url_path + .replace(Self::SERVER_URL_PLACEHOLDER, &server_url)) + } +} + diff --git a/openframe-agent-lib/src/services/update_cleanup_service.rs b/openframe-agent-lib/src/services/update_cleanup_service.rs new file mode 100644 index 000000000..6aaaa0511 --- /dev/null +++ b/openframe-agent-lib/src/services/update_cleanup_service.rs @@ -0,0 +1,91 @@ +use anyhow::{Context, Result}; +use std::fs; +use std::path::PathBuf; +use tracing::{info, warn}; + +#[derive(Clone)] +pub struct UpdateCleanupService { + exe_path: PathBuf, +} + +impl UpdateCleanupService { + pub fn new() -> Result { + let exe_path = std::env::current_exe() + .context("Failed to get current executable path")?; + + Ok(Self { exe_path }) + } + + pub async fn cleanup_all(&self) { + let mut cleaned = 0; + + match self.cleanup_all_old_backups().await { + Ok(count) => cleaned += count, + Err(e) => warn!("Failed to cleanup old backups: {:#}", e), + } + + match self.cleanup_all_old_logs().await { + Ok(count) => cleaned += count, + Err(e) => warn!("Failed to cleanup old logs: {:#}", e), + } + + if cleaned > 0 { + info!("Cleanup: removed {} old files", cleaned); + } + } + + async fn cleanup_all_old_backups(&self) -> Result { + let exe_dir = self.exe_path.parent() + .context("Failed to get executable directory")?; + + let exe_name = self.exe_path.file_name() + .context("Failed to get executable name")? + .to_string_lossy(); + + let backup_pattern = format!("{}.backup.", exe_name); + + let mut cleaned = 0; + if let Ok(entries) = fs::read_dir(exe_dir) { + for entry in entries.filter_map(|e| e.ok()) { + let file_name = entry.file_name().to_string_lossy().to_string(); + if file_name.starts_with(&backup_pattern) { + match fs::remove_file(entry.path()) { + Ok(_) => { + info!("Removed old backup: {}", entry.path().display()); + cleaned += 1; + } + Err(e) => { + warn!("Failed to remove backup {}: {}", entry.path().display(), e); + } + } + } + } + } + + Ok(cleaned) + } + + async fn cleanup_all_old_logs(&self) -> Result { + let temp_dir = std::env::temp_dir(); + + let mut cleaned = 0; + if let Ok(entries) = fs::read_dir(&temp_dir) { + for entry in entries.filter_map(|e| e.ok()) { + let name = entry.file_name().to_string_lossy().to_string(); + if name.starts_with("openframe-update-") && name.ends_with(".log") { + match fs::remove_file(entry.path()) { + Ok(_) => { + info!("Removed old log: {}", entry.path().display()); + cleaned += 1; + } + Err(e) => { + warn!("Failed to remove log {}: {}", entry.path().display(), e); + } + } + } + } + } + + Ok(cleaned) + } +} diff --git a/openframe-agent-lib/src/services/update_handler_service.rs b/openframe-agent-lib/src/services/update_handler_service.rs new file mode 100644 index 000000000..f0ae52c32 --- /dev/null +++ b/openframe-agent-lib/src/services/update_handler_service.rs @@ -0,0 +1,123 @@ +use anyhow::{Context, Result}; +use tracing::{info, warn}; +use crate::models::update_state::{UpdateState, UpdatePhase}; +use crate::models::openframe_client_info::ClientUpdateStatus; +use crate::services::update_state_service::UpdateStateService; +use crate::services::openframe_client_info_service::OpenFrameClientInfoService; +use crate::services::update_cleanup_service::UpdateCleanupService; +use crate::services::installed_agent_message_publisher::InstalledAgentMessagePublisher; +use crate::services::agent_configuration_service::AgentConfigurationService; +#[derive(Clone)] +pub struct UpdateHandlerService { + state_service: UpdateStateService, + client_info_service: OpenFrameClientInfoService, + cleanup_service: UpdateCleanupService, + installed_agent_publisher: InstalledAgentMessagePublisher, + config_service: AgentConfigurationService, +} + +impl UpdateHandlerService { + pub fn new( + state_service: UpdateStateService, + client_info_service: OpenFrameClientInfoService, + cleanup_service: UpdateCleanupService, + installed_agent_publisher: InstalledAgentMessagePublisher, + config_service: AgentConfigurationService, + ) -> Self { + Self { + state_service, + client_info_service, + cleanup_service, + installed_agent_publisher, + config_service, + } + } + + pub async fn handle_pending_update(&self) -> Result<()> { + let update_state = match self.state_service.load().await? { + Some(state) => state, + None => return Ok(()), + }; + + info!("Found update state: version={}, phase={:?}", update_state.target_version, update_state.phase); + + let update_succeeded = if update_state.phase == UpdatePhase::Completed { + true + } else { + let client_info = self.client_info_service.get().await?; + client_info.current_version == update_state.target_version + }; + + if update_succeeded { + self.handle_success(update_state).await + } else { + self.handle_failure(update_state).await + } + } + + async fn handle_success(&self, state: UpdateState) -> Result<()> { + info!("Update to {} succeeded", state.target_version); + + self.client_info_service + .update_version(state.target_version.clone()) + .await + .context("Failed to update client version")?; + + self.client_info_service + .set_update_status(ClientUpdateStatus::Updated, Some(state.target_version.clone())) + .await + .context("Failed to set update status")?; + + self.send_nats_notification(&state.target_version).await; + + self.cleanup_service.cleanup_all().await; + self.state_service.clear().await?; + + info!("Update completed, notified backend, cleaned up"); + Ok(()) + } + + async fn handle_failure(&self, state: UpdateState) -> Result<()> { + info!("Update to {} failed (PowerShell rollback done)", state.target_version); + + self.client_info_service + .set_update_status(ClientUpdateStatus::Failed, Some(state.target_version.clone())) + .await + .context("Failed to set update status")?; + + self.cleanup_service.cleanup_all().await; + self.state_service.clear().await?; + + info!("Update marked as failed, NATS will retry"); + Ok(()) + } + + async fn send_nats_notification(&self, version: &str) { + match self.config_service.get_machine_id().await { + Ok(machine_id) => { + for attempt in 1..=5 { + match self.installed_agent_publisher + .publish(machine_id.clone(), "openframe-client".to_string(), version.to_string()) + .await + { + Ok(_) => { + info!("Successfully published NATS notification for update to {}", version); + return; + } + Err(e) => { + if attempt < 5 { + warn!("Failed to publish NATS notification (attempt {}/5): {:#}. Retrying...", attempt, e); + tokio::time::sleep(tokio::time::Duration::from_secs(1)).await; + } else { + warn!("Failed to publish NATS notification after 5 attempts: {:#}", e); + } + } + } + } + } + Err(e) => { + warn!("Failed to get machine_id for NATS notification: {:#}", e); + } + } + } +} diff --git a/openframe-agent-lib/src/services/update_state_service.rs b/openframe-agent-lib/src/services/update_state_service.rs new file mode 100644 index 000000000..f71a4716f --- /dev/null +++ b/openframe-agent-lib/src/services/update_state_service.rs @@ -0,0 +1,76 @@ +use anyhow::{Context, Result}; +use std::fs; +use std::path::PathBuf; +use tracing::{info, debug}; +use crate::models::update_state::UpdateState; +use crate::platform::directories::DirectoryManager; + +#[derive(Clone)] +pub struct UpdateStateService { + state_file_path: PathBuf, +} + +impl UpdateStateService { + pub fn new(directory_manager: DirectoryManager) -> Result { + let state_file_path = directory_manager.secured_dir().join("update_state.json"); + + directory_manager.ensure_directories() + .with_context(|| "Failed to ensure secured directory exists")?; + + Ok(Self { + state_file_path + }) + } + + pub async fn load(&self) -> Result> { + info!("Checking for update state file at: {}", self.state_file_path.display()); + + if !self.state_file_path.exists() { + info!("No update state file found at: {}", self.state_file_path.display()); + return Ok(None); + } + + let json_content = fs::read_to_string(&self.state_file_path) + .with_context(|| format!("Failed to read update state file: {:?}", self.state_file_path))?; + + info!("Read update state file content: {}", json_content); + + let state: UpdateState = serde_json::from_str(&json_content) + .context("Failed to deserialize update state from JSON")?; + + info!("Loaded update state for version: {}, phase: {:?}", state.target_version, state.phase); + Ok(Some(state)) + } + + pub async fn save(&self, state: &UpdateState) -> Result<()> { + let json_content = serde_json::to_string_pretty(state) + .context("Failed to serialize update state to JSON")?; + + fs::write(&self.state_file_path, json_content) + .with_context(|| format!("Failed to write update state file: {:?}", self.state_file_path))?; + + debug!("Saved update state for version: {}, phase: {:?}", state.target_version, state.phase); + Ok(()) + } + + pub async fn clear(&self) -> Result<()> { + if self.state_file_path.exists() { + fs::remove_file(&self.state_file_path) + .with_context(|| format!("Failed to remove update state file: {:?}", self.state_file_path))?; + + info!("Cleared update state"); + } + Ok(()) + } + + pub async fn has_incomplete_update(&self) -> Result { + match self.load().await? { + Some(_state) => Ok(true), // If state exists, recovery is needed + None => Ok(false), + } + } + + pub fn get_state_file_path(&self) -> String { + self.state_file_path.to_string_lossy().to_string() + } +} diff --git a/openframe-agent-lib/src/system.rs b/openframe-agent-lib/src/system.rs new file mode 100644 index 000000000..61350d88a --- /dev/null +++ b/openframe-agent-lib/src/system.rs @@ -0,0 +1,61 @@ +use anyhow::Result; +use serde::Serialize; +use std::sync::Mutex; +use std::time::{SystemTime, UNIX_EPOCH}; +use sysinfo::{CpuRefreshKind, MemoryRefreshKind, RefreshKind, System}; + +#[derive(Debug, Serialize)] +pub struct SystemMetrics { + timestamp: u64, + cpu_usage: f32, + memory_total: u64, + memory_used: u64, + disk_total: u64, + disk_used: u64, + uptime: u64, +} + +pub struct SystemInfo { + sys: Mutex, +} + +impl SystemInfo { + pub fn new() -> Result { + let sys = System::new_with_specifics( + RefreshKind::new() + .with_cpu(CpuRefreshKind::everything()) + .with_memory(MemoryRefreshKind::everything()), + ); + Ok(Self { + sys: Mutex::new(sys), + }) + } + + pub fn collect_metrics(&self) -> Result { + let mut sys = self.sys.lock().unwrap(); + sys.refresh_cpu(); + sys.refresh_memory(); + + let cpu_usage = sys.global_cpu_info().cpu_usage(); + let memory_total = sys.total_memory(); + let memory_used = sys.used_memory(); + + // For disk space, we'll use sys-info + let mut disk_total = 0; + let mut disk_used = 0; + if let Ok(space) = sys_info::disk_info() { + disk_total = space.total * 1024; // Convert to bytes + disk_used = (space.total - space.free) * 1024; + } + + Ok(SystemMetrics { + timestamp: SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs(), + cpu_usage, + memory_total, + memory_used, + disk_total, + disk_used, + uptime: 0, // TODO: remove as deprecated class + }) + } +} diff --git a/openframe-agent-lib/src/utils/version_comparator.rs b/openframe-agent-lib/src/utils/version_comparator.rs new file mode 100644 index 000000000..49c6c5bcf --- /dev/null +++ b/openframe-agent-lib/src/utils/version_comparator.rs @@ -0,0 +1,20 @@ +pub struct VersionComparator; + +// TODO: use during version update feature +impl VersionComparator { + + pub fn compare(&self, v1: &str, v2: &str) -> std::cmp::Ordering { + let v1 = semver::Version::parse(&self.normalize(v1)).unwrap(); + let v2 = semver::Version::parse(&self.normalize(v2)).unwrap(); + v1.cmp(&v2) + } + + fn normalize(&self, v: &str) -> String { + let parts: Vec<&str> = v.split('.').collect(); + match parts.len() { + 1 => format!("{}.0.0", parts[0]), + 2 => format!("{}.0", v), + _ => v.to_string(), + } + } +}