Skip to content

Commit e31ac91

Browse files
fix(agent): improve MSI installation handling and Hub Service updates
This commit addresses several issues with the updater's MSI installation and Hub Service update process: 1. Strip UTF-8 BOM from update.json parsing - Some editors add UTF-8 BOM (0xEF 0xBB 0xBF) which caused parse failures - Now strips BOM before JSON deserialization if present 2. Validate MSI exit codes properly - Previously only checked if msiexec launched, not if it succeeded - Now validates exit codes: 0 = success, 3010/1641 = success with reboot (logged as warning since our installers shouldn't require reboot) - Other codes properly treated as failures - Applied to both install and uninstall operations 3. Hub Service installer parameter improvements - Removed P.SERVICESTART parameter for Hub Service (not supported) - Added ADDLOCAL parameter generation based on installed services - Maps service names to MSI features: PAM, Encryption, Reporting - Preserves user's feature selection during updates 4. Enhanced service restart logic for Hub Service - Gateway: only restarts services with manual startup that were running - Hub Service: restarts all services that were running (since we can't control startup mode via installer parameters) These changes ensure Hub Service updates preserve the installed features and properly handle installation success/failure cases.
1 parent 9b5f64b commit e31ac91

File tree

3 files changed

+147
-42
lines changed

3 files changed

+147
-42
lines changed

devolutions-agent/src/updater/mod.rs

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -210,8 +210,16 @@ async fn read_update_json(update_file_path: &Utf8Path) -> anyhow::Result<UpdateJ
210210
let update_json_data = fs::read(update_file_path)
211211
.await
212212
.context("failed to read update.json file")?;
213+
214+
// Strip UTF-8 BOM if present (some editors add it)
215+
let data_without_bom = if update_json_data.starts_with(&[0xEF, 0xBB, 0xBF]) {
216+
&update_json_data[3..]
217+
} else {
218+
&update_json_data
219+
};
220+
213221
let update_json: UpdateJson =
214-
serde_json::from_slice(&update_json_data).context("failed to parse update.json file")?;
222+
serde_json::from_slice(data_without_bom).context("failed to parse update.json file")?;
215223

216224
Ok(update_json)
217225
}

devolutions-agent/src/updater/package.rs

Lines changed: 79 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -67,14 +67,46 @@ async fn install_msi(ctx: &UpdaterCtx, path: &Utf8Path, log_path: &Utf8Path) ->
6767
}
6868
}
6969

70-
if msi_install_result.is_err() {
71-
return Err(UpdaterError::MsiInstall {
72-
product: ctx.product,
73-
msi_path: path.to_owned(),
74-
});
70+
match msi_install_result {
71+
Ok(status) => {
72+
let exit_code = status.code().unwrap_or(-1);
73+
74+
// MSI exit codes:
75+
// 0 = Success
76+
// 3010 = Success but reboot required (unexpected - our installers shouldn't require reboot)
77+
// 1641 = Success and reboot initiated
78+
// Other codes = Error
79+
match exit_code {
80+
0 => {
81+
info!("MSI installation completed successfully");
82+
Ok(())
83+
}
84+
3010 | 1641 => {
85+
// Our installers should not require a reboot, but if they do, log as warning
86+
// and continue since the installation technically succeeded
87+
warn!(
88+
%exit_code,
89+
"MSI installation completed but unexpectedly requires system reboot"
90+
);
91+
Ok(())
92+
}
93+
_ => {
94+
error!(%exit_code, "MSI installation failed with exit code");
95+
Err(UpdaterError::MsiInstall {
96+
product: ctx.product,
97+
msi_path: path.to_owned(),
98+
})
99+
}
100+
}
101+
}
102+
Err(_) => {
103+
error!("Failed to execute msiexec command");
104+
Err(UpdaterError::MsiInstall {
105+
product: ctx.product,
106+
msi_path: path.to_owned(),
107+
})
108+
}
75109
}
76-
77-
Ok(())
78110
}
79111

80112
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
101133
}
102134
}
103135

104-
if msi_uninstall_result.is_err() {
105-
return Err(UpdaterError::MsiUninstall {
106-
product: ctx.product,
107-
product_code,
108-
});
136+
match msi_uninstall_result {
137+
Ok(status) => {
138+
let exit_code = status.code().unwrap_or(-1);
139+
140+
// MSI exit codes:
141+
// 0 = Success
142+
// 3010 = Success but reboot required (unexpected - our installers shouldn't require reboot)
143+
// 1641 = Success and reboot initiated
144+
// Other codes = Error
145+
match exit_code {
146+
0 => {
147+
info!(%product_code, "MSI uninstallation completed successfully");
148+
Ok(())
149+
}
150+
3010 | 1641 => {
151+
// Our installers should not require a reboot, but if they do, log as warning
152+
// and continue since the uninstallation technically succeeded
153+
warn!(
154+
%exit_code,
155+
%product_code,
156+
"MSI uninstallation completed but unexpectedly requires system reboot"
157+
);
158+
Ok(())
159+
}
160+
_ => {
161+
error!(%exit_code, %product_code, "MSI uninstallation failed with exit code");
162+
Err(UpdaterError::MsiUninstall {
163+
product: ctx.product,
164+
product_code,
165+
})
166+
}
167+
}
168+
}
169+
Err(_) => {
170+
error!(%product_code, "Failed to execute msiexec command");
171+
Err(UpdaterError::MsiUninstall {
172+
product: ctx.product,
173+
product_code,
174+
})
175+
}
109176
}
110-
111-
Ok(())
112177
}
113178

