From b131163145da94de9c3de96224521b430d7cf12b Mon Sep 17 00:00:00 2001 From: Vladyslav Nikonov Date: Tue, 24 Mar 2026 17:08:23 +0200 Subject: [PATCH 1/3] WIP: Agent auto update --- .github/workflows/ci.yml | 5 + .github/workflows/package.yml | 6 +- Cargo.lock | 2 + ci/package-agent-windows.ps1 | 19 +- ci/tlk.ps1 | 7 +- crates/devolutions-agent-shared/Cargo.toml | 1 + .../src/agent_auto_update.rs | 148 ++++++++++ crates/devolutions-agent-shared/src/lib.rs | 3 + .../src/update_json.rs | 5 + devolutions-agent/Cargo.toml | 10 + devolutions-agent/src/agent-updater.rs | 179 +++++++++++ devolutions-agent/src/config.rs | 23 +- devolutions-agent/src/updater/detect.rs | 7 +- devolutions-agent/src/updater/error.rs | 4 + devolutions-agent/src/updater/mod.rs | 279 +++++++++++++++++- devolutions-agent/src/updater/package.rs | 112 ++++++- devolutions-agent/src/updater/product.rs | 13 +- .../src/updater/product_actions.rs | 34 +++ .../src/updater/productinfo/mod.rs | 2 + devolutions-gateway/src/api/mod.rs | 9 +- devolutions-gateway/src/api/update.rs | 1 + devolutions-gateway/src/api/update_agent.rs | 228 ++++++++++++++ .../Actions/AgentActions.cs | 12 + .../Actions/CustomActions.cs | 49 ++- .../DevolutionsAgent.csproj | 1 + package/AgentWindowsManaged/Dialogs/Wizard.cs | 2 +- package/AgentWindowsManaged/Program.cs | 10 +- .../Properties/AgentProperties.g.cs | 95 +++--- .../Properties/AgentProperties.g.tt | 18 +- .../Resources/DevolutionsAgent_en-us.wxl | 9 +- .../Resources/DevolutionsAgent_fr-fr.wxl | 9 +- .../Resources/Strings.g.cs | 16 + .../Resources/Strings_en-US.json | 18 ++ 33 files changed, 1245 insertions(+), 91 deletions(-) create mode 100644 crates/devolutions-agent-shared/src/agent_auto_update.rs create mode 100644 devolutions-agent/src/agent-updater.rs create mode 100644 devolutions-gateway/src/api/update_agent.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 93c7a54f1..0549b9102 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -815,6 +815,9 @@ jobs: $DAgentSessionExecutable = Join-Path $TargetOutputPath "DevolutionsSession.exe" echo "dagent-session-executable=$DAgentSessionExecutable" >> $Env:GITHUB_OUTPUT + + $DAgentUpdaterExecutable = Join-Path $TargetOutputPath "devolutions-agent-updater.exe" + echo "dagent-updater-executable=$DAgentUpdaterExecutable" >> $Env:GITHUB_OUTPUT } $DAgentExecutable = Join-Path $TargetOutputPath $ExecutableFileName @@ -919,6 +922,7 @@ jobs: - name: Build run: | if ($Env:RUNNER_OS -eq "Windows") { + $Env:DAGENT_UPDATER_EXECUTABLE = "${{ steps.load-variables.outputs.dagent-updater-executable }}" $Env:DAGENT_PEDM_SHELL_EXT_DLL = "${{ steps.load-variables.outputs.dagent-pedm-shell-ext-dll }}" $Env:DAGENT_PEDM_SHELL_EXT_MSIX = "${{ steps.load-variables.outputs.dagent-pedm-shell-ext-msix }}" $Env:DAGENT_SESSION_EXECUTABLE = "${{ steps.load-variables.outputs.dagent-session-executable }}" @@ -951,6 +955,7 @@ jobs: $Env:DAGENT_PACKAGE = "${{ steps.load-variables.outputs.dagent-package }}" $Env:DAGENT_DESKTOP_AGENT_PATH = $DesktopStagingPath + $Env:DAGENT_UPDATER_EXECUTABLE = "${{ steps.load-variables.outputs.dagent-updater-executable }}" $Env:DAGENT_PEDM_SHELL_EXT_DLL = "${{ steps.load-variables.outputs.dagent-pedm-shell-ext-dll }}" $Env:DAGENT_PEDM_SHELL_EXT_MSIX = "${{ steps.load-variables.outputs.dagent-pedm-shell-ext-msix }}" $Env:DAGENT_SESSION_EXECUTABLE = "${{ steps.load-variables.outputs.dagent-session-executable }}" diff --git a/.github/workflows/package.yml b/.github/workflows/package.yml index efb0c0f74..ab14f2c6e 100644 --- a/.github/workflows/package.yml +++ b/.github/workflows/package.yml @@ -319,7 +319,7 @@ jobs: run: | $IncludePattern = @(switch ('${{ matrix.project }}') { 'devolutions-gateway' { @('DevolutionsGateway_*.exe') } - 'devolutions-agent' { @('DevolutionsAgent_*.exe', 'DevolutionsPedmShellExt.dll', 'DevolutionsPedmShellExt.msix', 'DevolutionsDesktopAgent.exe') } + 'devolutions-agent' { @('DevolutionsAgent_*.exe', 'devolutions-agent-updater.exe', 'DevolutionsPedmShellExt.dll', 'DevolutionsPedmShellExt.msix', 'DevolutionsDesktopAgent.exe') } 'jetsocat' { @('jetsocat_*') } }) $ExcludePattern = "*.pdb" @@ -473,7 +473,8 @@ jobs: run: | $PackageRoot = Join-Path ${{ runner.temp }} ${{ matrix.project}} - $Env:DAGENT_EXECUTABLE = Get-ChildItem -Path $PackageRoot -Recurse -Include '*DevolutionsAgent*.exe' | Select -First 1 + $Env:DAGENT_EXECUTABLE = Get-ChildItem -Path $PackageRoot -Recurse -Include '*DevolutionsAgent_*.exe' | Select -First 1 + $Env:DAGENT_UPDATER_EXECUTABLE = Get-ChildItem -Path $PackageRoot -Recurse -Include 'devolutions-agent-updater.exe' | Select -First 1 $Env:DAGENT_DESKTOP_AGENT_PATH = Resolve-Path -Path "devolutions-pedm-desktop" $Env:DAGENT_PEDM_SHELL_EXT_DLL = Get-ChildItem -Path $PackageRoot -Recurse -Include 'DevolutionsPedmShellExt.dll' | Select -First 1 $Env:DAGENT_PEDM_SHELL_EXT_MSIX = Get-ChildItem -Path $PackageRoot -Recurse -Include 'DevolutionsPedmShellExt.msix' | Select -First 1 @@ -482,6 +483,7 @@ jobs: $Env:DAGENT_WINTUN_DLL = Get-ChildItem -Path $PackageRoot -Recurse -Include 'wintun.dll' | Select -First 1 Write-Host "DAGENT_EXECUTABLE = ${Env:DAGENT_EXECUTABLE}" + Write-Host "DAGENT_UPDATER_EXECUTABLE = ${Env:DAGENT_UPDATER_EXECUTABLE}" Write-Host "DAGENT_DESKTOP_AGENT_PATH = ${Env:DAGENT_DESKTOP_AGENT_PATH}" Write-Host "DAGENT_PEDM_SHELL_EXT_DLL = ${Env:DAGENT_PEDM_SHELL_EXT_DLL}" Write-Host "DAGENT_PEDM_SHELL_EXT_MSIX = ${Env:DAGENT_PEDM_SHELL_EXT_MSIX}" diff --git a/Cargo.lock b/Cargo.lock index a6b2c1567..6b250d7d8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1398,6 +1398,7 @@ dependencies = [ "futures", "hex", "http-client-proxy", + "humantime", "ironrdp", "notify-debouncer-mini", "parking_lot", @@ -1409,6 +1410,7 @@ dependencies = [ "sha2 0.10.9", "tap", "thiserror 2.0.18", + "time", "tokio 1.49.0", "tokio-rustls", "tracing", diff --git a/ci/package-agent-windows.ps1 b/ci/package-agent-windows.ps1 index 8931f3dd5..54fb937ad 100644 --- a/ci/package-agent-windows.ps1 +++ b/ci/package-agent-windows.ps1 @@ -3,6 +3,8 @@ param( [parameter(Mandatory = $true)] [string] $Exe, [parameter(Mandatory = $true)] + [string] $UpdaterExe, + [parameter(Mandatory = $true)] [string] $PedmDll, [parameter(Mandatory = $true)] [string] $PedmMsix, @@ -31,7 +33,7 @@ function Set-FileNameAndCopy { [string]$Path, [string]$NewName ) - + if (-Not (Test-Path $Path)) { throw "File not found: $Path" } @@ -84,6 +86,9 @@ function New-AgentMsi() { # The path to the devolutions-agent.exe file. [string] $Exe, [parameter(Mandatory = $true)] + # The path to the devolutions-agent-updater.exe file. + [string] $UpdaterExe, + [parameter(Mandatory = $true)] # The path to the devolutions_pedm_shell_ext.dll file. [string] $PedmDll, [parameter(Mandatory = $true)] @@ -111,6 +116,7 @@ function New-AgentMsi() { # Convert slashes. This does not affect function. It's just for display. $Exe = Convert-Path -Path $Exe + $UpdaterExe = Convert-Path -Path $UpdaterExe $PedmDll = Convert-Path -Path $PedmDll $PedmMsix = Convert-Path -Path $PedmMsix $SessionExe = Convert-Path -Path $SessionExe @@ -127,20 +133,23 @@ function New-AgentMsi() { # These file names don't matter for building, but we will clean them up anyways for consistency. The names can be seen if inspecting the MSI. # The Agent exe will get copied to `C:\Program Files\Devolutions\Agent\DevolutionsAgent.exe` after install. $myExe = Set-FileNameAndCopy -Path $Exe -NewName 'DevolutionsAgent.exe' + # The updater shim is a detached helper for installing MSI updates. + $myUpdaterExe = Set-FileNameAndCopy -Path $UpdaterExe -NewName 'devolutions-agent-updater.exe' # The session is a service that gets launched on demand. $mySessionExe = Set-FileNameAndCopy -Path $SessionExe -NewName 'DevolutionsSession.exe' Write-Output "$repoDir\dotnet\DesktopAgent\bin\Release\net48\DevolutionsDesktopAgent.exe" Set-EnvVarPath 'DAGENT_EXECUTABLE' $myExe + Set-EnvVarPath 'DAGENT_UPDATER_EXECUTABLE' $myUpdaterExe Set-EnvVarPath 'DAGENT_PEDM_SHELL_EXT_DLL' $myPedmDll Set-EnvVarPath 'DAGENT_PEDM_SHELL_EXT_MSIX' $myPedmMsix Set-EnvVarPath 'DAGENT_SESSION_EXECUTABLE' $mySessionExe # The actual DevolutionsDesktopAgent.exe will be `\dotnet\DesktopAgent\bin\Release\net48\DevolutionsDesktopAgent.exe`. - # After install, the contsnts of `net48` will be copied to `C:\Program Files\Devolutions\Agent\desktop\`. + # After install, the contsnts of `net48` will be copied to `C:\Program Files\Devolutions\Agent\desktop\`. Set-EnvVarPath 'DAGENT_DESKTOP_AGENT_PATH' "$repoDir\dotnet\DesktopAgent\bin\Release\net48" - + $version = Get-Version Push-Location @@ -152,7 +161,7 @@ function New-AgentMsi() { if ($Generate) { # This is used by `package/WindowsManaged/Program.cs`. $Env:DAGENT_MSI_SOURCE_ONLY_BUILD = '1' - + foreach ($lang in Get-PackageLanguages) { $Env:DAGENT_MSI_LANG_ID = $lang.Name & 'MSBuild.exe' 'DevolutionsAgent.sln' '/t:restore,build' '/p:Configuration=Release' | Out-Host @@ -175,4 +184,4 @@ function New-AgentMsi() { Pop-Location } -New-AgentMsi -Generate:($Generate.IsPresent) -Exe $Exe -PedmDll $PedmDll -PedmMsix $PedmMsix -SessionExe $SessionExe -Architecture $Architecture -Outfile $Outfile +New-AgentMsi -Generate:($Generate.IsPresent) -Exe $Exe -UpdaterExe $UpdaterExe -PedmDll $PedmDll -PedmMsix $PedmMsix -SessionExe $SessionExe -Architecture $Architecture -Outfile $Outfile diff --git a/ci/tlk.ps1 b/ci/tlk.ps1 index 40b5ad392..7aa238506 100755 --- a/ci/tlk.ps1 +++ b/ci/tlk.ps1 @@ -303,6 +303,7 @@ class TlkRecipe $agentPackages = @([TlkPackage]::new("devolutions-agent", "devolutions-agent", $false)) if ($this.Target.IsWindows()) { + $agentPackages += [TlkPackage]::new("devolutions-agent-updater", "devolutions-agent", $false) $agentPackages += [TlkPackage]::new("devolutions-pedm-shell-ext", "crates/devolutions-pedm-shell-ext", $true) $agentPackages += [TlkPackage]::new("devolutions-session", "devolutions-session", $false) } @@ -387,6 +388,8 @@ class TlkRecipe "agent" { if ($CargoPackage.Name -Eq "devolutions-agent" -And (Test-Path Env:DAGENT_EXECUTABLE)) { $Env:DAGENT_EXECUTABLE + } elseif ($CargoPackage.Name -Eq "devolutions-agent-updater" -And (Test-Path Env:DAGENT_UPDATER_EXECUTABLE)) { + $Env:DAGENT_UPDATER_EXECUTABLE } elseif ($CargoPackage.Name -Eq "devolutions-pedm-shell-ext" -And (Test-Path Env:DAGENT_PEDM_SHELL_EXT_DLL)) { $Env:DAGENT_PEDM_SHELL_EXT_DLL } elseif ($CargoPackage.Name -Eq "devolutions-session" -And (Test-Path Env:DAGENT_SESSION_EXECUTABLE)) { @@ -760,7 +763,7 @@ class TlkRecipe } $DebUpstreamChangelogFile = Join-Path $OutputPath "changelog_deb_upstream" - + Merge-Tokens -TemplateFile $RulesTemplate -Tokens @{ dh_shlibdeps = $DhShLibDepsOverride upstream_changelog = $DebUpstreamChangelogFile @@ -799,7 +802,7 @@ class TlkRecipe # input for debian/changelog is the package-specific CHANGELOG.md $PackagingChangelogFile = Join-Path $InputPackagePath "CHANGELOG.md" - + $s = New-Changelog ` -Format 'Deb' ` -InputFile $UpstreamChangelogFile ` diff --git a/crates/devolutions-agent-shared/Cargo.toml b/crates/devolutions-agent-shared/Cargo.toml index 3f29bc996..f51135bf1 100644 --- a/crates/devolutions-agent-shared/Cargo.toml +++ b/crates/devolutions-agent-shared/Cargo.toml @@ -13,6 +13,7 @@ workspace = true camino = "1.1" cfg-if = "1" serde = { version = "1", features = ["derive"] } +serde_json = "1" thiserror = "2" [target.'cfg(windows)'.dependencies] diff --git a/crates/devolutions-agent-shared/src/agent_auto_update.rs b/crates/devolutions-agent-shared/src/agent_auto_update.rs new file mode 100644 index 000000000..435133f60 --- /dev/null +++ b/crates/devolutions-agent-shared/src/agent_auto_update.rs @@ -0,0 +1,148 @@ +//! Agent auto-update configuration and helpers. +//! +//! This module defines the `AgentAutoUpdateConf` structure and provides helpers to read +//! and write the auto-update section of `agent.json` without touching the rest of the file. + +use camino::Utf8PathBuf; + +use crate::get_data_dir; + +/// Default check interval expressed as a humantime string (`"1d"` = 24 hours). +pub const DEFAULT_INTERVAL: &str = "1d"; +pub const DEFAULT_WINDOW_START: &str = "02:00"; +/// Default maintenance window end time (exclusive, local time `HH:MM`). +pub const DEFAULT_WINDOW_END: &str = "04:00"; + +fn default_interval() -> String { + DEFAULT_INTERVAL.to_owned() +} + +fn default_window_start() -> String { + DEFAULT_WINDOW_START.to_owned() +} + +fn default_window_end() -> Option { + Some(DEFAULT_WINDOW_END.to_owned()) +} + +/// Auto-update schedule configuration for Devolutions Agent. +/// +/// When enabled, the agent periodically checks whether a new version of itself is +/// available in the Devolutions productinfo database. If a newer version is found +/// and the current local time falls inside the configured maintenance window, the +/// agent writes the new target version to `update.json`, which is then picked up by +/// the updater task to perform the silent MSI installation. +/// +/// Settings changes take effect when the agent service (re)starts. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "PascalCase")] +pub struct AgentAutoUpdateConf { + /// Enable periodic Devolutions Agent self-update checks. + pub enabled: bool, + + /// Minimum interval between auto-update checks. + /// + /// Accepts humantime duration strings such as `"1d"`, `"12h"`, `"30m 20s"`, or a + /// bare integer treated as seconds (e.g. `"3600"`). Defaults to `"1d"`. + #[serde(default = "default_interval")] + pub interval: String, + + /// Start of the maintenance window (local time, `HH:MM` format). + /// + /// Defaults to `"02:00"`. + #[serde(default = "default_window_start")] + pub update_window_start: String, + + /// End of the maintenance window (local time, `HH:MM` format, exclusive). + /// + /// Defaults to `"04:00"`. When `null`, the window has no upper bound. + /// If the end time is earlier than or equal to the start time the window is + /// assumed to cross midnight (e.g. `"22:00"`–`"03:00"` is a 5-hour window). + #[serde(default = "default_window_end")] + pub update_window_end: Option, +} + +impl Default for AgentAutoUpdateConf { + fn default() -> Self { + Self { + enabled: false, + interval: DEFAULT_INTERVAL.to_owned(), + update_window_start: DEFAULT_WINDOW_START.to_owned(), + update_window_end: Some(DEFAULT_WINDOW_END.to_owned()), + } + } +} + +/// Returns the path to `agent.json`. +pub fn get_agent_config_path() -> Utf8PathBuf { + get_data_dir().join("agent.json") +} + +/// Read the current auto-update configuration from `agent.json`. +/// +/// Returns defaults when the file is absent or the `AgentAutoUpdate` section is missing. +pub fn read_agent_auto_update_conf() -> std::io::Result { + let path = get_agent_config_path(); + + let content = match std::fs::read(&path) { + Ok(c) => c, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(AgentAutoUpdateConf::default()), + Err(e) => return Err(e), + }; + + // Strip UTF-8 BOM if present. + let content = if content.starts_with(&[0xEF, 0xBB, 0xBF]) { + &content[3..] + } else { + &content + }; + + let value: serde_json::Value = + serde_json::from_slice(content).map_err(|e| std::io::Error::other(format!("invalid agent.json: {e}")))?; + + let section = value + .get("Updater") + .and_then(|u| u.get("AgentAutoUpdate")) + .cloned() + .unwrap_or(serde_json::Value::Null); + + if section.is_null() { + return Ok(AgentAutoUpdateConf::default()); + } + + serde_json::from_value(section).map_err(|e| std::io::Error::other(format!("invalid AgentAutoUpdate section: {e}"))) +} + +/// Write `conf` into the `Updater.AgentAutoUpdate` section of `agent.json`. +/// +/// All other keys in the file are preserved. +pub fn write_agent_auto_update_conf(conf: &AgentAutoUpdateConf) -> std::io::Result<()> { + let path = get_agent_config_path(); + + // Read or create the root JSON value. + let mut root: serde_json::Value = if path.exists() { + let content = std::fs::read(&path)?; + let content = if content.starts_with(&[0xEF, 0xBB, 0xBF]) { + content[3..].to_vec() + } else { + content + }; + serde_json::from_slice(&content) + .map_err(|e| std::io::Error::other(format!("invalid agent.json: {e}")))? + } else { + serde_json::json!({}) + }; + + // Ensure the `Updater` object exists (with `Enabled: true` as default). + if root.get("Updater").is_none() { + root["Updater"] = serde_json::json!({ "Enabled": true }); + } + + root["Updater"]["AgentAutoUpdate"] = + serde_json::to_value(conf).map_err(|e| std::io::Error::other(format!("serialization error: {e}")))?; + + let json = serde_json::to_string_pretty(&root) + .map_err(|e| std::io::Error::other(format!("serialization error: {e}")))?; + + std::fs::write(&path, json) +} diff --git a/crates/devolutions-agent-shared/src/lib.rs b/crates/devolutions-agent-shared/src/lib.rs index 149c45e25..00a8b1fab 100644 --- a/crates/devolutions-agent-shared/src/lib.rs +++ b/crates/devolutions-agent-shared/src/lib.rs @@ -4,6 +4,7 @@ extern crate serde; #[cfg(windows)] pub mod windows; +pub mod agent_auto_update; mod date_version; mod update_json; @@ -12,6 +13,8 @@ use std::env; use camino::Utf8PathBuf; use cfg_if::cfg_if; +#[rustfmt::skip] +pub use agent_auto_update::AgentAutoUpdateConf; #[rustfmt::skip] pub use date_version::{DateVersion, DateVersionError}; #[rustfmt::skip] diff --git a/crates/devolutions-agent-shared/src/update_json.rs b/crates/devolutions-agent-shared/src/update_json.rs index e8366fe54..c27164873 100644 --- a/crates/devolutions-agent-shared/src/update_json.rs +++ b/crates/devolutions-agent-shared/src/update_json.rs @@ -11,6 +11,9 @@ use crate::DateVersion; /// }, /// "HubService": { /// "TargetVersion": "latest" +/// }, +/// "Agent": { +/// "TargetVersion": "latest" /// } /// } /// ``` @@ -22,6 +25,8 @@ pub struct UpdateJson { pub gateway: Option, #[serde(skip_serializing_if = "Option::is_none")] pub hub_service: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub agent: Option, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] diff --git a/devolutions-agent/Cargo.toml b/devolutions-agent/Cargo.toml index a93df50c8..d7bea6bfe 100644 --- a/devolutions-agent/Cargo.toml +++ b/devolutions-agent/Cargo.toml @@ -8,6 +8,14 @@ description = "Agent companion service for Devolutions Gateway" build = "build.rs" publish = false +[[bin]] +name = "devolutions-agent" +path = "src/main.rs" + +[[bin]] +name = "devolutions-agent-updater" +path = "src/agent-updater.rs" + [lints] workspace = true @@ -57,6 +65,8 @@ features = [ [target.'cfg(windows)'.dependencies] aws-lc-rs = "1.15" +humantime = "2" +time = { version = "0.3", features = ["local-offset", "macros", "parsing"] } devolutions-pedm = { path = "../crates/devolutions-pedm" } hex = "0.4" notify-debouncer-mini = "0.6" diff --git a/devolutions-agent/src/agent-updater.rs b/devolutions-agent/src/agent-updater.rs new file mode 100644 index 000000000..7fe29a0e2 --- /dev/null +++ b/devolutions-agent/src/agent-updater.rs @@ -0,0 +1,179 @@ +//! Devolutions Agent Updater shim. +//! +//! This minimal executable is launched as a detached process by the Devolutions Agent service +//! to perform a silent MSI update of Devolutions Agent itself. +//! +//! Running as a detached process is necessary because the MSI installer stops and restarts +//! the Devolutions Agent Windows service during installation. If the agent tried to call +//! msiexec directly and wait for it, the agent would be killed mid-update. By launching +//! this shim as a detached process, the shim survives the agent service restart and +//! ensures the MSI installation completes successfully. +//! +//! # Usage +//! +//! ```text +//! devolutions-agent-updater [-x ] +//! ``` +//! +//! When `-x ` is provided (a braced GUID such as +//! `{xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx}`), the shim first runs +//! `msiexec /x` to uninstall the currently installed version and then runs +//! `msiexec /i` to install the target version. This is required for downgrades +//! because MSI upgrade conditions prevent installing an older version on top of +//! a newer one. + +// Suppress the console window in release builds. In debug builds, we keep the console for +// visibility when running from a terminal during development. +#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] + +fn main() { + #[cfg(not(windows))] + { + eprintln!("devolutions-agent-updater is only supported on Windows"); + std::process::exit(1); + } + + #[cfg(windows)] + windows_main(); +} + +#[cfg(windows)] +fn windows_main() { + let args: Vec = std::env::args().collect(); + + if args.len() < 2 { + let _ = write_to_stderr("Usage: devolutions-agent-updater [-x ] "); + std::process::exit(1); + } + + // Parse optional -x flag before the positional MSI path. + let (uninstall_product_code, msi_path) = { + let mut iter = args.iter().skip(1).peekable(); + let product_code = if iter.peek().map(|s| s.as_str()) == Some("-x") { + iter.next(); // consume "-x" + let code = iter.next().map(String::as_str); + if code.is_none() { + let _ = write_to_stderr("Error: -x requires a product code argument"); + std::process::exit(1); + } + code + } else { + None + }; + let msi = match iter.next() { + Some(s) => s.as_str(), + None => { + let _ = write_to_stderr("Usage: devolutions-agent-updater [-x ] "); + std::process::exit(1); + } + }; + (product_code, msi) + }; + + // Derive paths from the MSI path. + // The shim log uses a separate extension so it doesn't conflict with the msiexec log. + let shim_log_path = format!("{msi_path}.shim.log"); + let msiexec_log_path = format!("{msi_path}.log"); + + write_log(&shim_log_path, "devolutions-agent-updater: starting"); + write_log(&shim_log_path, &format!(" MSI path: {msi_path}")); + write_log(&shim_log_path, &format!(" msiexec log: {msiexec_log_path}")); + + // For downgrades, uninstall the currently installed version first. + if let Some(product_code) = uninstall_product_code { + write_log(&shim_log_path, &format!(" Uninstalling product code: {product_code}")); + + let uninstall_log_path = format!("{msi_path}.uninstall.log"); + let status = std::process::Command::new("msiexec") + .args(["/x", product_code, "/quiet", "/norestart", "/l*v", uninstall_log_path.as_str()]) + .status(); + + match status { + Ok(exit_status) => { + let code = exit_status.code().unwrap_or(-1); + match code { + 0 | 3010 | 1641 => { + write_log( + &shim_log_path, + &format!("devolutions-agent-updater: uninstall completed with code {code} (success)"), + ); + } + _ => { + write_log( + &shim_log_path, + &format!("devolutions-agent-updater: uninstall failed with exit code {code}"), + ); + std::process::exit(code); + } + } + } + Err(err) => { + write_log( + &shim_log_path, + &format!("devolutions-agent-updater: failed to launch msiexec for uninstall: {err}"), + ); + std::process::exit(1); + } + } + } + + let status = std::process::Command::new("msiexec") + .args([ + "/i", + msi_path, + "/quiet", + "/norestart", + "/l*v", + msiexec_log_path.as_str(), + ]) + .status(); + + match status { + Ok(exit_status) => { + let code = exit_status.code().unwrap_or(-1); + + // MSI exit codes: + // 0 = Success + // 3010 = Success (reboot required, but our installers shouldn't need a reboot) + // 1641 = Success (reboot initiated) + match code { + 0 | 3010 | 1641 => { + write_log( + &shim_log_path, + &format!("devolutions-agent-updater: msiexec completed with code {code} (success)"), + ); + } + _ => { + write_log( + &shim_log_path, + &format!("devolutions-agent-updater: msiexec failed with exit code {code}"), + ); + std::process::exit(code); + } + } + } + Err(err) => { + write_log( + &shim_log_path, + &format!("devolutions-agent-updater: failed to launch msiexec: {err}"), + ); + std::process::exit(1); + } + } +} + +/// Append a line to a log file, ignoring errors (best-effort logging). +#[cfg(windows)] +fn write_log(path: &str, msg: &str) { + use std::fs::OpenOptions; + use std::io::Write as _; + + if let Ok(mut file) = OpenOptions::new().create(true).append(true).open(path) { + let _ = writeln!(file, "{msg}"); + } +} + +fn write_to_stderr(msg: &str) -> std::io::Result<()> { + use std::io::Write as _; + writeln!(std::io::stderr(), "{msg}") +} diff --git a/devolutions-agent/src/config.rs b/devolutions-agent/src/config.rs index 1632fcc60..110038a48 100644 --- a/devolutions-agent/src/config.rs +++ b/devolutions-agent/src/config.rs @@ -192,18 +192,32 @@ pub fn load_conf_file_or_generate_new() -> anyhow::Result { pub mod dto { use super::*; + use devolutions_agent_shared::AgentAutoUpdateConf; #[derive(PartialEq, Eq, Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "PascalCase")] pub struct UpdaterConf { - /// Enable updater module + /// Enable updater module. pub enabled: bool, + /// Periodic Devolutions Agent self-update schedule. + /// + /// When this section is present and `Enabled` is `true`, the agent will automatically + /// check for a new version of itself at the configured interval and, if a newer version + /// is available, trigger a silent MSI update during the configured maintenance window. + /// + /// This setting can be managed remotely via the Devolutions Gateway API + /// (`GET`/`POST /jet/update-agent`) or set directly in this file. + #[serde(skip_serializing_if = "Option::is_none")] + pub agent_auto_update: Option, } #[allow(clippy::derivable_impls)] // Just to be explicit about the default values of the config. impl Default for UpdaterConf { fn default() -> Self { - Self { enabled: false } + Self { + enabled: false, + agent_auto_update: None, + } } } @@ -324,7 +338,10 @@ pub mod dto { Self { verbosity_profile: None, log_file: None, - updater: Some(UpdaterConf { enabled: true }), + updater: Some(UpdaterConf { + enabled: true, + agent_auto_update: None, + }), remote_desktop: None, pedm: None, proxy: None, diff --git a/devolutions-agent/src/updater/detect.rs b/devolutions-agent/src/updater/detect.rs index eaab03eaf..e739d122c 100644 --- a/devolutions-agent/src/updater/detect.rs +++ b/devolutions-agent/src/updater/detect.rs @@ -1,6 +1,6 @@ //! Module which provides logic to detect installed products and their versions. use devolutions_agent_shared::DateVersion; -use devolutions_agent_shared::windows::{GATEWAY_UPDATE_CODE, HUB_SERVICE_UPDATE_CODE, registry}; +use devolutions_agent_shared::windows::{AGENT_UPDATE_CODE, GATEWAY_UPDATE_CODE, HUB_SERVICE_UPDATE_CODE, registry}; use uuid::Uuid; use crate::updater::{Product, UpdaterError}; @@ -16,6 +16,10 @@ pub(crate) fn get_installed_product_version(product: Product) -> Result { + registry::get_installed_product_version(AGENT_UPDATE_CODE, registry::ProductVersionEncoding::Agent) + .map_err(UpdaterError::WindowsRegistry) + } } } @@ -25,5 +29,6 @@ pub(crate) fn get_product_code(product: Product) -> Result, Updater Product::HubService => { registry::get_product_code(HUB_SERVICE_UPDATE_CODE).map_err(UpdaterError::WindowsRegistry) } + Product::Agent => registry::get_product_code(AGENT_UPDATE_CODE).map_err(UpdaterError::WindowsRegistry), } } diff --git a/devolutions-agent/src/updater/error.rs b/devolutions-agent/src/updater/error.rs index 44ee84fc2..bc59ef5de 100644 --- a/devolutions-agent/src/updater/error.rs +++ b/devolutions-agent/src/updater/error.rs @@ -58,4 +58,8 @@ pub(crate) enum UpdaterError { QueryServiceState { product: Product, source: anyhow::Error }, #[error("failed to start service for `{product}`")] StartService { product: Product, source: anyhow::Error }, + #[error("agent updater shim not found at expected path: `{path}`")] + AgentUpdaterShimNotFound { path: Utf8PathBuf }, + #[error("failed to launch agent updater shim")] + AgentShimLaunch { source: std::io::Error }, } diff --git a/devolutions-agent/src/updater/mod.rs b/devolutions-agent/src/updater/mod.rs index 994a1c08c..497c15caa 100644 --- a/devolutions-agent/src/updater/mod.rs +++ b/devolutions-agent/src/updater/mod.rs @@ -9,12 +9,15 @@ mod productinfo; mod security; use std::sync::Arc; -use std::time::Duration; +use std::time::{Duration, Instant}; + +use time::Time; +use time::macros::format_description; use anyhow::{Context, anyhow}; use async_trait::async_trait; use camino::{Utf8Path, Utf8PathBuf}; -use devolutions_agent_shared::{DateVersion, UpdateJson, VersionSpecification, get_updater_file_path}; +use devolutions_agent_shared::{DateVersion, ProductUpdateInfo, UpdateJson, VersionSpecification, get_updater_file_path}; use devolutions_gateway_task::{ShutdownSignal, Task}; use notify_debouncer_mini::notify::RecursiveMode; use tokio::fs; @@ -33,9 +36,11 @@ use crate::config::ConfHandle; use crate::updater::productinfo::ProductInfoDb; const UPDATE_JSON_WATCH_INTERVAL: Duration = Duration::from_secs(3); +/// How often the task checks whether an auto-update should be triggered. +const POLL_INTERVAL: Duration = Duration::from_secs(60); -// List of updateable products could be extended in future -const PRODUCTS: &[Product] = &[Product::Gateway, Product::HubService]; +// List of updateable products could be extended in future. +const PRODUCTS: &[Product] = &[Product::Gateway, Product::HubService, Product::Agent]; /// Load productinfo source from configured URL or file path async fn load_productinfo_source(conf: &ConfHandle) -> Result { @@ -82,6 +87,9 @@ struct UpdaterCtx { product: Product, actions: Box, conf: ConfHandle, + /// For agent self-update downgrades: the product code of the currently installed version + /// to be uninstalled by the shim before installing the target version. + downgrade_product_code: Option, } struct DowngradeInfo { @@ -140,8 +148,64 @@ impl Task for UpdaterTask { // Trigger initial check during task startup file_change_notification.notify_waiters(); + let mut last_auto_update_trigger: Option = None; + + // First poll fires after POLL_INTERVAL, not immediately on startup. + let mut poll_ticker = tokio::time::interval_at( + tokio::time::Instant::now() + POLL_INTERVAL, + POLL_INTERVAL, + ); + loop { tokio::select! { + _ = poll_ticker.tick() => { + let auto_update = { + let conf_data = conf.get_conf(); + conf_data.updater.agent_auto_update.clone() + }; + + let Some(auto_update) = auto_update else { continue }; + + if !auto_update.enabled { + continue; + } + + let interval = parse_interval(&auto_update.interval); + if let Some(last) = last_auto_update_trigger + && last.elapsed() < interval + { + continue; + } + + let now = time::OffsetDateTime::now_local() + .unwrap_or_else(|_| time::OffsetDateTime::now_utc()); + if !is_in_update_window(now.time(), &auto_update.update_window_start, auto_update.update_window_end.as_deref()) { + continue; + } + + info!("Agent auto-update: maintenance window active, checking for new version"); + last_auto_update_trigger = Some(Instant::now()); + + let synthetic = UpdateJson { + agent: Some(ProductUpdateInfo { target_version: VersionSpecification::Latest }), + gateway: None, + hub_service: None, + }; + + match check_for_updates(Product::Agent, &synthetic, &conf).await { + Ok(Some(order)) => { + if let Err(error) = update_product(conf.clone(), Product::Agent, order).await { + error!(error = format!("{error:#}"), "Agent auto-update: failed to update agent"); + } + } + Ok(None) => { + info!("Agent auto-update: agent is already up to date"); + } + Err(error) => { + error!(error = format!("{error:#}"), "Agent auto-update: failed to check for updates"); + } + } + } _ = file_change_notification.notified() => { info!("update.json file changed, checking for updates..."); @@ -200,6 +264,11 @@ async fn update_product(conf: ConfHandle, product: Product, order: UpdateOrder) product, actions: build_product_actions(product), conf, + downgrade_product_code: order.downgrade.as_ref().and_then(|d| { + // For Agent, the shim handles uninstall + install in sequence; pass the product + // code so it can run `msiexec /x` before `msiexec /i`. + (product == Product::Agent).then_some(d.product_code) + }), }; validate_download_url(&ctx, &order.package_url)?; @@ -238,7 +307,10 @@ async fn update_product(conf: ConfHandle, product: Product, order: UpdateOrder) let uninstall_log_path = package_path.with_extension("uninstall.log"); // NOTE: An uninstall/reinstall will lose any custom feature selection or other options in the existing installation - uninstall_package(&ctx, downgrade.product_code, &uninstall_log_path).await?; + // For Product::Agent the shim handles uninstall; skip the in-process step. + if product != Product::Agent { + uninstall_package(&ctx, downgrade.product_code, &uninstall_log_path).await?; + } } let log_path = package_path.with_extension("log"); @@ -334,6 +406,15 @@ async fn check_for_updates( } })?; + // The first agent version with self-update support is 2026.2 (anything built after + // 2026.1.x lacks the updater shim and would permanently disable auto-update). + const AGENT_MIN_SELF_UPDATE_VERSION: DateVersion = DateVersion { + year: 2026, + month: 1, + day: 0, + revision: 0, + }; + let remote_version = product_info.version.parse::()?; match target_version { @@ -343,6 +424,16 @@ async fn check_for_updates( return Ok(None); } + if product == Product::Agent && remote_version <= AGENT_MIN_SELF_UPDATE_VERSION { + warn!( + %product, + target_version = %remote_version, + min_version = %AGENT_MIN_SELF_UPDATE_VERSION, + "Latest version does not support agent self-update; skipping to avoid breaking auto-update" + ); + return Ok(None); + } + Ok(Some(UpdateOrder { target_version: remote_version, downgrade: None, @@ -400,6 +491,16 @@ async fn check_for_updates( } // Target MSI found, proceed with update. + if product == Product::Agent && version <= AGENT_MIN_SELF_UPDATE_VERSION { + warn!( + %product, + %version, + min_version = %AGENT_MIN_SELF_UPDATE_VERSION, + "Target version does not support agent self-update; skipping to avoid breaking auto-update" + ); + return Ok(None); + } + // For the downgrade, we remove the installed product and install the target // version. This is the simplest and more reliable way to handle downgrades. (WiX // downgrade is not used). @@ -476,10 +577,178 @@ fn try_modify_product_url_version( Ok(new_url) } +/// Parse a humantime duration string into a [`Duration`]. +/// +/// A bare integer with no unit suffix is treated as seconds. +/// Falls back to 24 hours if the string cannot be parsed. +fn parse_interval(s: &str) -> Duration { + if let Ok(secs) = s.trim().parse::() { + return Duration::from_secs(secs); + } + match humantime::parse_duration(s) { + Ok(d) => d, + Err(_) => { + warn!(interval = s, "Agent auto-update: invalid interval format, falling back to 24 hours"); + Duration::from_secs(86_400) + } + } +} + +/// Returns `true` when `now` falls within the configured maintenance window. +/// +/// `window_start` must be in `HH:MM` (24-hour) local-time format. +/// When `window_end` is `None` the window is unbounded: any time at or after `window_start` +/// is accepted. When `window_end` is `Some` and `end \u2264 start`, midnight crossing is assumed +/// (e.g. `"22:00"`\u2013`"03:00"` covers `[22:00, midnight) \u222a [midnight, 03:00)`). +fn is_in_update_window(now: Time, window_start: &str, window_end: Option<&str>) -> bool { + let fmt = format_description!("[hour]:[minute]"); + let parse = |s: &str| Time::parse(s, fmt).ok(); + + let Some(start) = parse(window_start) else { + warn!( + window_start, + "Agent auto-update: invalid maintenance window start time format, skipping check" + ); + return false; + }; + + match window_end { + None => now >= start, + Some(end_str) => { + let Some(end) = parse(end_str) else { + warn!( + window_end = end_str, + "Agent auto-update: invalid maintenance window end time format, skipping check" + ); + return false; + }; + if end <= start { + // Window crosses midnight: [start, midnight) \u222a [midnight, end) + now >= start || now < end + } else { + // Normal window: [start, end) + now >= start && now < end + } + } + } +} + #[cfg(test)] mod tests { + use std::time::Duration; + + use time::Time; + use time::macros::format_description; + use super::*; + fn t(s: &str) -> Time { + Time::parse(s, format_description!("[hour]:[minute]")).expect("valid test time") + } + + // --- Maintenance window tests --- + + #[test] + fn inside_window() { + assert!(is_in_update_window(t("03:00"), "02:00", Some("04:00"))); + } + + #[test] + fn at_window_start() { + assert!(is_in_update_window(t("02:00"), "02:00", Some("04:00"))); + } + + #[test] + fn at_window_end_exclusive() { + assert!(!is_in_update_window(t("04:00"), "02:00", Some("04:00"))); + } + + #[test] + fn before_window() { + assert!(!is_in_update_window(t("01:59"), "02:00", Some("04:00"))); + } + + #[test] + fn after_window() { + assert!(!is_in_update_window(t("04:01"), "02:00", Some("04:00"))); + } + + #[test] + fn invalid_window_format_returns_false() { + assert!(!is_in_update_window(t("03:00"), "bad", Some("04:00"))); + assert!(!is_in_update_window(t("03:00"), "02:00", Some("not-a-time"))); + } + + #[test] + fn no_end_allows_any_time_after_start() { + assert!(!is_in_update_window(t("01:59"), "02:00", None)); + assert!(is_in_update_window(t("02:00"), "02:00", None)); + assert!(is_in_update_window(t("23:59"), "02:00", None)); + } + + #[test] + fn invalid_start_with_no_end_returns_false() { + assert!(!is_in_update_window(t("03:00"), "bad", None)); + } + + #[test] + fn midnight_crossing_inside_early() { + // 22:00..03:00 \u2014 time is 01:00, past midnight but before end + assert!(is_in_update_window(t("01:00"), "22:00", Some("03:00"))); + } + + #[test] + fn midnight_crossing_inside_late() { + // 22:00..03:00 \u2014 time is 23:00, before midnight but after start + assert!(is_in_update_window(t("23:00"), "22:00", Some("03:00"))); + } + + #[test] + fn midnight_crossing_at_start() { + assert!(is_in_update_window(t("22:00"), "22:00", Some("03:00"))); + } + + #[test] + fn midnight_crossing_at_end_exclusive() { + assert!(!is_in_update_window(t("03:00"), "22:00", Some("03:00"))); + } + + #[test] + fn midnight_crossing_outside() { + // 22:00..03:00 \u2014 time is 10:00, outside the window + assert!(!is_in_update_window(t("10:00"), "22:00", Some("03:00"))); + } + + // --- Interval parsing tests --- + + #[test] + fn interval_bare_number_is_seconds() { + assert_eq!(parse_interval("3600"), Duration::from_secs(3600)); + } + + #[test] + fn interval_bare_small_number_is_seconds_not_fallback() { + // "30" has no unit suffix; must be treated as 30 seconds, not fall back to 24 hours. + assert_eq!(parse_interval("30"), Duration::from_secs(30)); + } + + #[test] + fn interval_humantime_day() { + assert_eq!(parse_interval("1d"), Duration::from_secs(86_400)); + } + + #[test] + fn interval_humantime_hours_minutes() { + assert_eq!(parse_interval("1h 30m"), Duration::from_secs(5400)); + } + + #[test] + fn interval_invalid_falls_back() { + assert_eq!(parse_interval("not-a-duration"), Duration::from_secs(86_400)); + } + + // --- URL version modification tests --- + #[test] fn test_try_modify_product_url_version() { let url = "https://cdn.devolutions.net/download/DevolutionsGateway-x86_64-2024.3.3.0.msi"; diff --git a/devolutions-agent/src/updater/package.rs b/devolutions-agent/src/updater/package.rs index fd82be7f0..8d8db68ae 100644 --- a/devolutions-agent/src/updater/package.rs +++ b/devolutions-agent/src/updater/package.rs @@ -2,7 +2,7 @@ use std::ops::DerefMut; -use camino::Utf8Path; +use camino::{Utf8Path, Utf8PathBuf}; use uuid::Uuid; use win_api_wrappers::utils::WideString; @@ -16,6 +16,9 @@ const DEVOLUTIONS_CERT_THUMBPRINTS: &[&str] = &[ "50f753333811ff11f1920274afde3ffd4468b210", ]; +/// Filename of the updater shim executable installed alongside the agent. +const AGENT_UPDATER_SHIM_NAME: &str = "devolutions-agent-updater.exe"; + pub(crate) async fn install_package( ctx: &UpdaterCtx, path: &Utf8Path, @@ -23,6 +26,7 @@ pub(crate) async fn install_package( ) -> Result<(), UpdaterError> { match ctx.product { Product::Gateway | Product::HubService => install_msi(ctx, path, log_path).await, + Product::Agent => install_agent_via_shim(path, ctx.downgrade_product_code).await, } } @@ -33,7 +37,111 @@ pub(crate) async fn uninstall_package( ) -> Result<(), UpdaterError> { match ctx.product { Product::Gateway | Product::HubService => uninstall_msi(ctx, product_code, log_path).await, + // For agent self-update the shim handles uninstall + install in sequence; the + // in-process uninstall step is skipped to avoid stopping the service prematurely. + Product::Agent => Ok(()), + } +} + +/// Install a new version of Devolutions Agent by launching the updater shim as a detached process. +/// +/// The shim (`devolutions-agent-updater.exe`) is copied to a temp location before being launched +/// so that the MSI installer can freely overwrite the agent installation directory. The shim +/// then runs `msiexec` silently, which stops the agent service, replaces its files, and +/// restarts it. Since the shim is detached from the agent service, it survives the service +/// restart and ensures the installation completes. +/// +/// When `downgrade_product_code` is `Some` the shim will first run `msiexec /x` to uninstall +/// the currently installed version before running `msiexec /i` for the target version. +async fn install_agent_via_shim(msi_path: &Utf8Path, downgrade_product_code: Option) -> Result<(), UpdaterError> { + let shim_path = find_agent_updater_shim()?; + + // Copy the shim to a temp location so it survives the MSI replacing the installation dir. + let temp_shim_path = copy_shim_to_temp(&shim_path).await?; + info!(%msi_path, %temp_shim_path, "Launching agent updater shim as detached process"); + + // Schedule the temp shim copy for deletion at the next system reboot. + if let Err(error) = remove_file_on_reboot(&temp_shim_path) { + error!(%error, "Failed to schedule temp shim for deletion on reboot"); } + + launch_updater_shim_detached(&temp_shim_path, msi_path, downgrade_product_code)?; + + if downgrade_product_code.is_some() { + info!("Agent updater shim launched; agent will be uninstalled then reinstalled at the target version"); + } else { + info!("Agent updater shim launched; agent service will be updated and restarted shortly"); + } + + Ok(()) +} + +/// Locate the agent updater shim executable next to the running agent binary. +fn find_agent_updater_shim() -> Result { + let exe_path = std::env::current_exe().map_err(UpdaterError::Io)?; + + let exe_path = Utf8PathBuf::from_path_buf(exe_path) + .map_err(|_| UpdaterError::Io(std::io::Error::other("agent executable path contains invalid UTF-8")))?; + + let exe_dir = exe_path + .parent() + .ok_or_else(|| UpdaterError::Io(std::io::Error::other("cannot determine agent executable directory")))?; + + let shim_path = exe_dir.join(AGENT_UPDATER_SHIM_NAME); + + if !shim_path.exists() { + return Err(UpdaterError::AgentUpdaterShimNotFound { path: shim_path }); + } + + Ok(shim_path) +} + +/// Copy the shim executable to a temporary path (UUID-named) so it can run independently of +/// the installation directory. +async fn copy_shim_to_temp(shim_path: &Utf8Path) -> Result { + let temp_shim_path = Utf8PathBuf::from_path_buf(std::env::temp_dir()) + .expect("BUG: OS should always return valid UTF-8 temp path") + .join(format!("{}-devolutions-agent-updater.exe", Uuid::new_v4())); + + tokio::fs::copy(shim_path, &temp_shim_path) + .await + .map_err(UpdaterError::Io)?; + + Ok(temp_shim_path) +} + +/// Launch the updater shim as a detached process so it survives the agent service restart. +/// +/// `DETACHED_PROCESS` disassociates the child from the parent's console. +/// `CREATE_NEW_PROCESS_GROUP` creates a new process group so that Ctrl+C signals from the +/// parent do not propagate to the child. +/// +/// When `downgrade_product_code` is `Some`, it is passed to the shim as `-x ` +/// (before the MSI path) so it can uninstall the old version before installing the new one. +fn launch_updater_shim_detached( + shim_path: &Utf8Path, + msi_path: &Utf8Path, + downgrade_product_code: Option, +) -> Result<(), UpdaterError> { + use std::os::windows::process::CommandExt as _; + + // Flags reference: https://learn.microsoft.com/en-us/windows/win32/procthread/process-creation-flags + const DETACHED_PROCESS: u32 = 0x0000_0008; + const CREATE_NEW_PROCESS_GROUP: u32 = 0x0000_0200; + + let mut cmd = std::process::Command::new(shim_path.as_str()); + if let Some(code) = downgrade_product_code { + cmd.args(["-x", &code.braced().to_string()]); + } + cmd.arg(msi_path.as_str()); + cmd.stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .creation_flags(DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP) + .spawn() + .map_err(|source| UpdaterError::AgentShimLaunch { source })?; + + Ok(()) } async fn install_msi(ctx: &UpdaterCtx, path: &Utf8Path, log_path: &Utf8Path) -> Result<(), UpdaterError> { @@ -224,7 +332,7 @@ fn ensure_enough_rights() -> Result<(), UpdaterError> { pub(crate) fn validate_package(ctx: &UpdaterCtx, path: &Utf8Path) -> Result<(), UpdaterError> { match ctx.product { - Product::Gateway | Product::HubService => validate_msi(ctx, path), + Product::Gateway | Product::HubService | Product::Agent => validate_msi(ctx, path), } } diff --git a/devolutions-agent/src/updater/product.rs b/devolutions-agent/src/updater/product.rs index d6a1644df..d97390f0c 100644 --- a/devolutions-agent/src/updater/product.rs +++ b/devolutions-agent/src/updater/product.rs @@ -3,13 +3,17 @@ use std::str::FromStr; use devolutions_agent_shared::{ProductUpdateInfo, UpdateJson}; -use crate::updater::productinfo::{GATEWAY_PRODUCT_ID, HUB_SERVICE_PRODUCT_ID}; +use crate::updater::productinfo::{AGENT_PRODUCT_ID, GATEWAY_PRODUCT_ID, HUB_SERVICE_PRODUCT_ID}; /// Product IDs to track updates for #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum Product { + /// Devolutions Gateway service Gateway, + /// Devolutions Hub Service HubService, + /// Devolutions Agent service (self-update) + Agent, } impl fmt::Display for Product { @@ -17,6 +21,7 @@ impl fmt::Display for Product { match self { Product::Gateway => write!(f, "Gateway"), Product::HubService => write!(f, "HubService"), + Product::Agent => write!(f, "Agent"), } } } @@ -28,6 +33,7 @@ impl FromStr for Product { match s { "Gateway" => Ok(Product::Gateway), "HubService" => Ok(Product::HubService), + "Agent" => Ok(Product::Agent), _ => Err(()), } } @@ -38,6 +44,7 @@ impl Product { match self { Product::Gateway => update_json.gateway.clone(), Product::HubService => update_json.hub_service.clone(), + Product::Agent => update_json.agent.clone(), } } @@ -45,13 +52,13 @@ impl Product { match self { Product::Gateway => GATEWAY_PRODUCT_ID, Product::HubService => HUB_SERVICE_PRODUCT_ID, + Product::Agent => AGENT_PRODUCT_ID, } } pub(crate) const fn get_package_extension(self) -> &'static str { match self { - Product::Gateway => "msi", - Product::HubService => "msi", + Product::Gateway | Product::HubService | Product::Agent => "msi", } } } diff --git a/devolutions-agent/src/updater/product_actions.rs b/devolutions-agent/src/updater/product_actions.rs index 3b745eafd..5de7767c2 100644 --- a/devolutions-agent/src/updater/product_actions.rs +++ b/devolutions-agent/src/updater/product_actions.rs @@ -112,6 +112,9 @@ impl ServiceUpdateActions { let should_start = match self.product { Product::Gateway => !state.startup_was_automatic && state.was_running, Product::HubService => state.was_running, + // INVARIANT: AgentSelfUpdateActions is used for Product::Agent, not + // ServiceUpdateActions; this branch is unreachable. + Product::Agent => unreachable!("ServiceUpdateActions is never used for Product::Agent"), }; if should_start { @@ -185,6 +188,9 @@ impl ProductUpdateActions for ServiceUpdateActions { warn!("No Hub Service features detected, installer may use defaults"); } } + // INVARIANT: AgentSelfUpdateActions is used for Product::Agent, not + // ServiceUpdateActions; this branch is unreachable. + Product::Agent => unreachable!("ServiceUpdateActions is never used for Product::Agent"), } Vec::new() @@ -208,5 +214,33 @@ pub(crate) fn build_product_actions(product: Product) -> Box Box::new(AgentSelfUpdateActions), + } +} + +/// Product update actions for Devolutions Agent self-update. +/// +/// The agent service lifecycle (stop/start) is fully managed by the MSI installer, so no +/// explicit service manipulation is needed here. The install step launches a detached shim +/// process that runs msiexec, allowing the installer to stop and restart the agent service +/// without interrupting the updater shim. +struct AgentSelfUpdateActions; + +impl ProductUpdateActions for AgentSelfUpdateActions { + fn pre_update(&mut self) -> Result<(), UpdaterError> { + // The MSI installer manages the agent service lifecycle. + Ok(()) + } + + fn get_msiexec_install_params(&self) -> Vec { + // No extra msiexec parameters are needed for the agent self-update. + // The updater shim launches msiexec directly with default parameters. + Vec::new() + } + + fn post_update(&mut self) -> Result<(), UpdaterError> { + // The MSI installer manages the agent service lifecycle. + // The new agent version will start automatically after the MSI completes. + Ok(()) } } diff --git a/devolutions-agent/src/updater/productinfo/mod.rs b/devolutions-agent/src/updater/productinfo/mod.rs index e9d20966d..5f3a600e7 100644 --- a/devolutions-agent/src/updater/productinfo/mod.rs +++ b/devolutions-agent/src/updater/productinfo/mod.rs @@ -6,4 +6,6 @@ pub(crate) const GATEWAY_PRODUCT_ID: &str = "Gateway"; pub(crate) const HUB_SERVICE_PRODUCT_ID: &str = "HubServices"; +pub(crate) const AGENT_PRODUCT_ID: &str = "Agent"; + pub(crate) use db::{ProductInfoDb, get_target_arch}; diff --git a/devolutions-gateway/src/api/mod.rs b/devolutions-gateway/src/api/mod.rs index a5cbbc643..8b0d61372 100644 --- a/devolutions-gateway/src/api/mod.rs +++ b/devolutions-gateway/src/api/mod.rs @@ -16,6 +16,7 @@ pub mod session; pub mod sessions; pub mod traffic; pub mod update; +pub mod update_agent; pub mod webapp; pub fn make_router(state: crate::DgwState) -> axum::Router { @@ -35,7 +36,13 @@ pub fn make_router(state: crate::DgwState) -> axum::Router { .nest("/jet/webapp", webapp::make_router(state.clone())) .nest("/jet/net", net::make_router(state.clone())) .nest("/jet/traffic", traffic::make_router(state.clone())) - .route("/jet/update", axum::routing::post(update::trigger_update_check)); + .route("/jet/update", axum::routing::post(update::trigger_update_check)) + .route("/jet/agent-update", axum::routing::post(update_agent::trigger_agent_update)) + .route( + "/jet/agent-update-config", + axum::routing::get(update_agent::get_agent_auto_update) + .post(update_agent::set_agent_auto_update), + ); if state.conf_handle.get_conf().web_app.enabled { router = router.route( diff --git a/devolutions-gateway/src/api/update.rs b/devolutions-gateway/src/api/update.rs index 4a1139dc1..b2d7fe00a 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, + agent: None, }; let update_json = serde_json::to_string(&update_json).map_err( diff --git a/devolutions-gateway/src/api/update_agent.rs b/devolutions-gateway/src/api/update_agent.rs new file mode 100644 index 000000000..795b9fa70 --- /dev/null +++ b/devolutions-gateway/src/api/update_agent.rs @@ -0,0 +1,228 @@ +use axum::Json; +use axum::extract::Query; +use devolutions_agent_shared::{ProductUpdateInfo, UpdateJson, VersionSpecification, get_updater_file_path}; +use devolutions_agent_shared::AgentAutoUpdateConf; +use devolutions_agent_shared::agent_auto_update::{ + DEFAULT_INTERVAL, DEFAULT_WINDOW_END, DEFAULT_WINDOW_START, read_agent_auto_update_conf, + write_agent_auto_update_conf, +}; +use hyper::StatusCode; + +use crate::extract::UpdateScope; +use crate::http::{HttpError, HttpErrorBuilder}; + +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[derive(Serialize)] +pub(crate) struct GetAgentAutoUpdateResponse { + /// Whether periodic Devolutions Agent self-update is enabled. + #[serde(rename = "Enabled")] + pub enabled: bool, + /// Minimum interval between auto-update checks (humantime string, e.g. `"1d"`, `"12h"`). + #[serde(rename = "Interval")] + pub interval: String, + /// Start of the maintenance window (local time, `HH:MM`). + #[serde(rename = "UpdateWindowStart")] + pub update_window_start: String, + /// End of the maintenance window (local time, `HH:MM`, exclusive). + /// `null` means no upper bound (the window runs until midnight and beyond). + #[serde(rename = "UpdateWindowEnd")] + pub update_window_end: Option, +} + +impl From for GetAgentAutoUpdateResponse { + fn from(c: AgentAutoUpdateConf) -> Self { + Self { + enabled: c.enabled, + interval: c.interval, + update_window_start: c.update_window_start, + update_window_end: c.update_window_end, + } + } +} + +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[derive(Deserialize)] +pub(crate) struct SetAgentAutoUpdateRequest { + /// Whether periodic Devolutions Agent self-update is enabled. + #[serde(rename = "Enabled")] + pub enabled: bool, + /// Minimum interval between auto-update checks (default: `"1d"`). + /// + /// Accepts humantime duration strings such as `"1d"`, `"12h"`, `"30m"`, or a bare integer + /// treated as seconds (e.g. `"3600"`). + #[serde(rename = "Interval", default = "default_interval")] + pub interval: String, + /// Start of the maintenance window in `HH:MM` local time (default: `"02:00"`). + #[serde(rename = "UpdateWindowStart", default = "default_window_start")] + pub update_window_start: String, + /// End of the maintenance window in `HH:MM` local time, exclusive (default: `"04:00"`). + /// + /// `null` means the window has no upper bound (any time from `UpdateWindowStart` onward). + /// If end ≤ start the window is assumed to cross midnight. + #[serde(rename = "UpdateWindowEnd", default = "default_window_end")] + pub update_window_end: Option, +} + +fn default_interval() -> String { + DEFAULT_INTERVAL.to_owned() +} + +fn default_window_start() -> String { + DEFAULT_WINDOW_START.to_owned() +} + +fn default_window_end() -> Option { + Some(DEFAULT_WINDOW_END.to_owned()) +} + +impl From for AgentAutoUpdateConf { + fn from(r: SetAgentAutoUpdateRequest) -> Self { + Self { + enabled: r.enabled, + interval: r.interval, + update_window_start: r.update_window_start, + update_window_end: r.update_window_end, + } + } +} + +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[derive(Serialize)] +pub(crate) struct SetAgentAutoUpdateResponse {} + +/// Retrieve Devolutions Agent auto-update settings. +/// +/// Returns the current `AgentAutoUpdate` configuration from `agent.json`. +/// When the section is absent the response contains the built-in defaults +/// (`Enabled: false`, `IntervalHours: 24`, window `02:00`-`04:00`). +/// +/// The Devolutions Agent service must be restarted for changes to take effect. +#[cfg_attr(feature = "openapi", utoipa::path( + get, + operation_id = "GetAgentAutoUpdate", + tag = "Update", + path = "/jet/agent-update-config", + responses( + (status = 200, description = "Agent auto-update settings", body = GetAgentAutoUpdateResponse), + (status = 401, description = "Invalid or missing authorization token"), + (status = 403, description = "Insufficient permissions"), + (status = 500, description = "Failed to read agent configuration"), + ), + security(("scope_token" = ["gateway.update"])), +))] +pub(super) async fn get_agent_auto_update( + _scope: UpdateScope, +) -> Result, HttpError> { + let conf = read_agent_auto_update_conf().map_err( + HttpError::internal() + .with_msg("failed to read agent auto-update configuration") + .err(), + )?; + + Ok(Json(GetAgentAutoUpdateResponse::from(conf))) +} + +/// Update Devolutions Agent auto-update settings. +/// +/// Writes the supplied configuration into the `Updater.AgentAutoUpdate` section of +/// `agent.json`, preserving all other keys in the file. +/// +/// The Devolutions Agent service must be restarted for changes to take effect. +#[cfg_attr(feature = "openapi", utoipa::path( + post, + operation_id = "SetAgentAutoUpdate", + tag = "Update", + path = "/jet/agent-update-config", + request_body = SetAgentAutoUpdateRequest, + responses( + (status = 200, description = "Agent auto-update settings updated successfully", body = SetAgentAutoUpdateResponse), + (status = 400, description = "Bad request"), + (status = 401, description = "Invalid or missing authorization token"), + (status = 403, description = "Insufficient permissions"), + (status = 500, description = "Failed to write agent configuration"), + ), + security(("scope_token" = ["gateway.update"])), +))] +pub(super) async fn set_agent_auto_update( + _scope: UpdateScope, + Json(body): Json, +) -> Result, HttpError> { + let conf = AgentAutoUpdateConf::from(body); + + write_agent_auto_update_conf(&conf).map_err( + HttpError::internal() + .with_msg("failed to write agent auto-update configuration") + .err(), + )?; + + Ok(Json(SetAgentAutoUpdateResponse {})) +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct AgentUpdateQueryParam { + version: VersionSpecification, +} + +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[derive(Serialize)] +pub(crate) struct AgentUpdateResponse {} + +/// Triggers Devolutions Agent update process. +/// +/// Writes the requested version into the `Agent` field of `update.json`, which is then +/// picked up by Devolutions Agent when changes are detected. If the version written is +/// higher than the currently installed version, Devolutions Agent will proceed with the +/// update process via the agent-updater shim. +#[cfg_attr(feature = "openapi", utoipa::path( + post, + operation_id = "TriggerAgentUpdate", + tag = "Update", + path = "/jet/agent-update", + params( + ("version" = String, Query, description = "The version to install; use 'latest' for the latest version, or 'w.x.y.z' for a specific version"), + ), + responses( + (status = 200, description = "Agent update request has been processed successfully", body = AgentUpdateResponse), + (status = 400, description = "Bad request"), + (status = 401, description = "Invalid or missing authorization token"), + (status = 403, description = "Insufficient permissions"), + (status = 500, description = "Agent updater service is malfunctioning"), + (status = 503, description = "Agent updater service is unavailable"), + ), + security(("scope_token" = ["gateway.update"])), +))] +pub(super) async fn trigger_agent_update( + Query(query): Query, + _scope: UpdateScope, +) -> Result, HttpError> { + let target_version = query.version; + + let updater_file_path = get_updater_file_path(); + + if !updater_file_path.exists() { + return Err( + HttpErrorBuilder::new(StatusCode::SERVICE_UNAVAILABLE).msg("Agent updater service is not installed") + ); + } + + let update_json = UpdateJson { + agent: Some(ProductUpdateInfo { target_version }), + gateway: None, + hub_service: None, + }; + + let update_json = serde_json::to_string(&update_json).map_err( + HttpError::internal() + .with_msg("failed to serialize the update manifest") + .err(), + )?; + + std::fs::write(updater_file_path, update_json).map_err( + HttpError::internal() + .with_msg("failed to write the new `update.json` manifest on disk") + .err(), + )?; + + Ok(Json(AgentUpdateResponse {})) +} diff --git a/package/AgentWindowsManaged/Actions/AgentActions.cs b/package/AgentWindowsManaged/Actions/AgentActions.cs index 407b8bebf..1b4fb7055 100644 --- a/package/AgentWindowsManaged/Actions/AgentActions.cs +++ b/package/AgentWindowsManaged/Actions/AgentActions.cs @@ -301,6 +301,17 @@ internal static class AgentActions Condition = Features.PEDM_FEATURE.BeingUninstall(), }; + private static readonly ElevatedManagedAction configureAgentAutoUpdate = new( + CustomActions.ConfigureAgentAutoUpdate + ) + { + Id = new Id($"CA.{nameof(configureAgentAutoUpdate)}"), + Sequence = Sequence.InstallExecuteSequence, + Return = Return.check, + Step = Step.StartServices, + When = When.Before + }; + private static string UseProperties(IEnumerable properties) { if (!properties?.Any() ?? false) @@ -329,6 +340,7 @@ private static string UseProperties(IEnumerable properties) getInstallDirFromRegistry, setArpInstallLocation, configureFeatures, + configureAgentAutoUpdate, createProgramDataDirectory, setProgramDataDirectoryPermissions, createProgramDataPedmDirectories, diff --git a/package/AgentWindowsManaged/Actions/CustomActions.cs b/package/AgentWindowsManaged/Actions/CustomActions.cs index 10d3c4185..de6a53b6c 100644 --- a/package/AgentWindowsManaged/Actions/CustomActions.cs +++ b/package/AgentWindowsManaged/Actions/CustomActions.cs @@ -290,6 +290,51 @@ static ActionResult ToggleAgentFeature(Session session, string feature, bool ena } } + [CustomAction] + public static ActionResult ConfigureAgentAutoUpdate(Session session) + { + string path = Path.Combine(ProgramDataDirectory, "agent.json"); + + try + { + bool enable = session.IsFeatureEnabled(Features.AGENT_UPDATER_FEATURE.Id); + + Dictionary config = []; + + try + { + using StreamReader reader = new StreamReader(path); + config = JsonConvert.DeserializeObject>(reader.ReadToEnd()); + } + catch (Exception) + { + // ignored. Previous config is either invalid or non-existent. + } + + // Navigate into or create the "Updater" section to set AgentAutoUpdate.Enabled. + Dictionary updaterSection = []; + + if (config.TryGetValue("Updater", out object existing) && + existing is Newtonsoft.Json.Linq.JObject existingObj) + { + updaterSection = existingObj.ToObject>(); + } + + updaterSection["AgentAutoUpdate"] = new Dictionary { { "Enabled", enable } }; + config["Updater"] = updaterSection; + + using StreamWriter writer = new StreamWriter(path); + writer.Write(JsonConvert.SerializeObject(config, Formatting.Indented)); + + return ActionResult.Success; + } + catch (Exception e) + { + session.Log($"failed to configure agent auto-update: {e}"); + return ActionResult.Failure; + } + } + [CustomAction] public static ActionResult ConfigureFeatures(Session session) { @@ -319,7 +364,7 @@ public static ActionResult RegisterExplorerCommand(Session session) string destinationDllPath = Path.Combine(installDir, Includes.SHELL_EXT_BINARY_NAME); File.Copy(dllPath, destinationDllPath, true); - + string clsidPath = $"CLSID\\{Includes.SHELL_EXT_CSLID:B}"; using RegistryKey clsidKey = Registry.ClassesRoot.CreateSubKey(clsidPath); @@ -488,7 +533,7 @@ public static ActionResult ShutdownDesktopApp(Session session) using EventWaitHandle quitEvent = new EventWaitHandle(false, EventResetMode.ManualReset, $"{mutexId}_{process.Id}"); quitEvent.Set(); } - + process.WaitForExit((int)TimeSpan.FromSeconds(1).TotalMilliseconds); if (process.HasExited) diff --git a/package/AgentWindowsManaged/DevolutionsAgent.csproj b/package/AgentWindowsManaged/DevolutionsAgent.csproj index 527dd4bd4..812b8c812 100644 --- a/package/AgentWindowsManaged/DevolutionsAgent.csproj +++ b/package/AgentWindowsManaged/DevolutionsAgent.csproj @@ -30,6 +30,7 @@ + diff --git a/package/AgentWindowsManaged/Dialogs/Wizard.cs b/package/AgentWindowsManaged/Dialogs/Wizard.cs index df1f71f78..bd96a6b09 100644 --- a/package/AgentWindowsManaged/Dialogs/Wizard.cs +++ b/package/AgentWindowsManaged/Dialogs/Wizard.cs @@ -28,7 +28,7 @@ static Wizard() Sequence = dialogs.ToArray(); } - + internal static IEnumerable Dialogs => Sequence; internal static int Move(IManagedDialog current, bool forward) diff --git a/package/AgentWindowsManaged/Program.cs b/package/AgentWindowsManaged/Program.cs index 883b274f3..815fadf8d 100644 --- a/package/AgentWindowsManaged/Program.cs +++ b/package/AgentWindowsManaged/Program.cs @@ -87,6 +87,8 @@ private static string DevolutionsDesktopAgentPath private static string DevolutionsPedmShellExtMsix => ResolveArtifact("DAGENT_PEDM_SHELL_EXT_MSIX", "..\\..\\target\\debug\\DevolutionsPedmShellExt.msix"); + private static string DevolutionsAgentUpdaterExePath => ResolveArtifact("DAGENT_UPDATER_EXECUTABLE", "..\\..\\target\\debug\\devolutions-agent-updater.exe"); + private static string DevolutionsSession => ResolveArtifact("DAGENT_SESSION_EXECUTABLE", "..\\..\\target\\debug\\devolutions-session.exe"); private static string DevolutionsTun2SocksExe => ResolveArtifact("DAGENT_TUN2SOCKS_EXE", "..\\..\\tun2socks.exe"); @@ -291,9 +293,11 @@ static void Main() new Dir(Features.PEDM_FEATURE, "ShellExt", new File(Features.PEDM_FEATURE, DevolutionsPedmShellExtDll), new File(Features.PEDM_FEATURE, DevolutionsPedmShellExtMsix)), - new Dir(Features.AGENT_FEATURE, "tun2socks", - new File(Features.AGENT_FEATURE, DevolutionsTun2SocksExe), - new File(Features.AGENT_FEATURE, DevolutionsWintunDll)) + new Dir(Features.AGENT_FEATURE, "tun2socks", + new File(Features.AGENT_FEATURE, DevolutionsTun2SocksExe), + new File(Features.AGENT_FEATURE, DevolutionsWintunDll)), + new Dir(Features.AGENT_UPDATER_FEATURE, "updater", + new File(Features.AGENT_UPDATER_FEATURE, DevolutionsAgentUpdaterExePath)) } })), }; diff --git a/package/AgentWindowsManaged/Properties/AgentProperties.g.cs b/package/AgentWindowsManaged/Properties/AgentProperties.g.cs index 212424cb6..72da183ad 100644 --- a/package/AgentWindowsManaged/Properties/AgentProperties.g.cs +++ b/package/AgentWindowsManaged/Properties/AgentProperties.g.cs @@ -8,7 +8,7 @@ namespace DevolutionsAgent.Properties /// internal partial class AgentProperties { - + internal static readonly WixProperty configureAgent = new() { Id = "P.CONFIGUREAGENT", @@ -27,15 +27,15 @@ public Boolean ConfigureAgent string stringValue = this.FnGetPropValue(configureAgent.Id); return WixProperties.GetPropertyValue(stringValue); } - set - { + set + { if (this.runtimeSession is not null) { - this.runtimeSession.Set(configureAgent, value); + this.runtimeSession.Set(configureAgent, value); } } } - + internal static readonly WixProperty debugPowerShell = new() { Id = "P.DEBUGPOWERSHELL", @@ -53,15 +53,15 @@ public Boolean DebugPowerShell string stringValue = this.FnGetPropValue(debugPowerShell.Id); return WixProperties.GetPropertyValue(stringValue); } - set - { + set + { if (this.runtimeSession is not null) { - this.runtimeSession.Set(debugPowerShell, value); + this.runtimeSession.Set(debugPowerShell, value); } } } - + internal static readonly WixProperty installId = new() { Id = "P.InstallId", @@ -79,15 +79,15 @@ public Guid InstallId string stringValue = this.FnGetPropValue(installId.Id); return WixProperties.GetPropertyValue(stringValue); } - set - { + set + { if (this.runtimeSession is not null) { - this.runtimeSession.Set(installId, value); + this.runtimeSession.Set(installId, value); } } } - + internal static readonly WixProperty netFx45Version = new() { Id = "P.NetFx45Version", @@ -105,15 +105,15 @@ public UInt32 NetFx45Version string stringValue = this.FnGetPropValue(netFx45Version.Id); return WixProperties.GetPropertyValue(stringValue); } - set - { + set + { if (this.runtimeSession is not null) { - this.runtimeSession.Set(netFx45Version, value); + this.runtimeSession.Set(netFx45Version, value); } } } - + internal static readonly WixProperty firstInstall = new() { Id = "P.FirstInstall", @@ -131,15 +131,15 @@ public Boolean FirstInstall string stringValue = this.FnGetPropValue(firstInstall.Id); return WixProperties.GetPropertyValue(stringValue); } - set - { + set + { if (this.runtimeSession is not null) { - this.runtimeSession.Set(firstInstall, value); + this.runtimeSession.Set(firstInstall, value); } } } - + internal static readonly WixProperty upgrading = new() { Id = "P.Upgrading", @@ -157,15 +157,15 @@ public Boolean Upgrading string stringValue = this.FnGetPropValue(upgrading.Id); return WixProperties.GetPropertyValue(stringValue); } - set - { + set + { if (this.runtimeSession is not null) { - this.runtimeSession.Set(upgrading, value); + this.runtimeSession.Set(upgrading, value); } } } - + internal static readonly WixProperty removingForUpgrade = new() { Id = "P.RemovingForUpgrade", @@ -183,15 +183,15 @@ public Boolean RemovingForUpgrade string stringValue = this.FnGetPropValue(removingForUpgrade.Id); return WixProperties.GetPropertyValue(stringValue); } - set - { + set + { if (this.runtimeSession is not null) { - this.runtimeSession.Set(removingForUpgrade, value); + this.runtimeSession.Set(removingForUpgrade, value); } } } - + internal static readonly WixProperty uninstalling = new() { Id = "P.Uninstalling", @@ -209,15 +209,15 @@ public Boolean Uninstalling string stringValue = this.FnGetPropValue(uninstalling.Id); return WixProperties.GetPropertyValue(stringValue); } - set - { + set + { if (this.runtimeSession is not null) { - this.runtimeSession.Set(uninstalling, value); + this.runtimeSession.Set(uninstalling, value); } } } - + internal static readonly WixProperty maintenance = new() { Id = "P.Maintenance", @@ -235,38 +235,37 @@ public Boolean Maintenance string stringValue = this.FnGetPropValue(maintenance.Id); return WixProperties.GetPropertyValue(stringValue); } - set - { + set + { if (this.runtimeSession is not null) { - this.runtimeSession.Set(maintenance, value); + this.runtimeSession.Set(maintenance, value); } } } - + public static IWixProperty[] Properties = { - + configureAgent, - + debugPowerShell, - + installId, - + netFx45Version, - + firstInstall, - + upgrading, - + removingForUpgrade, - + uninstalling, - + maintenance, - + }; } } - diff --git a/package/AgentWindowsManaged/Properties/AgentProperties.g.tt b/package/AgentWindowsManaged/Properties/AgentProperties.g.tt index 28047cff0..16422767c 100644 --- a/package/AgentWindowsManaged/Properties/AgentProperties.g.tt +++ b/package/AgentWindowsManaged/Properties/AgentProperties.g.tt @@ -14,7 +14,7 @@ namespace DevolutionsAgent.Properties /// internal partial class AgentProperties { -<# for (int idx = 0; idx < this.properties.GetLength(0); idx++) { #> +<# for (int idx = 0; idx < this.properties.GetLength(0); idx++) { #> internal static readonly WixProperty<<#= this.properties[idx].TypeName #>> <#= this.properties[idx].PrivateName #> = new() { <# string id = string.IsNullOrEmpty(this.properties[idx].Id) ? this.properties[idx].Name : this.properties[idx].Id; #> @@ -43,21 +43,21 @@ namespace DevolutionsAgent.Properties string stringValue = this.FnGetPropValue(<#= this.properties[idx].PrivateName #>.Id); return WixProperties.GetPropertyValue<<#= this.properties[idx].TypeName #>>(stringValue); } - set - { + set + { if (this.runtimeSession is not null) { - this.runtimeSession.Set(<#= this.properties[idx].PrivateName #>, value); + this.runtimeSession.Set(<#= this.properties[idx].PrivateName #>, value); } } } -<# } #> +<# } #> public static IWixProperty[] Properties = { -<# for (int idx = 0; idx < this.properties.GetLength(0); idx++) { #> +<# for (int idx = 0; idx < this.properties.GetLength(0); idx++) { #> <#= this.properties[idx].PrivateName #>, -<# } #> +<# } #> }; } } @@ -67,7 +67,7 @@ namespace DevolutionsAgent.Properties { public abstract string Comment { get; set; } public abstract bool Hidden { get; set; } - public abstract string Id { get; set; } + public abstract string Id { get; set; } public abstract string Name { get; set; } public abstract string PrivateName { get; } public abstract bool Public { get; set; } @@ -139,5 +139,5 @@ namespace DevolutionsAgent.Properties new PropertyDefinition("RemovingForUpgrade", false, isPublic: false, secure: false), new PropertyDefinition("Uninstalling", false, isPublic: false, secure: false), new PropertyDefinition("Maintenance", false, isPublic: false, secure: false), - }; + }; #> diff --git a/package/AgentWindowsManaged/Resources/DevolutionsAgent_en-us.wxl b/package/AgentWindowsManaged/Resources/DevolutionsAgent_en-us.wxl index ba52af25b..5a069e68b 100644 --- a/package/AgentWindowsManaged/Resources/DevolutionsAgent_en-us.wxl +++ b/package/AgentWindowsManaged/Resources/DevolutionsAgent_en-us.wxl @@ -3,8 +3,8 @@ Installs the Devolutions Agent service Devolutions Agent - Enables the Devolutions Gateway updater - Devolutions Gateway Updater + When enabled, the Devolutions Agent will automatically update itself whenever a new version is available within the configured maintenance window. + Enable Automatic Agent Updates Enables PEDM features and installs the shell extension Devolutions PEDM Installs the RDP Extension @@ -43,6 +43,11 @@ If it appears minimized then active it from the taskbar. Click Next to install to the default folder or click Change to choose another. Destination Folder + + Service Configuration + Configure optional service features. + When enabled, the Devolutions Agent will automatically update itself whenever a new version is available within the configured maintenance window. + Enable automatic agent updates Changing [ProductName] Installing [ProductName] diff --git a/package/AgentWindowsManaged/Resources/DevolutionsAgent_fr-fr.wxl b/package/AgentWindowsManaged/Resources/DevolutionsAgent_fr-fr.wxl index eb2213bd0..f8c107c7d 100644 --- a/package/AgentWindowsManaged/Resources/DevolutionsAgent_fr-fr.wxl +++ b/package/AgentWindowsManaged/Resources/DevolutionsAgent_fr-fr.wxl @@ -37,6 +37,11 @@ Si elle apparaît en mode réduit, alors vous devez l'activer à partir de la ba Cliquez sur Suivant pour effectuer l'installation dans le dossier par défaut, ou cliquez sur Modifier pour choisir un autre dossier. Dossier de destination + + Configuration du service + Configurez les fonctionnalités optionnelles du service. + Lorsqu'activé, Devolutions Agent se met à jour automatiquement dès qu'une nouvelle version est disponible dans la fenêtre de maintenance configurée. + Activer les mises à jour automatiques de l'agent Modification de [ProductName] Installation de [ProductName] @@ -62,8 +67,8 @@ Si elle apparaît en mode réduit, alors vous devez l'activer à partir de la ba Installs the Devolutions Agent service Devolutions Agent - Enables the Devolutions Gateway updater - Devolutions Gateway Updater + Lorsqu'activé, Devolutions Agent se met à jour automatiquement dès qu'une nouvelle version est disponible dans la fenêtre de maintenance configurée. + Activer la mise à jour automatique de l'agent Enables PEDM features and installs the shell extension Devolutions PEDM diff --git a/package/AgentWindowsManaged/Resources/Strings.g.cs b/package/AgentWindowsManaged/Resources/Strings.g.cs index e70727651..261f8a389 100644 --- a/package/AgentWindowsManaged/Resources/Strings.g.cs +++ b/package/AgentWindowsManaged/Resources/Strings.g.cs @@ -133,6 +133,22 @@ public static string I18n(this MsiRuntime runtime, string res) /// public const string InstallDirDlgDescription = "InstallDirDlgDescription"; /// + /// Service Configuration + /// + public const string ServiceConfigDlgTitle = "ServiceConfigDlgTitle"; + /// + /// Configure optional service features. + /// + public const string ServiceConfigDlgDescription = "ServiceConfigDlgDescription"; + /// + /// When enabled, the Devolutions Agent will automatically update itself whenever a new version is available within the configured maintenance window. + /// + public const string ServiceConfigDlgAgentAutoUpdateDescription = "ServiceConfigDlgAgentAutoUpdateDescription"; + /// + /// Enable automatic agent updates + /// + public const string ServiceConfigDlgAgentAutoUpdateLabel = "ServiceConfigDlgAgentAutoUpdateLabel"; + /// /// Installing [ProductName] /// public const string ProgressDlgTitleInstalling = "ProgressDlgTitleInstalling"; diff --git a/package/AgentWindowsManaged/Resources/Strings_en-US.json b/package/AgentWindowsManaged/Resources/Strings_en-US.json index 1a56cf955..cd6afc804 100644 --- a/package/AgentWindowsManaged/Resources/Strings_en-US.json +++ b/package/AgentWindowsManaged/Resources/Strings_en-US.json @@ -146,6 +146,24 @@ "text": "Click Next to install to the default folder or click Change to choose another." } ], + "serviceConfig": [ + { + "id": "ServiceConfigDlgTitle", + "text": "Service Configuration" + }, + { + "id": "ServiceConfigDlgDescription", + "text": "Configure optional service features." + }, + { + "id": "ServiceConfigDlgAgentAutoUpdateDescription", + "text": "When enabled, the Devolutions Agent will automatically update itself whenever a new version is available within the configured maintenance window." + }, + { + "id": "ServiceConfigDlgAgentAutoUpdateLabel", + "text": "Enable automatic agent updates" + } + ], "progress": [ { "id": "ProgressDlgTitleInstalling", From 43d4a7e1ce7d966e414974ecff24f5ec129a5b5e Mon Sep 17 00:00:00 2001 From: Vladyslav Nikonov Date: Fri, 27 Mar 2026 19:11:58 +0200 Subject: [PATCH 2/3] WIP: Improve agent update process --- Cargo.lock | 8 ++ Cargo.toml | 2 + devolutions-agent-updater/Cargo.toml | 15 +++ .../src/main.rs | 99 +++++++++++++++---- devolutions-agent/Cargo.toml | 4 - devolutions-agent/src/updater/error.rs | 2 + devolutions-agent/src/updater/mod.rs | 72 +++++++++++++- devolutions-agent/src/updater/package.rs | 92 ++++++++++++++--- package/AgentWindowsManaged/Program.cs | 11 ++- 9 files changed, 261 insertions(+), 44 deletions(-) create mode 100644 devolutions-agent-updater/Cargo.toml rename devolutions-agent/src/agent-updater.rs => devolutions-agent-updater/src/main.rs (66%) diff --git a/Cargo.lock b/Cargo.lock index 6b250d7d8..5174c329b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1434,6 +1434,14 @@ dependencies = [ "windows-result 0.3.4", ] +[[package]] +name = "devolutions-agent-updater" +version = "2026.1.0" +dependencies = [ + "camino", + "devolutions-agent", +] + [[package]] name = "devolutions-gateway" version = "2026.1.0" diff --git a/Cargo.toml b/Cargo.toml index 0d0cd75b5..7d12fedc4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,6 +3,7 @@ resolver = "2" members = [ "crates/*", "devolutions-agent", + "devolutions-agent-updater", "devolutions-gateway", "devolutions-session", "jetsocat", @@ -11,6 +12,7 @@ members = [ ] default-members = [ "devolutions-agent", + "devolutions-agent-updater", "devolutions-gateway", "devolutions-session", "jetsocat", diff --git a/devolutions-agent-updater/Cargo.toml b/devolutions-agent-updater/Cargo.toml new file mode 100644 index 000000000..f294f6372 --- /dev/null +++ b/devolutions-agent-updater/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "devolutions-agent-updater" +version.workspace = true +edition = "2024" +license = "MIT/Apache-2.0" +authors = ["Devolutions Inc. "] +description = "Updater shim for Devolutions Agent" +publish = false + +[lints] +workspace = true + +[dependencies] +camino = "1.1" +devolutions-agent = { path = "../devolutions-agent" } diff --git a/devolutions-agent/src/agent-updater.rs b/devolutions-agent-updater/src/main.rs similarity index 66% rename from devolutions-agent/src/agent-updater.rs rename to devolutions-agent-updater/src/main.rs index 7fe29a0e2..701e0770b 100644 --- a/devolutions-agent/src/agent-updater.rs +++ b/devolutions-agent-updater/src/main.rs @@ -26,6 +26,9 @@ // visibility when running from a terminal during development. #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] +#[cfg(windows)] +use devolutions_agent::updater::{self, AgentServiceState}; + fn main() { #[cfg(not(windows))] { @@ -73,61 +76,106 @@ fn windows_main() { // Derive paths from the MSI path. // The shim log uses a separate extension so it doesn't conflict with the msiexec log. let shim_log_path = format!("{msi_path}.shim.log"); - let msiexec_log_path = format!("{msi_path}.log"); + let install_log_path = format!("{msi_path}.install.log"); write_log(&shim_log_path, "devolutions-agent-updater: starting"); write_log(&shim_log_path, &format!(" MSI path: {msi_path}")); - write_log(&shim_log_path, &format!(" msiexec log: {msiexec_log_path}")); + write_log(&shim_log_path, &format!(" Install log: {install_log_path}")); + + // Capture agent service state before the update so we can restore it afterwards. + let service_state = match updater::query_agent_service_state() { + Ok(state) => { + write_log( + &shim_log_path, + &format!( + "Agent service state: running={}, automatic_startup={}", + state.was_running, state.startup_was_automatic + ), + ); + Some(state) + } + Err(e) => { + write_log(&shim_log_path, &format!("Failed to query agent service state: {e:#}")); + None + } + }; + + let exit_code = run_update( + uninstall_product_code, + msi_path, + &shim_log_path, + &install_log_path, + service_state.as_ref(), + ); + + // Always mark the shim log for deletion on the next reboot (best-effort). + let _ = updater::remove_file_on_reboot(camino::Utf8Path::new(&shim_log_path)); + + if exit_code != 0 { + std::process::exit(exit_code); + } +} + +/// Run the optional uninstall followed by the MSI install. +/// +/// Returns 0 on success or a non-zero msiexec exit code on failure. +#[cfg(windows)] +fn run_update( + uninstall_product_code: Option<&str>, + msi_path: &str, + shim_log_path: &str, + install_log_path: &str, + service_state: Option<&AgentServiceState>, +) -> i32 { // For downgrades, uninstall the currently installed version first. if let Some(product_code) = uninstall_product_code { - write_log(&shim_log_path, &format!(" Uninstalling product code: {product_code}")); + write_log(shim_log_path, &format!(" Uninstalling product code: {product_code}")); let uninstall_log_path = format!("{msi_path}.uninstall.log"); let status = std::process::Command::new("msiexec") .args(["/x", product_code, "/quiet", "/norestart", "/l*v", uninstall_log_path.as_str()]) .status(); + // Mark the uninstall log for deletion on reboot regardless of the msiexec result. + let _ = updater::remove_file_on_reboot(camino::Utf8Path::new(&uninstall_log_path)); + match status { Ok(exit_status) => { let code = exit_status.code().unwrap_or(-1); match code { 0 | 3010 | 1641 => { write_log( - &shim_log_path, + shim_log_path, &format!("devolutions-agent-updater: uninstall completed with code {code} (success)"), ); } _ => { write_log( - &shim_log_path, + shim_log_path, &format!("devolutions-agent-updater: uninstall failed with exit code {code}"), ); - std::process::exit(code); + return code; } } } Err(err) => { write_log( - &shim_log_path, + shim_log_path, &format!("devolutions-agent-updater: failed to launch msiexec for uninstall: {err}"), ); - std::process::exit(1); + return 1; } } } let status = std::process::Command::new("msiexec") - .args([ - "/i", - msi_path, - "/quiet", - "/norestart", - "/l*v", - msiexec_log_path.as_str(), - ]) + .args(["/i", msi_path, "/quiet", "/norestart", "/l*v", install_log_path]) .status(); + // Mark the install log for deletion on reboot regardless of the msiexec result. + let _ = updater::remove_file_on_reboot(camino::Utf8Path::new(install_log_path)); + match status { Ok(exit_status) => { let code = exit_status.code().unwrap_or(-1); @@ -139,25 +187,34 @@ fn windows_main() { match code { 0 | 3010 | 1641 => { write_log( - &shim_log_path, + shim_log_path, &format!("devolutions-agent-updater: msiexec completed with code {code} (success)"), ); + // Post-update: restore service running state when startup mode is manual. + if let Some(state) = service_state { + match updater::start_agent_service_if_needed(state) { + Ok(true) => write_log(shim_log_path, "Agent service started successfully"), + Ok(false) => {} + Err(e) => write_log(shim_log_path, &format!("Failed to start agent service: {e:#}")), + } + } + 0 } _ => { write_log( - &shim_log_path, + shim_log_path, &format!("devolutions-agent-updater: msiexec failed with exit code {code}"), ); - std::process::exit(code); + code } } } Err(err) => { write_log( - &shim_log_path, + shim_log_path, &format!("devolutions-agent-updater: failed to launch msiexec: {err}"), ); - std::process::exit(1); + 1 } } } diff --git a/devolutions-agent/Cargo.toml b/devolutions-agent/Cargo.toml index d7bea6bfe..dce4f32c9 100644 --- a/devolutions-agent/Cargo.toml +++ b/devolutions-agent/Cargo.toml @@ -12,10 +12,6 @@ publish = false name = "devolutions-agent" path = "src/main.rs" -[[bin]] -name = "devolutions-agent-updater" -path = "src/agent-updater.rs" - [lints] workspace = true diff --git a/devolutions-agent/src/updater/error.rs b/devolutions-agent/src/updater/error.rs index bc59ef5de..df2af0144 100644 --- a/devolutions-agent/src/updater/error.rs +++ b/devolutions-agent/src/updater/error.rs @@ -62,4 +62,6 @@ pub(crate) enum UpdaterError { AgentUpdaterShimNotFound { path: Utf8PathBuf }, #[error("failed to launch agent updater shim")] AgentShimLaunch { source: std::io::Error }, + #[error("an agent update is already in progress; skipping concurrent update request")] + AgentUpdateAlreadyInProgress, } diff --git a/devolutions-agent/src/updater/mod.rs b/devolutions-agent/src/updater/mod.rs index 497c15caa..c82864e71 100644 --- a/devolutions-agent/src/updater/mod.rs +++ b/devolutions-agent/src/updater/mod.rs @@ -8,7 +8,16 @@ mod product_actions; mod productinfo; mod security; +/// Schedule a file for deletion on the next system reboot (best-effort). +/// +/// Wraps the internal reboot-removal logic with an [`anyhow::Error`] return type for use +/// outside this crate. +pub fn remove_file_on_reboot(file_path: &Utf8Path) -> anyhow::Result<()> { + io::remove_file_on_reboot(file_path).map_err(anyhow::Error::from) +} + use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; use std::time::{Duration, Instant}; use time::Time; @@ -22,6 +31,7 @@ use devolutions_gateway_task::{ShutdownSignal, Task}; use notify_debouncer_mini::notify::RecursiveMode; use tokio::fs; use uuid::Uuid; +use win_api_wrappers::service::{ServiceManager, ServiceStartupMode}; use self::detect::get_product_code; pub(crate) use self::error::UpdaterError; @@ -35,6 +45,45 @@ use self::security::set_file_dacl; use crate::config::ConfHandle; use crate::updater::productinfo::ProductInfoDb; +/// Windows service name for Devolutions Agent. +pub const AGENT_SERVICE_NAME: &str = "DevolutionsAgent"; + +/// Service state captured before the MSI update begins, used to restore state afterwards. +pub struct AgentServiceState { + pub was_running: bool, + pub startup_was_automatic: bool, +} + +/// Query the Devolutions Agent service state before the MSI update begins. +/// +/// Called while the agent service is still running so startup mode and running state +/// reflect the pre-update configuration. +pub fn query_agent_service_state() -> anyhow::Result { + let sm = ServiceManager::open_read()?; + let svc = sm.open_service_read(AGENT_SERVICE_NAME)?; + Ok(AgentServiceState { + startup_was_automatic: svc.startup_mode()? == ServiceStartupMode::Automatic, + was_running: svc.is_running()?, + }) +} + +/// Start the Devolutions Agent service after a successful update if its startup mode is manual. +/// +/// Services configured for automatic startup are restarted by the Windows SCM after the MSI +/// completes. Services with manual startup must be started explicitly. +/// +/// Returns `true` if the service was started, `false` if a start was not needed. +pub fn start_agent_service_if_needed(state: &AgentServiceState) -> anyhow::Result { + // Automatic-startup services restart themselves via the SCM; no action needed. + if state.startup_was_automatic || !state.was_running { + return Ok(false); + } + let sm = ServiceManager::open_all_access()?; + let svc = sm.open_service_all_access(AGENT_SERVICE_NAME)?; + svc.start()?; + Ok(true) +} + const UPDATE_JSON_WATCH_INTERVAL: Duration = Duration::from_secs(3); /// How often the task checks whether an auto-update should be triggered. const POLL_INTERVAL: Duration = Duration::from_secs(60); @@ -83,10 +132,11 @@ fn validate_download_url(ctx: &UpdaterCtx, url: &str) -> Result<(), UpdaterError } /// Context for updater task -struct UpdaterCtx { +pub(crate) struct UpdaterCtx { product: Product, actions: Box, conf: ConfHandle, + shutdown_signal: ShutdownSignal, /// For agent self-update downgrades: the product code of the currently installed version /// to be uninstalled by the shim before installing the target version. downgrade_product_code: Option, @@ -104,6 +154,13 @@ struct UpdateOrder { hash: Option, } +/// Set to `true` while the agent self-update shim is running. +/// +/// Used as a lightweight guard to prevent overlapping agent updates and to block any +/// other product update from starting while the agent MSI is being installed (the MSI +/// may restart dependent services). +static AGENT_UPDATE_IN_PROGRESS: AtomicBool = AtomicBool::new(false); + pub struct UpdaterTask { conf_handle: ConfHandle, } @@ -194,7 +251,7 @@ impl Task for UpdaterTask { match check_for_updates(Product::Agent, &synthetic, &conf).await { Ok(Some(order)) => { - if let Err(error) = update_product(conf.clone(), Product::Agent, order).await { + if let Err(error) = update_product(conf.clone(), Product::Agent, order, shutdown_signal.clone()).await { error!(error = format!("{error:#}"), "Agent auto-update: failed to update agent"); } } @@ -241,7 +298,7 @@ impl Task for UpdaterTask { } for (product, order) in update_orders { - if let Err(error) = update_product(conf.clone(), product, order).await { + if let Err(error) = update_product(conf.clone(), product, order, shutdown_signal.clone()).await { error!(%product, %error, "Failed to update product"); } } @@ -256,7 +313,13 @@ impl Task for UpdaterTask { } } -async fn update_product(conf: ConfHandle, product: Product, order: UpdateOrder) -> anyhow::Result<()> { +async fn update_product(conf: ConfHandle, product: Product, order: UpdateOrder, shutdown_signal: ShutdownSignal) -> anyhow::Result<()> { + // Block any product update while the agent shim is running in the background. + // The agent MSI restarts dependent services and must complete uninterrupted. + if AGENT_UPDATE_IN_PROGRESS.load(Ordering::Acquire) { + anyhow::bail!("skipping {product} update: agent update is in progress"); + } + let target_version = order.target_version; let hash = order.hash; @@ -264,6 +327,7 @@ async fn update_product(conf: ConfHandle, product: Product, order: UpdateOrder) product, actions: build_product_actions(product), conf, + shutdown_signal, downgrade_product_code: order.downgrade.as_ref().and_then(|d| { // For Agent, the shim handles uninstall + install in sequence; pass the product // code so it can run `msiexec /x` before `msiexec /i`. diff --git a/devolutions-agent/src/updater/package.rs b/devolutions-agent/src/updater/package.rs index 8d8db68ae..8497ec8fe 100644 --- a/devolutions-agent/src/updater/package.rs +++ b/devolutions-agent/src/updater/package.rs @@ -7,7 +7,7 @@ use uuid::Uuid; use win_api_wrappers::utils::WideString; use crate::updater::io::remove_file_on_reboot; -use crate::updater::{Product, UpdaterCtx, UpdaterError}; +use crate::updater::{AGENT_UPDATE_IN_PROGRESS, Product, UpdaterCtx, UpdaterError}; /// List of allowed thumbprints for Devolutions code signing certificates const DEVOLUTIONS_CERT_THUMBPRINTS: &[&str] = &[ @@ -17,7 +17,7 @@ const DEVOLUTIONS_CERT_THUMBPRINTS: &[&str] = &[ ]; /// Filename of the updater shim executable installed alongside the agent. -const AGENT_UPDATER_SHIM_NAME: &str = "devolutions-agent-updater.exe"; +const AGENT_UPDATER_SHIM_NAME: &str = "DevolutionsAgentUpdater.exe"; pub(crate) async fn install_package( ctx: &UpdaterCtx, @@ -26,7 +26,7 @@ pub(crate) async fn install_package( ) -> Result<(), UpdaterError> { match ctx.product { Product::Gateway | Product::HubService => install_msi(ctx, path, log_path).await, - Product::Agent => install_agent_via_shim(path, ctx.downgrade_product_code).await, + Product::Agent => install_agent_via_shim(ctx, path).await, } } @@ -53,7 +53,7 @@ pub(crate) async fn uninstall_package( /// /// When `downgrade_product_code` is `Some` the shim will first run `msiexec /x` to uninstall /// the currently installed version before running `msiexec /i` for the target version. -async fn install_agent_via_shim(msi_path: &Utf8Path, downgrade_product_code: Option) -> Result<(), UpdaterError> { +async fn install_agent_via_shim(ctx: &UpdaterCtx, msi_path: &Utf8Path) -> Result<(), UpdaterError> { let shim_path = find_agent_updater_shim()?; // Copy the shim to a temp location so it survives the MSI replacing the installation dir. @@ -65,9 +65,14 @@ async fn install_agent_via_shim(msi_path: &Utf8Path, downgrade_product_code: Opt error!(%error, "Failed to schedule temp shim for deletion on reboot"); } - launch_updater_shim_detached(&temp_shim_path, msi_path, downgrade_product_code)?; + launch_updater_shim_detached( + ctx, + &temp_shim_path, + msi_path, + ctx.downgrade_product_code, + ).await?; - if downgrade_product_code.is_some() { + if ctx.downgrade_product_code.is_some() { info!("Agent updater shim launched; agent will be uninstalled then reinstalled at the target version"); } else { info!("Agent updater shim launched; agent service will be updated and restarted shortly"); @@ -110,37 +115,100 @@ async fn copy_shim_to_temp(shim_path: &Utf8Path) -> Result` /// (before the MSI path) so it can uninstall the old version before installing the new one. -fn launch_updater_shim_detached( +async fn launch_updater_shim_detached( + ctx: &UpdaterCtx, shim_path: &Utf8Path, msi_path: &Utf8Path, downgrade_product_code: Option, ) -> Result<(), UpdaterError> { - use std::os::windows::process::CommandExt as _; + use std::sync::atomic::Ordering; // Flags reference: https://learn.microsoft.com/en-us/windows/win32/procthread/process-creation-flags const DETACHED_PROCESS: u32 = 0x0000_0008; const CREATE_NEW_PROCESS_GROUP: u32 = 0x0000_0200; + const CREATE_BREAKAWAY_FROM_JOB: u32 = 0x0100_0000; + const SHIM_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10 * 60); + + // Reject concurrent agent updates. + if AGENT_UPDATE_IN_PROGRESS + .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) + .is_err() + { + return Err(UpdaterError::AgentUpdateAlreadyInProgress); + } - let mut cmd = std::process::Command::new(shim_path.as_str()); + let shim_log_path = shim_path.with_extension("shim.log"); + + let mut cmd = tokio::process::Command::new(shim_path.as_str()); if let Some(code) = downgrade_product_code { cmd.args(["-x", &code.braced().to_string()]); } cmd.arg(msi_path.as_str()); - cmd.stdin(std::process::Stdio::null()) + let mut child = cmd + .stdin(std::process::Stdio::null()) .stdout(std::process::Stdio::null()) .stderr(std::process::Stdio::null()) - .creation_flags(DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP) + .creation_flags(DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP | CREATE_BREAKAWAY_FROM_JOB) .spawn() .map_err(|source| UpdaterError::AgentShimLaunch { source })?; + info!(%shim_log_path, "Waiting for agent updater shim to complete (or service shutdown)"); + + let mut shutdown = ctx.shutdown_signal.clone(); + + tokio::select! { + result = child.wait() => { + // The shim exited before the agent service was stopped by the MSI. + // This is unexpected: the MSI should stop the service (killing us) before the + // shim finishes. Treat any exit — successful or not — as a failure. + let code = result.ok().and_then(|s| s.code()).unwrap_or(-1); + AGENT_UPDATE_IN_PROGRESS.store(false, Ordering::Release); + error!( + %shim_log_path, + exit_code = code, + "Agent updater shim exited unexpectedly before the service was restarted; \ + the update may not have completed. Check the shim log for details.", + ); + } + _ = tokio::time::sleep(SHIM_TIMEOUT) => { + // Shim has been running for too long; something is wrong. + AGENT_UPDATE_IN_PROGRESS.store(false, Ordering::Release); + error!( + %shim_log_path, + timeout_secs = SHIM_TIMEOUT.as_secs(), + "Agent updater shim timed out; the update may not have completed. \ + Check the shim log for details.", + ); + } + _ = shutdown.wait() => { + // The service is being stopped — most likely by the MSI installer as part of the + // update process. Assume the update is proceeding correctly and exit cleanly. + // AGENT_UPDATE_IN_PROGRESS is intentionally left `true`; the next agent instance + // starts fresh and resets it via the static initialiser. + info!("Shutdown signal received while waiting for updater shim; assuming MSI update is in progress"); + } + } + Ok(()) } diff --git a/package/AgentWindowsManaged/Program.cs b/package/AgentWindowsManaged/Program.cs index 815fadf8d..47338d33a 100644 --- a/package/AgentWindowsManaged/Program.cs +++ b/package/AgentWindowsManaged/Program.cs @@ -286,6 +286,13 @@ static void Main() }, }, new (Features.SESSION_FEATURE, DevolutionsSession) + { + TargetFileName = "DevolutionsSession.exe" + }, + new (Features.AGENT_UPDATER_FEATURE, DevolutionsAgentUpdaterExePath) + { + TargetFileName = "DevolutionsAgentUpdater.exe" + } }, Dirs = new[] { @@ -295,9 +302,7 @@ static void Main() new File(Features.PEDM_FEATURE, DevolutionsPedmShellExtMsix)), new Dir(Features.AGENT_FEATURE, "tun2socks", new File(Features.AGENT_FEATURE, DevolutionsTun2SocksExe), - new File(Features.AGENT_FEATURE, DevolutionsWintunDll)), - new Dir(Features.AGENT_UPDATER_FEATURE, "updater", - new File(Features.AGENT_UPDATER_FEATURE, DevolutionsAgentUpdaterExePath)) + new File(Features.AGENT_FEATURE, DevolutionsWintunDll)) } })), }; From ec96a1311d0cd06e511ed5c8bce2369e5173047c Mon Sep 17 00:00:00 2001 From: Vladyslav Nikonov Date: Sat, 28 Mar 2026 01:05:43 +0200 Subject: [PATCH 3/3] Updater improvements --- crates/devolutions-agent-shared/src/lib.rs | 2 +- .../src/update_json.rs | 270 +++++++++++++++++- devolutions-agent/src/updater/mod.rs | 43 ++- devolutions-agent/src/updater/product.rs | 11 +- devolutions-gateway/src/api/mod.rs | 1 - devolutions-gateway/src/api/update.rs | 226 +++++++++++++-- devolutions-gateway/src/api/update_agent.rs | 74 +---- devolutions-gateway/src/openapi.rs | 3 + 8 files changed, 492 insertions(+), 138 deletions(-) diff --git a/crates/devolutions-agent-shared/src/lib.rs b/crates/devolutions-agent-shared/src/lib.rs index 00a8b1fab..288e9241d 100644 --- a/crates/devolutions-agent-shared/src/lib.rs +++ b/crates/devolutions-agent-shared/src/lib.rs @@ -18,7 +18,7 @@ pub use agent_auto_update::AgentAutoUpdateConf; #[rustfmt::skip] pub use date_version::{DateVersion, DateVersionError}; #[rustfmt::skip] -pub use update_json::{ProductUpdateInfo, UpdateJson, VersionSpecification}; +pub use update_json::{ProductUpdateInfo, UPDATE_MANIFEST_V2_MINOR_VERSION, UpdateJson, UpdateManifest, UpdateManifestV2, UpdateProductKey, VersionedManifest, VersionSpecification}; cfg_if! { if #[cfg(target_os = "windows")] { diff --git a/crates/devolutions-agent-shared/src/update_json.rs b/crates/devolutions-agent-shared/src/update_json.rs index c27164873..462cdc030 100644 --- a/crates/devolutions-agent-shared/src/update_json.rs +++ b/crates/devolutions-agent-shared/src/update_json.rs @@ -1,23 +1,21 @@ +use std::collections::HashMap; use std::fmt; +use serde::{Deserialize, Deserializer, Serialize, Serializer}; + use crate::DateVersion; -/// Example JSON structure: +// ── V1 ── (keep for backward-compat; written by gateways <= 2026.1.0) ─────── + +/// Example V1 JSON structure: /// /// ```json /// { -/// "Gateway": { -/// "TargetVersion": "1.2.3.4" -/// }, -/// "HubService": { -/// "TargetVersion": "latest" -/// }, -/// "Agent": { -/// "TargetVersion": "latest" -/// } +/// "Gateway": { "TargetVersion": "1.2.3.4" }, +/// "HubService": { "TargetVersion": "latest" }, +/// "Agent": { "TargetVersion": "latest" } /// } /// ``` -/// #[derive(Debug, Default, Deserialize, Serialize)] #[serde(rename_all = "PascalCase")] pub struct UpdateJson { @@ -29,6 +27,8 @@ pub struct UpdateJson { pub agent: Option, } +// ── Shared value types ──────────────────────────────────────────────────────── + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub enum VersionSpecification { @@ -46,6 +46,18 @@ impl fmt::Display for VersionSpecification { } } +impl std::str::FromStr for VersionSpecification { + type Err = crate::DateVersionError; + + fn from_str(s: &str) -> Result { + if s.eq_ignore_ascii_case("latest") { + Ok(Self::Latest) + } else { + Ok(Self::Specific(s.parse()?)) + } + } +} + #[derive(Debug, Clone, Deserialize, Serialize)] #[serde(rename_all = "PascalCase")] pub struct ProductUpdateInfo { @@ -53,6 +65,185 @@ pub struct ProductUpdateInfo { pub target_version: VersionSpecification, } +// ── V2 ── (new agents init update.json with `{"VersionMajor": "2"}`) ───────── + +/// Minor version of the V2 manifest format written by this build of the agent. +/// +/// The minor version tracks feature-set additions within V2 (major version): +/// a new updatable product or capability increments this value so the gateway +/// can detect what the agent supports and reject requests for unsupported features. +pub const UPDATE_MANIFEST_V2_MINOR_VERSION: u32 = 0; + +/// V2 manifest content: minor version plus a map of product names to update info. +/// +/// `VersionMajor` is handled by the parent [`VersionedManifest`] tag and is absent here. +/// +/// Example (full V2 file): +/// ```json +/// { +/// "VersionMajor": "2", +/// "VersionMinor": 0, +/// "Products": { +/// "Gateway": { "TargetVersion": "2026.1.0" }, +/// "Agent": { "TargetVersion": "latest" } +/// } +/// } +/// ``` +#[derive(Debug, Deserialize, Serialize)] +#[serde(rename_all = "PascalCase")] +pub struct UpdateManifestV2 { + /// Feature-set version within V2. Defaults to `0` when the field is absent in the file. + #[serde(default)] + pub version_minor: u32, + /// Map of product name → update info. Empty when the file is a bare V2 stub. + #[serde(default, skip_serializing_if = "HashMap::is_empty")] + pub products: HashMap, +} + +impl Default for UpdateManifestV2 { + fn default() -> Self { + Self { + version_minor: UPDATE_MANIFEST_V2_MINOR_VERSION, + products: HashMap::new(), + } + } +} + +/// Internally-tagged versioned manifest (tag field: `"VersionMajor"`). +/// +/// New agents initialise `update.json` with `{"VersionMajor": "2", "VersionMinor": 0}` to +/// signal that they can consume V2 format. The gateway reads this before writing to decide +/// which format to use. +#[derive(Debug, Deserialize, Serialize)] +#[serde(tag = "VersionMajor")] +pub enum VersionedManifest { + /// V2 manifest — matched when the file contains `"VersionMajor": "2"`. + #[serde(rename = "2")] + V2(UpdateManifestV2), +} + +// ── Unified manifest ───────────────────────────────────────────────────────── + +/// A parsed update manifest: either a legacy V1 file or a versioned V2+ file. +/// +/// New agents initialise `update.json` with `{"VersionMajor": "2", "VersionMinor": 0}`; +/// old agents write `{}`. The gateway reads the existing file before writing to determine +/// which format to use. +/// +/// Serde variant order is significant: `Manifest` is tried first because `Legacy` +/// (using `#[serde(flatten)]`) would otherwise greedily match any object including V2. +#[derive(Debug, Deserialize, Serialize)] +#[serde(untagged)] +pub enum UpdateManifest { + /// V2+ format: has a `"Version"` field. + Manifest(VersionedManifest), + /// Legacy V1 format: no `"Version"` field. + Legacy(UpdateJson), +} + +impl UpdateManifest { + /// Parse `update.json` bytes, automatically detecting the format. + /// + /// Strips a UTF-8 BOM if present before parsing. + pub fn parse(data: &[u8]) -> serde_json::Result { + // Strip UTF-8 BOM if present (some editors add it). + let data = if data.starts_with(&[0xEF, 0xBB, 0xBF]) { &data[3..] } else { data }; + serde_json::from_slice(data) + } + + /// Normalise the manifest into a flat product map for uniform processing. + /// + /// - V2 `products` is used directly. + /// - V1 named fields are mapped to their [`UpdateProductKey`] equivalents. + /// - V1 `other` entries are best-effort converted; entries that do not match + /// [`ProductUpdateInfo`]'s schema are silently dropped. + pub fn into_products(self) -> HashMap { + match self { + Self::Manifest(VersionedManifest::V2(v2)) => v2.products, + Self::Legacy(v1) => { + let mut map = HashMap::new(); + if let Some(gw) = v1.gateway { + map.insert(UpdateProductKey::Gateway, gw); + } + if let Some(hs) = v1.hub_service { + map.insert(UpdateProductKey::HubService, hs); + } + if let Some(ag) = v1.agent { + map.insert(UpdateProductKey::Agent, ag); + } + map + } + } + } +} + +// ── V2 ── (new agents init update.json with `{"VersionMajor": "2"}`) ───────── + +/// Product key used in the V2 update manifest `Products` map. +/// +/// Known variants correspond to products this version of the agent understands. +/// `Other` captures any product name that is not yet known and preserves it so +/// that a future agent version can act on it. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum UpdateProductKey { + Gateway, + HubService, + Agent, + /// Any product name not recognised by this version of the agent. + Other(String), +} + +impl UpdateProductKey { + pub fn as_str(&self) -> &str { + match self { + Self::Gateway => "Gateway", + Self::HubService => "HubService", + Self::Agent => "Agent", + Self::Other(s) => s.as_str(), + } + } +} + +impl fmt::Display for UpdateProductKey { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +impl Serialize for UpdateProductKey { + fn serialize(&self, s: S) -> Result { + s.serialize_str(self.as_str()) + } +} + +impl<'de> Deserialize<'de> for UpdateProductKey { + fn deserialize>(d: D) -> Result { + struct KeyVisitor; + + impl serde::de::Visitor<'_> for KeyVisitor { + type Value = UpdateProductKey; + + fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "a product name string") + } + + fn visit_str(self, v: &str) -> Result { + Ok(match v { + "Gateway" => UpdateProductKey::Gateway, + "HubService" => UpdateProductKey::HubService, + "Agent" => UpdateProductKey::Agent, + other => UpdateProductKey::Other(other.to_owned()), + }) + } + } + + d.deserialize_str(KeyVisitor) + } +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + + #[cfg(test)] mod tests { #![allow(clippy::unwrap_used, reason = "test code can panic on errors")] @@ -77,4 +268,61 @@ mod tests { assert_eq!(reserialized, format!("\"{serialized}\"")); } } + + #[test] + fn empty_v1_parses_as_legacy() { + let manifest = UpdateManifest::parse(b"{}").unwrap(); + assert!(matches!(manifest, UpdateManifest::Legacy(_))); + assert!(manifest.into_products().is_empty()); + } + + #[test] + fn empty_v2_stub_parses_as_manifest() { + let manifest = UpdateManifest::parse(br#"{"VersionMajor":"2"}"#).unwrap(); + assert!(matches!(manifest, UpdateManifest::Manifest(VersionedManifest::V2(_)))); + assert!(manifest.into_products().is_empty()); + } + + #[test] + fn v2_with_products_roundtrip() { + let json = r#"{"VersionMajor":"2","VersionMinor":0,"Products":{"Agent":{"TargetVersion":"latest"},"Gateway":{"TargetVersion":"2026.1.0"}}}"#; + let manifest = UpdateManifest::parse(json.as_bytes()).unwrap(); + let products = manifest.into_products(); + assert_eq!(products.len(), 2); + assert!(matches!( + products.get(&UpdateProductKey::Agent).unwrap().target_version, + VersionSpecification::Latest + )); + } + + #[test] + fn v1_with_products_into_products() { + let json = r#"{"Gateway":{"TargetVersion":"2026.1.0"},"Agent":{"TargetVersion":"latest"}}"#; + let manifest = UpdateManifest::parse(json.as_bytes()).unwrap(); + assert!(matches!(manifest, UpdateManifest::Legacy(_))); + let products = manifest.into_products(); + assert_eq!(products.len(), 2); + assert!(matches!( + products.get(&UpdateProductKey::Gateway).unwrap().target_version, + VersionSpecification::Specific(_) + )); + } + + #[test] + fn bom_is_stripped() { + // UTF-8 BOM prefix + let mut data = vec![0xEF, 0xBB, 0xBF]; + data.extend_from_slice(b"{}"); + let manifest = UpdateManifest::parse(&data).unwrap(); + assert!(matches!(manifest, UpdateManifest::Legacy(_))); + } + + #[test] + fn v2_stub_serialise_roundtrip() { + let stub = UpdateManifest::Manifest(VersionedManifest::V2(UpdateManifestV2::default())); + let serialized = serde_json::to_string(&stub).unwrap(); + assert_eq!(serialized, r#"{"VersionMajor":"2","VersionMinor":0}"#); + let back = UpdateManifest::parse(serialized.as_bytes()).unwrap(); + assert!(matches!(back, UpdateManifest::Manifest(VersionedManifest::V2(_)))); + } } diff --git a/devolutions-agent/src/updater/mod.rs b/devolutions-agent/src/updater/mod.rs index c82864e71..5607439a2 100644 --- a/devolutions-agent/src/updater/mod.rs +++ b/devolutions-agent/src/updater/mod.rs @@ -26,7 +26,9 @@ use time::macros::format_description; use anyhow::{Context, anyhow}; use async_trait::async_trait; use camino::{Utf8Path, Utf8PathBuf}; -use devolutions_agent_shared::{DateVersion, ProductUpdateInfo, UpdateJson, VersionSpecification, get_updater_file_path}; +use std::collections::HashMap; + +use devolutions_agent_shared::{DateVersion, ProductUpdateInfo, UpdateManifest, UpdateManifestV2, UpdateProductKey, VersionSpecification, VersionedManifest, get_updater_file_path}; use devolutions_gateway_task::{ShutdownSignal, Task}; use notify_debouncer_mini::notify::RecursiveMode; use tokio::fs; @@ -243,11 +245,11 @@ impl Task for UpdaterTask { info!("Agent auto-update: maintenance window active, checking for new version"); last_auto_update_trigger = Some(Instant::now()); - let synthetic = UpdateJson { - agent: Some(ProductUpdateInfo { target_version: VersionSpecification::Latest }), - gateway: None, - hub_service: None, - }; + let mut synthetic: HashMap = HashMap::new(); + synthetic.insert( + UpdateProductKey::Agent, + ProductUpdateInfo { target_version: VersionSpecification::Latest }, + ); match check_for_updates(Product::Agent, &synthetic, &conf).await { Ok(Some(order)) => { @@ -267,8 +269,8 @@ impl Task for UpdaterTask { info!("update.json file changed, checking for updates..."); - let update_json = match read_update_json(&update_file_path).await { - Ok(update_json) => update_json, + let products_map = match read_update_json(&update_file_path).await { + Ok(products_map) => products_map, Err(error) => { error!(%error, "Failed to parse `update.json`"); // Allow this error to be non-critical, as this file could be @@ -280,7 +282,7 @@ impl Task for UpdaterTask { let mut update_orders = vec![]; for product in PRODUCTS { - let update_order = match check_for_updates(*product, &update_json, &conf).await { + let update_order = match check_for_updates(*product, &products_map, &conf).await { Ok(order) => order, Err(error) => { error!(%product, error = format!("{error:#}"), "Failed to check for updates for a product"); @@ -390,30 +392,22 @@ async fn update_product(conf: ConfHandle, product: Product, order: UpdateOrder, Ok(()) } -async fn read_update_json(update_file_path: &Utf8Path) -> anyhow::Result { - let update_json_data = fs::read(update_file_path) +async fn read_update_json(update_file_path: &Utf8Path) -> anyhow::Result> { + let data = fs::read(update_file_path) .await .context("failed to read update.json file")?; - // Strip UTF-8 BOM if present (some editors add it) - let data_without_bom = if update_json_data.starts_with(&[0xEF, 0xBB, 0xBF]) { - &update_json_data[3..] - } else { - &update_json_data - }; - - let update_json: UpdateJson = - serde_json::from_slice(data_without_bom).context("failed to parse update.json file")?; + let manifest = UpdateManifest::parse(&data).context("failed to parse update.json file")?; - Ok(update_json) + Ok(manifest.into_products()) } async fn check_for_updates( product: Product, - update_json: &UpdateJson, + products: &HashMap, conf: &ConfHandle, ) -> anyhow::Result> { - let target_version = match product.get_update_info(update_json).map(|info| info.target_version) { + let target_version = match products.get(&product.as_update_product_key()).map(|info| info.target_version.clone()) { Some(version) => version, None => { trace!(%product, "No target version specified in update.json, skipping update check"); @@ -592,8 +586,9 @@ async fn check_for_updates( async fn init_update_json() -> anyhow::Result { let update_file_path = get_updater_file_path(); + let empty_v2 = UpdateManifest::Manifest(VersionedManifest::V2(UpdateManifestV2::default())); let default_update_json = - serde_json::to_string_pretty(&UpdateJson::default()).context("failed to serialize default update.json")?; + serde_json::to_string_pretty(&empty_v2).context("failed to serialize default update.json")?; fs::write(&update_file_path, default_update_json) .await diff --git a/devolutions-agent/src/updater/product.rs b/devolutions-agent/src/updater/product.rs index d97390f0c..ed0874e64 100644 --- a/devolutions-agent/src/updater/product.rs +++ b/devolutions-agent/src/updater/product.rs @@ -1,7 +1,7 @@ use std::fmt; use std::str::FromStr; -use devolutions_agent_shared::{ProductUpdateInfo, UpdateJson}; +use devolutions_agent_shared::UpdateProductKey; use crate::updater::productinfo::{AGENT_PRODUCT_ID, GATEWAY_PRODUCT_ID, HUB_SERVICE_PRODUCT_ID}; @@ -40,11 +40,12 @@ impl FromStr for Product { } impl Product { - pub(crate) fn get_update_info(self, update_json: &UpdateJson) -> Option { + /// Convert to the corresponding [`UpdateProductKey`] for looking up update info in a products map. + pub(crate) fn as_update_product_key(self) -> UpdateProductKey { match self { - Product::Gateway => update_json.gateway.clone(), - Product::HubService => update_json.hub_service.clone(), - Product::Agent => update_json.agent.clone(), + Product::Gateway => UpdateProductKey::Gateway, + Product::HubService => UpdateProductKey::HubService, + Product::Agent => UpdateProductKey::Agent, } } diff --git a/devolutions-gateway/src/api/mod.rs b/devolutions-gateway/src/api/mod.rs index 8b0d61372..b072bd34c 100644 --- a/devolutions-gateway/src/api/mod.rs +++ b/devolutions-gateway/src/api/mod.rs @@ -37,7 +37,6 @@ pub fn make_router(state: crate::DgwState) -> axum::Router { .nest("/jet/net", net::make_router(state.clone())) .nest("/jet/traffic", traffic::make_router(state.clone())) .route("/jet/update", axum::routing::post(update::trigger_update_check)) - .route("/jet/agent-update", axum::routing::post(update_agent::trigger_agent_update)) .route( "/jet/agent-update-config", axum::routing::get(update_agent::get_agent_auto_update) diff --git a/devolutions-gateway/src/api/update.rs b/devolutions-gateway/src/api/update.rs index b2d7fe00a..025f3e6bd 100644 --- a/devolutions-gateway/src/api/update.rs +++ b/devolutions-gateway/src/api/update.rs @@ -1,49 +1,235 @@ +use std::collections::HashMap; + use axum::Json; -use axum::extract::Query; -use devolutions_agent_shared::{ProductUpdateInfo, UpdateJson, VersionSpecification, get_updater_file_path}; +use axum::body::Bytes; +use devolutions_agent_shared::{ + ProductUpdateInfo, UpdateJson, UpdateManifest, UpdateManifestV2, UpdateProductKey, + VersionSpecification, VersionedManifest, get_updater_file_path, +}; use hyper::StatusCode; use crate::extract::UpdateScope; use crate::http::{HttpError, HttpErrorBuilder}; -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -pub(crate) struct UpdateQueryParam { - version: VersionSpecification, +// ── OpenAPI request / response types ───────────────────────────────────────── + +/// Version specification string: `"latest"` or a specific version like `"2026.1.0"`. +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[derive(Debug, Deserialize)] +#[serde(rename_all = "PascalCase")] +pub(crate) struct UpdateProductRequest { + /// Target version: `"latest"` or `"YYYY.M.D"` / `"YYYY.M.D.R"`. + pub version: VersionSpecification, +} + +/// Known product names accepted by the update endpoint. +/// +/// `Other` captures any product name not yet known to this gateway version; +/// it is forwarded to the agent unchanged so future agents can act on it. +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub(crate) enum UpdateProduct { + Gateway, + HubService, + Agent, + /// A product name not recognised by this gateway version. + Other(String), +} + +impl<'de> serde::Deserialize<'de> for UpdateProduct { + fn deserialize>(d: D) -> Result { + struct V; + impl serde::de::Visitor<'_> for V { + type Value = UpdateProduct; + fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "a product name string") + } + fn visit_str(self, s: &str) -> Result { + Ok(match s { + "Gateway" => UpdateProduct::Gateway, + "HubService" => UpdateProduct::HubService, + "Agent" => UpdateProduct::Agent, + other => UpdateProduct::Other(other.to_owned()), + }) + } + } + d.deserialize_str(V) + } +} + +/// Request body for the unified update endpoint. +/// +/// Every key in `Products` is a product name. Known products (`Gateway`, `Agent`, +/// `HubService`) are processed natively; any other name is forwarded as-is to the +/// agent so future product types are supported transparently. +/// +/// # Example +/// +/// ```json +/// { +/// "Products": { +/// "Gateway": { "Version": "2026.1.0" }, +/// "Agent": { "Version": "latest" } +/// } +/// } +/// ``` +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[derive(Debug, Deserialize)] +#[serde(rename_all = "PascalCase")] +pub(crate) struct UpdateRequest { + /// Map of product name → version specification. + pub products: HashMap, } +/// Response returned by the update endpoint. #[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] #[derive(Serialize)] pub(crate) struct UpdateResponse {} -/// Triggers Devolutions Gateway update process. +// ── Conversion: API types → shared manifest types ───────────────────────────── + +impl From for UpdateProductKey { + fn from(p: UpdateProduct) -> Self { + match p { + UpdateProduct::Gateway => UpdateProductKey::Gateway, + UpdateProduct::HubService => UpdateProductKey::HubService, + UpdateProduct::Agent => UpdateProductKey::Agent, + UpdateProduct::Other(s) => UpdateProductKey::Other(s), + } + } +} + +/// Convert the API request to a V2 manifest. +fn into_v2(req: UpdateRequest) -> UpdateManifest { + let products = req + .products + .into_iter() + .map(|(k, v)| (UpdateProductKey::from(k), ProductUpdateInfo { target_version: v.version })) + .collect(); + UpdateManifest::Manifest(VersionedManifest::V2(UpdateManifestV2 { + products, + ..UpdateManifestV2::default() + })) +} + +/// Convert the API request to a V1 manifest (for agents that have not yet upgraded to V2). /// -/// This is done via updating `Agent/update.json` file, which is then read by Devolutions Agent -/// when changes are detected. If the version written to `update.json` is indeed higher than the -/// currently installed version, Devolutions Agent will proceed with the update process. +/// Known products go into named fields; unknown products are stored in +/// `other` as raw JSON (they survive the file round-trip to the agent). +fn into_v1(req: UpdateRequest) -> UpdateManifest { + let mut json = UpdateJson::default(); + for (product, info) in req.products { + let pi = ProductUpdateInfo { target_version: info.version }; + match product { + UpdateProduct::Gateway => json.gateway = Some(pi), + UpdateProduct::HubService => json.hub_service = Some(pi), + UpdateProduct::Agent => json.agent = Some(pi), + UpdateProduct::Other(_) => { + // V1 format does not support unknown products; silently dropped. + } + } + } + UpdateManifest::Legacy(json) +} + +/// Serialise the request into the on-disk format appropriate for the installed agent. +/// +/// The format is determined by reading the existing `update.json` file: +/// - If it contains `"Version": "2"` the agent supports V2 → write V2. +/// - Otherwise (file is absent, empty, or V1) → write V1. +fn serialise_manifest(req: UpdateRequest, path: &camino::Utf8Path) -> anyhow::Result { + let use_v2 = std::fs::read(path) + .ok() + .and_then(|data| UpdateManifest::parse(&data).ok()) + .is_some_and(|m| matches!(m, UpdateManifest::Manifest(_))); + + let manifest = if use_v2 { into_v2(req) } else { into_v1(req) }; + Ok(serde_json::to_string(&manifest)?) +} + +// ── Handler ─────────────────────────────────────────────────────────────────── + +/// Trigger an update for one or more Devolutions products. +/// +/// Writes the requested version(s) into `Agent/update.json`, which is watched by Devolutions +/// Agent. When a requested version is higher than the installed version the agent proceeds +/// with the update. +/// +/// **Body form** (preferred): pass a JSON body with a `Products` map. +/// +/// **Query-param form** (legacy, gateway-only): `POST /jet/update?version=latest`. +/// This form updates only the Gateway product and is kept for backward compatibility. +/// +/// Both forms cannot be used simultaneously; doing so returns HTTP 400. #[cfg_attr(feature = "openapi", utoipa::path( post, operation_id = "TriggerUpdate", tag = "Update", path = "/jet/update", params( - ("version" = String, Query, description = "The version to install; use 'latest' for the latest version, or 'w.x.y.z' for a specific version"), + ("version" = Option, Query, description = "[Legacy] Gateway-only target version; use the request body for multi-product updates"), ), + request_body(content = Option, description = "Products and target versions to update", content_type = "application/json"), responses( - (status = 200, description = "Update request has been processed successfully", body = UpdateResponse), + (status = 200, description = "Update request accepted", body = UpdateResponse), (status = 400, description = "Bad request"), (status = 401, description = "Invalid or missing authorization token"), (status = 403, description = "Insufficient permissions"), - (status = 500, description = "Agent updater service is malfunctioning"), + (status = 500, description = "Failed to write update manifest"), (status = 503, description = "Agent updater service is unavailable"), ), security(("scope_token" = ["gateway.update"])), ))] pub(super) async fn trigger_update_check( - Query(query): Query, + uri: axum::http::Uri, _scope: UpdateScope, + body: Bytes, ) -> Result, HttpError> { - let target_version = query.version; + // Extract optional legacy `?version=` query param (gateway-only path). + let query_version: Option = uri.query().and_then(|q| { + q.split('&').find_map(|kv| { + kv.split_once('=') + .filter(|(k, _)| *k == "version") + .map(|(_, v)| v.to_owned()) + }) + }); + + // Parse the optional JSON body. + let body_request: Option = if body.is_empty() { + None + } else { + Some(serde_json::from_slice(&body).map_err( + HttpError::bad_request() + .with_msg("invalid request body") + .err(), + )?) + }; + + // Both forms supplied simultaneously → 400. + if query_version.is_some() && body_request.is_some() { + return Err(HttpErrorBuilder::new(StatusCode::BAD_REQUEST) + .msg("cannot specify both query parameter and request body; use one or the other")); + } + + // Neither form supplied → 400. + if query_version.is_none() && body_request.is_none() { + return Err(HttpErrorBuilder::new(StatusCode::BAD_REQUEST).msg("no products specified")); + } + + // Convert whichever form was used into an UpdateRequest. + let request: UpdateRequest = if let Some(v) = query_version { + // Legacy form: gateway-only update. + let version: VersionSpecification = v.parse().map_err( + HttpError::bad_request() + .with_msg("invalid version in query parameter") + .err(), + )?; + let mut products = HashMap::new(); + products.insert(UpdateProduct::Gateway, UpdateProductRequest { version }); + UpdateRequest { products } + } else { + body_request.expect("verified non-None above") + }; let updater_file_path = get_updater_file_path(); @@ -53,19 +239,13 @@ pub(super) async fn trigger_update_check( ); } - let update_json = UpdateJson { - gateway: Some(ProductUpdateInfo { target_version }), - hub_service: None, - agent: None, - }; - - let update_json = serde_json::to_string(&update_json).map_err( + let serialized = serialise_manifest(request, &updater_file_path).map_err( HttpError::internal() .with_msg("failed to serialize the update manifest") .err(), )?; - std::fs::write(updater_file_path, update_json).map_err( + std::fs::write(&updater_file_path, serialized).map_err( HttpError::internal() .with_msg("failed to write the new `update.json` manifest on disk") .err(), diff --git a/devolutions-gateway/src/api/update_agent.rs b/devolutions-gateway/src/api/update_agent.rs index 795b9fa70..8ce845e50 100644 --- a/devolutions-gateway/src/api/update_agent.rs +++ b/devolutions-gateway/src/api/update_agent.rs @@ -1,15 +1,12 @@ use axum::Json; -use axum::extract::Query; -use devolutions_agent_shared::{ProductUpdateInfo, UpdateJson, VersionSpecification, get_updater_file_path}; use devolutions_agent_shared::AgentAutoUpdateConf; use devolutions_agent_shared::agent_auto_update::{ DEFAULT_INTERVAL, DEFAULT_WINDOW_END, DEFAULT_WINDOW_START, read_agent_auto_update_conf, write_agent_auto_update_conf, }; -use hyper::StatusCode; use crate::extract::UpdateScope; -use crate::http::{HttpError, HttpErrorBuilder}; +use crate::http::HttpError; #[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] #[derive(Serialize)] @@ -157,72 +154,3 @@ pub(super) async fn set_agent_auto_update( Ok(Json(SetAgentAutoUpdateResponse {})) } - -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -pub(crate) struct AgentUpdateQueryParam { - version: VersionSpecification, -} - -#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] -#[derive(Serialize)] -pub(crate) struct AgentUpdateResponse {} - -/// Triggers Devolutions Agent update process. -/// -/// Writes the requested version into the `Agent` field of `update.json`, which is then -/// picked up by Devolutions Agent when changes are detected. If the version written is -/// higher than the currently installed version, Devolutions Agent will proceed with the -/// update process via the agent-updater shim. -#[cfg_attr(feature = "openapi", utoipa::path( - post, - operation_id = "TriggerAgentUpdate", - tag = "Update", - path = "/jet/agent-update", - params( - ("version" = String, Query, description = "The version to install; use 'latest' for the latest version, or 'w.x.y.z' for a specific version"), - ), - responses( - (status = 200, description = "Agent update request has been processed successfully", body = AgentUpdateResponse), - (status = 400, description = "Bad request"), - (status = 401, description = "Invalid or missing authorization token"), - (status = 403, description = "Insufficient permissions"), - (status = 500, description = "Agent updater service is malfunctioning"), - (status = 503, description = "Agent updater service is unavailable"), - ), - security(("scope_token" = ["gateway.update"])), -))] -pub(super) async fn trigger_agent_update( - Query(query): Query, - _scope: UpdateScope, -) -> Result, HttpError> { - let target_version = query.version; - - let updater_file_path = get_updater_file_path(); - - if !updater_file_path.exists() { - return Err( - HttpErrorBuilder::new(StatusCode::SERVICE_UNAVAILABLE).msg("Agent updater service is not installed") - ); - } - - let update_json = UpdateJson { - agent: Some(ProductUpdateInfo { target_version }), - gateway: None, - hub_service: None, - }; - - let update_json = serde_json::to_string(&update_json).map_err( - HttpError::internal() - .with_msg("failed to serialize the update manifest") - .err(), - )?; - - std::fs::write(updater_file_path, update_json).map_err( - HttpError::internal() - .with_msg("failed to write the new `update.json` manifest on disk") - .err(), - )?; - - Ok(Json(AgentUpdateResponse {})) -} diff --git a/devolutions-gateway/src/openapi.rs b/devolutions-gateway/src/openapi.rs index fd46afd74..0286c3e04 100644 --- a/devolutions-gateway/src/openapi.rs +++ b/devolutions-gateway/src/openapi.rs @@ -51,6 +51,9 @@ use crate::config::dto::{DataEncoding, PubKeyFormat, Subscriber}; crate::token::AccessScope, crate::api::webapp::AppTokenSignRequest, crate::api::webapp::AppTokenContentType, + crate::api::update::UpdateRequest, + crate::api::update::UpdateProduct, + crate::api::update::UpdateProductRequest, crate::api::update::UpdateResponse, PreflightOperation, PreflightOperationKind,