diff --git a/crates/devolutions-agent-shared/src/update_json.rs b/crates/devolutions-agent-shared/src/update_json.rs index 09d4e09bb..8bd95a12b 100644 --- a/crates/devolutions-agent-shared/src/update_json.rs +++ b/crates/devolutions-agent-shared/src/update_json.rs @@ -7,6 +7,9 @@ use std::fmt; /// { /// "Gateway": { /// "TargetVersion": "1.2.3.4" +/// }, +/// "HubService": { +/// "TargetVersion": "latest" /// } /// } /// ``` @@ -16,6 +19,8 @@ use std::fmt; pub struct UpdateJson { #[serde(skip_serializing_if = "Option::is_none")] pub gateway: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub hub_service: Option, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] diff --git a/crates/devolutions-agent-shared/src/windows/mod.rs b/crates/devolutions-agent-shared/src/windows/mod.rs index e4c90d190..57dff52d4 100644 --- a/crates/devolutions-agent-shared/src/windows/mod.rs +++ b/crates/devolutions-agent-shared/src/windows/mod.rs @@ -16,3 +16,7 @@ pub const GATEWAY_UPDATE_CODE: Uuid = uuid!("{db3903d6-c451-4393-bd80-eb9f45b902 /// /// See [`GATEWAY_UPDATE_CODE`] for more information on update codes. pub const AGENT_UPDATE_CODE: Uuid = uuid!("{82318d3c-811f-4d5d-9a82-b7c31b076755}"); +/// MSI upgrade code for the Devolutions Hub Service. +/// +/// See [`GATEWAY_UPDATE_CODE`] for more information on update codes. +pub const HUB_SERVICE_UPDATE_CODE: Uuid = uuid!("{f437046e-8e13-430a-8c8f-29fcb9023b59}"); diff --git a/devolutions-agent/src/updater/detect.rs b/devolutions-agent/src/updater/detect.rs index 095982be7..5bae67e8d 100644 --- a/devolutions-agent/src/updater/detect.rs +++ b/devolutions-agent/src/updater/detect.rs @@ -2,7 +2,7 @@ use uuid::Uuid; use devolutions_agent_shared::DateVersion; -use devolutions_agent_shared::windows::{GATEWAY_UPDATE_CODE, registry}; +use devolutions_agent_shared::windows::{GATEWAY_UPDATE_CODE, HUB_SERVICE_UPDATE_CODE, registry}; use crate::updater::{Product, UpdaterError}; @@ -12,11 +12,17 @@ pub(crate) fn get_installed_product_version(product: Product) -> Result { registry::get_installed_product_version(GATEWAY_UPDATE_CODE).map_err(UpdaterError::WindowsRegistry) } + Product::HubService => { + registry::get_installed_product_version(HUB_SERVICE_UPDATE_CODE).map_err(UpdaterError::WindowsRegistry) + } } } pub(crate) fn get_product_code(product: Product) -> Result, UpdaterError> { match product { Product::Gateway => registry::get_product_code(GATEWAY_UPDATE_CODE).map_err(UpdaterError::WindowsRegistry), + Product::HubService => { + registry::get_product_code(HUB_SERVICE_UPDATE_CODE).map_err(UpdaterError::WindowsRegistry) + } } } diff --git a/devolutions-agent/src/updater/mod.rs b/devolutions-agent/src/updater/mod.rs index b2b2ea30c..324a3a9e4 100644 --- a/devolutions-agent/src/updater/mod.rs +++ b/devolutions-agent/src/updater/mod.rs @@ -35,7 +35,7 @@ pub(crate) use self::product::Product; const UPDATE_JSON_WATCH_INTERVAL: Duration = Duration::from_secs(3); // List of updateable products could be extended in future -const PRODUCTS: &[Product] = &[Product::Gateway]; +const PRODUCTS: &[Product] = &[Product::Gateway, Product::HubService]; /// Context for updater task struct UpdaterCtx { @@ -210,8 +210,16 @@ async fn read_update_json(update_file_path: &Utf8Path) -> anyhow::Result Result<(), UpdaterError> { match ctx.product { - Product::Gateway => install_msi(ctx, path, log_path).await, + Product::Gateway | Product::HubService => install_msi(ctx, path, log_path).await, } } @@ -32,7 +32,7 @@ pub(crate) async fn uninstall_package( log_path: &Utf8Path, ) -> Result<(), UpdaterError> { match ctx.product { - Product::Gateway => uninstall_msi(ctx, product_code, log_path).await, + Product::Gateway | Product::HubService => uninstall_msi(ctx, product_code, log_path).await, } } @@ -67,14 +67,46 @@ async fn install_msi(ctx: &UpdaterCtx, path: &Utf8Path, log_path: &Utf8Path) -> } } - if msi_install_result.is_err() { - return Err(UpdaterError::MsiInstall { - product: ctx.product, - msi_path: path.to_owned(), - }); + match msi_install_result { + Ok(status) => { + let exit_code = status.code().unwrap_or(-1); + + // MSI exit codes: + // 0 = Success + // 3010 = Success but reboot required (unexpected - our installers shouldn't require reboot) + // 1641 = Success and reboot initiated + // Other codes = Error + match exit_code { + 0 => { + info!("MSI installation completed successfully"); + Ok(()) + } + 3010 | 1641 => { + // Our installers should not require a reboot, but if they do, log as warning + // and continue since the installation technically succeeded + warn!( + %exit_code, + "MSI installation completed but unexpectedly requires system reboot" + ); + Ok(()) + } + _ => { + error!(%exit_code, "MSI installation failed with exit code"); + Err(UpdaterError::MsiInstall { + product: ctx.product, + msi_path: path.to_owned(), + }) + } + } + } + Err(_) => { + error!("Failed to execute msiexec command"); + Err(UpdaterError::MsiInstall { + product: ctx.product, + msi_path: path.to_owned(), + }) + } } - - Ok(()) } async fn uninstall_msi(ctx: &UpdaterCtx, product_code: Uuid, log_path: &Utf8Path) -> Result<(), UpdaterError> { @@ -101,14 +133,47 @@ async fn uninstall_msi(ctx: &UpdaterCtx, product_code: Uuid, log_path: &Utf8Path } } - if msi_uninstall_result.is_err() { - return Err(UpdaterError::MsiUninstall { - product: ctx.product, - product_code, - }); + match msi_uninstall_result { + Ok(status) => { + let exit_code = status.code().unwrap_or(-1); + + // MSI exit codes: + // 0 = Success + // 3010 = Success but reboot required (unexpected - our installers shouldn't require reboot) + // 1641 = Success and reboot initiated + // Other codes = Error + match exit_code { + 0 => { + info!(%product_code, "MSI uninstallation completed successfully"); + Ok(()) + } + 3010 | 1641 => { + // Our installers should not require a reboot, but if they do, log as warning + // and continue since the uninstallation technically succeeded + warn!( + %exit_code, + %product_code, + "MSI uninstallation completed but unexpectedly requires system reboot" + ); + Ok(()) + } + _ => { + error!(%exit_code, %product_code, "MSI uninstallation failed with exit code"); + Err(UpdaterError::MsiUninstall { + product: ctx.product, + product_code, + }) + } + } + } + Err(_) => { + error!(%product_code, "Failed to execute msiexec command"); + Err(UpdaterError::MsiUninstall { + product: ctx.product, + product_code, + }) + } } - - Ok(()) } fn ensure_enough_rights() -> Result<(), UpdaterError> { @@ -159,7 +224,7 @@ fn ensure_enough_rights() -> Result<(), UpdaterError> { pub(crate) fn validate_package(ctx: &UpdaterCtx, path: &Utf8Path) -> Result<(), UpdaterError> { match ctx.product { - Product::Gateway => validate_msi(ctx, path), + Product::Gateway | Product::HubService => validate_msi(ctx, path), } } diff --git a/devolutions-agent/src/updater/product.rs b/devolutions-agent/src/updater/product.rs index c8e5d93cf..d6a1644df 100644 --- a/devolutions-agent/src/updater/product.rs +++ b/devolutions-agent/src/updater/product.rs @@ -3,18 +3,20 @@ use std::str::FromStr; use devolutions_agent_shared::{ProductUpdateInfo, UpdateJson}; -use crate::updater::productinfo::GATEWAY_PRODUCT_ID; +use crate::updater::productinfo::{GATEWAY_PRODUCT_ID, HUB_SERVICE_PRODUCT_ID}; /// Product IDs to track updates for #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum Product { Gateway, + HubService, } impl fmt::Display for Product { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Product::Gateway => write!(f, "Gateway"), + Product::HubService => write!(f, "HubService"), } } } @@ -25,6 +27,7 @@ impl FromStr for Product { fn from_str(s: &str) -> Result { match s { "Gateway" => Ok(Product::Gateway), + "HubService" => Ok(Product::HubService), _ => Err(()), } } @@ -34,18 +37,21 @@ impl Product { pub(crate) fn get_update_info(self, update_json: &UpdateJson) -> Option { match self { Product::Gateway => update_json.gateway.clone(), + Product::HubService => update_json.hub_service.clone(), } } pub(crate) const fn get_productinfo_id(self) -> &'static str { match self { Product::Gateway => GATEWAY_PRODUCT_ID, + Product::HubService => HUB_SERVICE_PRODUCT_ID, } } pub(crate) const fn get_package_extension(self) -> &'static str { match self { Product::Gateway => "msi", + Product::HubService => "msi", } } } diff --git a/devolutions-agent/src/updater/product_actions.rs b/devolutions-agent/src/updater/product_actions.rs index 67c634d2d..3b745eafd 100644 --- a/devolutions-agent/src/updater/product_actions.rs +++ b/devolutions-agent/src/updater/product_actions.rs @@ -2,7 +2,18 @@ use win_api_wrappers::service::{ServiceManager, ServiceStartupMode}; use crate::updater::{Product, UpdaterError}; -const SERVICE_NAME: &str = "DevolutionsGateway"; +const GATEWAY_SERVICE_NAME: &str = "DevolutionsGateway"; + +// Hub Service installs up to 3 separate Windows services (depending on selected features) +// Service Name -> MSI Feature mapping for ADDLOCAL parameter: +// "Devolutions Hub PAM Service" -> "PAM" +// "Devolutions Hub Encryption Service" -> "Encryption" +// "Devolutions Hub Reporting Service" -> "Reporting" +const HUB_SERVICE_NAMES: &[&str] = &[ + "Devolutions Hub PAM Service", + "Devolutions Hub Encryption Service", + "Devolutions Hub Reporting Service", +]; /// Additional actions that need to be performed during product update process pub(crate) trait ProductUpdateActions { @@ -11,52 +22,124 @@ pub(crate) trait ProductUpdateActions { fn post_update(&mut self) -> Result<(), UpdaterError>; } -/// Gateway specific update actions -#[derive(Default)] -struct GatewayUpdateActions { - service_was_running: bool, - service_startup_was_automatic: bool, +/// State information for a single service +#[derive(Debug)] +struct ServiceState { + name: &'static str, + exists: bool, + was_running: bool, + startup_was_automatic: bool, } -impl GatewayUpdateActions { +/// Generic service update actions for Windows service-based products +struct ServiceUpdateActions { + product: Product, + service_states: Vec, +} + +impl ServiceUpdateActions { + fn new_single_service(product: Product, service_name: &'static str) -> Self { + Self { + product, + service_states: vec![ServiceState { + name: service_name, + exists: false, + was_running: false, + startup_was_automatic: false, + }], + } + } + + fn new_multi_service(product: Product, service_names: &'static [&'static str]) -> Self { + Self { + product, + service_states: service_names + .iter() + .map(|&name| ServiceState { + name, + exists: false, + was_running: false, + startup_was_automatic: false, + }) + .collect(), + } + } + fn pre_update_impl(&mut self) -> anyhow::Result<()> { - info!("Querying service state for Gateway"); + info!("Querying service states for {}", self.product); let service_manager = ServiceManager::open_read()?; - let service = service_manager.open_service_read(SERVICE_NAME)?; - - self.service_startup_was_automatic = service.startup_mode()? == ServiceStartupMode::Automatic; - self.service_was_running = service.is_running()?; - info!( - "Service state for Gateway before update: running: {}, automatic_startup: {}", - self.service_was_running, self.service_startup_was_automatic - ); + for state in &mut self.service_states { + // Try to open the service - it may not exist if it wasn't installed (e.g., optional Hub features) + match service_manager.open_service_read(state.name) { + Ok(service) => { + state.exists = true; + state.startup_was_automatic = service.startup_mode()? == ServiceStartupMode::Automatic; + state.was_running = service.is_running()?; + + info!( + "Service '{}' found - running: {}, automatic_startup: {}", + state.name, state.was_running, state.startup_was_automatic + ); + } + Err(e) => { + state.exists = false; + debug!("Service '{}' not found (feature not installed): {}", state.name, e); + // Keep defaults (exists: false, was_running: false, startup_was_automatic: false) + } + } + } Ok(()) } fn post_update_impl(&self) -> anyhow::Result<()> { - // Start service if it was running prior to the update, but service startup - // was set to manual. - if !self.service_startup_was_automatic && self.service_was_running { - info!("Starting Gateway service after update"); - - let service_manager = ServiceManager::open_all_access()?; - let service = service_manager.open_service_all_access(SERVICE_NAME)?; - service.start()?; - - info!("Gateway service started"); + let service_manager = ServiceManager::open_all_access()?; + + for state in &self.service_states { + // Skip services that weren't installed before the update + if !state.exists { + debug!("Skipping service '{}' (was not installed)", state.name); + continue; + } + + match service_manager.open_service_all_access(state.name) { + Ok(service) => { + // Start service if it was running prior to the update + // For Gateway: only if startup was manual (automatic services will auto-start) + // For Hub Service: always start if it was running, since we can't control + // startup mode via P.SERVICESTART parameter + let should_start = match self.product { + Product::Gateway => !state.startup_was_automatic && state.was_running, + Product::HubService => state.was_running, + }; + + if should_start { + info!("Starting '{}' service after update", state.name); + service.start()?; + info!("Service '{}' started", state.name); + } else { + debug!( + "Service '{}' doesn't need manual restart (automatic_startup: {}, was_running: {})", + state.name, state.startup_was_automatic, state.was_running + ); + } + } + Err(e) => { + warn!("Failed to access service '{}' after update: {}", state.name, e); + } + } } Ok(()) } } -impl ProductUpdateActions for GatewayUpdateActions { +impl ProductUpdateActions for ServiceUpdateActions { fn pre_update(&mut self) -> Result<(), UpdaterError> { self.pre_update_impl() .map_err(|source| UpdaterError::QueryServiceState { - product: Product::Gateway, + product: self.product, source, }) } @@ -64,10 +147,44 @@ impl ProductUpdateActions for GatewayUpdateActions { fn get_msiexec_install_params(&self) -> Vec { // When performing update, we want to make sure the service startup mode is restored to the // previous state. (Installer sets Manual by default). - if self.service_startup_was_automatic { - info!("Adjusting MSIEXEC parameters for Gateway service startup mode"); - return vec!["P.SERVICESTART=Automatic".to_owned()]; + match self.product { + Product::Gateway => { + // Gateway installer supports P.SERVICESTART property + if self.service_states.len() == 1 && self.service_states[0].startup_was_automatic { + info!("Adjusting MSIEXEC parameters for Gateway service startup mode"); + return vec!["P.SERVICESTART=Automatic".to_owned()]; + } + } + Product::HubService => { + // Hub Service installer requires ADDLOCAL parameter to specify which services to install. + // Build the list based on currently installed services. + let mut features = Vec::new(); + + for state in &self.service_states { + if state.exists { + // Map service name to MSI feature name + let feature = match state.name { + "Devolutions Hub PAM Service" => "PAM", + "Devolutions Hub Encryption Service" => "Encryption", + "Devolutions Hub Reporting Service" => "Reporting", + _ => { + warn!("Unknown Hub Service: {}", state.name); + continue; + } + }; + features.push(feature); + } + } + + if !features.is_empty() { + let addlocal = format!("ADDLOCAL={}", features.join(",")); + info!("Adjusting MSIEXEC parameters for Hub Service features: {}", addlocal); + return vec![addlocal]; + } else { + warn!("No Hub Service features detected, installer may use defaults"); + } + } } Vec::new() @@ -75,7 +192,7 @@ impl ProductUpdateActions for GatewayUpdateActions { fn post_update(&mut self) -> Result<(), UpdaterError> { self.post_update_impl().map_err(|source| UpdaterError::StartService { - product: Product::Gateway, + product: self.product, source, }) } @@ -83,6 +200,13 @@ impl ProductUpdateActions for GatewayUpdateActions { pub(crate) fn build_product_actions(product: Product) -> Box { match product { - Product::Gateway => Box::new(GatewayUpdateActions::default()), + Product::Gateway => Box::new(ServiceUpdateActions::new_single_service( + Product::Gateway, + GATEWAY_SERVICE_NAME, + )), + Product::HubService => Box::new(ServiceUpdateActions::new_multi_service( + Product::HubService, + HUB_SERVICE_NAMES, + )), } } diff --git a/devolutions-agent/src/updater/productinfo/mod.rs b/devolutions-agent/src/updater/productinfo/mod.rs index 1a16ac8f5..66ed87406 100644 --- a/devolutions-agent/src/updater/productinfo/mod.rs +++ b/devolutions-agent/src/updater/productinfo/mod.rs @@ -7,4 +7,6 @@ pub(crate) const GATEWAY_PRODUCT_ID: &str = "Gatewaybin"; #[cfg(not(windows))] pub(crate) const GATEWAY_PRODUCT_ID: &str = "GatewaybinDebX64"; +pub(crate) const HUB_SERVICE_PRODUCT_ID: &str = "HubServicesbin"; + pub(crate) use db::ProductInfoDb; diff --git a/devolutions-gateway/src/api/update.rs b/devolutions-gateway/src/api/update.rs index d5863de76..0d8422340 100644 --- a/devolutions-gateway/src/api/update.rs +++ b/devolutions-gateway/src/api/update.rs @@ -56,6 +56,7 @@ pub(super) async fn trigger_update_check( let update_json = UpdateJson { gateway: Some(ProductUpdateInfo { target_version }), + hub_service: None, }; let update_json = serde_json::to_string(&update_json).map_err(