114179
fn ensure_enough_rights() -> Result<(), UpdaterError> {

devolutions-agent/src/updater/product_actions.rs

Lines changed: 59 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,10 @@ use crate::updater::{Product, UpdaterError};
55
const GATEWAY_SERVICE_NAME: &str = "DevolutionsGateway";
66

77
// Hub Service installs up to 3 separate Windows services (depending on selected features)
8+
// Service Name -> MSI Feature mapping for ADDLOCAL parameter:
9+
// "Devolutions Hub PAM Service" -> "PAM"
10+
// "Devolutions Hub Encryption Service" -> "Encryption"
11+
// "Devolutions Hub Reporting Service" -> "Reporting"
812
const HUB_SERVICE_NAMES: &[&str] = &[
913
"Devolutions Hub PAM Service",
1014
"Devolutions Hub Encryption Service",
@@ -99,25 +103,31 @@ impl ServiceUpdateActions {
99103
continue;
100104
}
101105

102-
// Start service if it was running prior to the update, but service startup
103-
// was set to manual.
104-
if !state.startup_was_automatic && state.was_running {
105-
info!("Starting '{}' service after update", state.name);
106-
107-
match service_manager.open_service_all_access(state.name) {
108-
Ok(service) => {
106+
match service_manager.open_service_all_access(state.name) {
107+
Ok(service) => {
108+
// Start service if it was running prior to the update
109+
// For Gateway: only if startup was manual (automatic services will auto-start)
110+
// For Hub Service: always start if it was running, since we can't control
111+
// startup mode via P.SERVICESTART parameter
112+
let should_start = match self.product {
113+
Product::Gateway => !state.startup_was_automatic && state.was_running,
114+
Product::HubService => state.was_running,
115+
};
116+
117+
if should_start {
118+
info!("Starting '{}' service after update", state.name);
109119
service.start()?;
110120
info!("Service '{}' started", state.name);
111-
}
112-
Err(e) => {
113-
warn!("Failed to start service '{}' after update: {}", state.name, e);
121+
} else {
122+
debug!(
123+
"Service '{}' doesn't need manual restart (automatic_startup: {}, was_running: {})",
124+
state.name, state.startup_was_automatic, state.was_running
125+
);
114126
}
115127
}
116-
} else {
117-
debug!(
118-
"Service '{}' doesn't need manual restart (automatic_startup: {}, was_running: {})",
119-
state.name, state.startup_was_automatic, state.was_running
120-
);
128+
Err(e) => {
129+
warn!("Failed to access service '{}' after update: {}", state.name, e);
130+
}
121131
}
122132
}
123133

@@ -138,21 +148,43 @@ impl ProductUpdateActions for ServiceUpdateActions {
138148
// When performing update, we want to make sure the service startup mode is restored to the
139149
// previous state. (Installer sets Manual by default).
140150

141-
// For products with a single primary service, check if it should be automatic
142-
if self.service_states.len() == 1 && self.service_states[0].startup_was_automatic {
143-
info!("Adjusting MSIEXEC parameters for {} service startup mode", self.product);
144-
return vec!["P.SERVICESTART=Automatic".to_owned()];
145-
}
146-
147-
// For Hub Service with multiple services, check if any PAM service should be automatic
148-
// (The MSI installer controls the main PAM service startup via P.SERVICESTART)
149-
if self.product == Product::HubService {
150-
if let Some(pam_state) = self.service_states.iter().find(|s| s.exists && s.name.contains("PAM")) {
151-
if pam_state.startup_was_automatic {
152-
info!("Adjusting MSIEXEC parameters for Hub PAM service startup mode");
151+
match self.product {
152+
Product::Gateway => {
153+
// Gateway installer supports P.SERVICESTART property
154+
if self.service_states.len() == 1 && self.service_states[0].startup_was_automatic {
155+
info!("Adjusting MSIEXEC parameters for Gateway service startup mode");
153156
return vec!["P.SERVICESTART=Automatic".to_owned()];
154157
}
155158
}
159+
Product::HubService => {
160+
// Hub Service installer requires ADDLOCAL parameter to specify which services to install.
161+
// Build the list based on currently installed services.
162+
let mut features = Vec::new();
163+
164+
for state in &self.service_states {
165+
if state.exists {
166+
// Map service name to MSI feature name
167+
let feature = match state.name {
168+
"Devolutions Hub PAM Service" => "PAM",
169+
"Devolutions Hub Encryption Service" => "Encryption",
170+
"Devolutions Hub Reporting Service" => "Reporting",
171+
_ => {
172+
warn!("Unknown Hub Service: {}", state.name);
173+
continue;
174+
}
175+
};
176+
features.push(feature);
177+
}
178+
}
179+
180+
if !features.is_empty() {
181+
let addlocal = format!("ADDLOCAL={}", features.join(","));
182+
info!("Adjusting MSIEXEC parameters for Hub Service features: {}", addlocal);
183+
return vec![addlocal];
184+
} else {
185+
warn!("No Hub Service features detected, installer may use defaults");
186+
}
187+
}
156188
}
157189

158190
Vec::new()

0 commit comments

Comments
 (0)