diff --git a/Cargo.lock b/Cargo.lock index 345b4d72d..97bbd6907 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -586,7 +586,7 @@ dependencies = [ "js-sys", "num-traits", "wasm-bindgen", - "windows-targets 0.52.0", + "windows-targets 0.52.5", ] [[package]] @@ -905,21 +905,42 @@ name = "devolutions-agent" version = "2024.1.2" dependencies = [ "anyhow", + "async-trait", "camino", "ceviche", - "cfg-if", "ctrlc", + "devolutions-agent-shared", "devolutions-gateway-task", "devolutions-log", "embed-resource", "futures", + "hex", + "notify-debouncer-mini", "parking_lot", + "reqwest", "serde", "serde_derive", "serde_json", + "sha2", + "smallvec", "tap", + "thiserror", "tokio", "tracing", + "uuid", + "windows 0.57.0", + "winreg 0.52.0", +] + +[[package]] +name = "devolutions-agent-shared" +version = "0.0.0" +dependencies = [ + "camino", + "cfg-if", + "serde", + "serde_json", + "thiserror", ] [[package]] @@ -1237,6 +1258,18 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1676f435fc1dadde4d03e43f5d62b259e1ce5f40bd4ffb21db2b42ebe59c1382" +[[package]] +name = "filetime" +version = "0.2.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ee447700ac8aa0b2f2bd7bc4462ad686ba06baa6727ac149a2d6277f0d240fd" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "windows-sys 0.52.0", +] + [[package]] name = "flagset" version = "0.4.4" @@ -1294,6 +1327,15 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fsevent-sys" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2" +dependencies = [ + "libc", +] + [[package]] name = "funty" version = "2.0.0" @@ -1535,6 +1577,12 @@ version = "0.3.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bd5256b483761cd23699d0da46cc6fd2ee3be420bbe6d020ae4a091e70b7e9fd" +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + [[package]] name = "hex-literal" version = "0.4.1" @@ -1760,6 +1808,26 @@ dependencies = [ "serde", ] +[[package]] +name = "inotify" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8069d3ec154eb856955c1c0fbffefbf5f3c40a104ec912d4797314c1801abff" +dependencies = [ + "bitflags 1.3.2", + "inotify-sys", + "libc", +] + +[[package]] +name = "inotify-sys" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e05c02b5e89bff3b946cedeca278abc628fe811e604f027c45a8aa3cf793d0eb" +dependencies = [ + "libc", +] + [[package]] name = "inout" version = "0.1.3" @@ -1940,6 +2008,26 @@ dependencies = [ "cpufeatures", ] +[[package]] +name = "kqueue" +version = "1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7447f1ca1b7b563588a205fe93dea8df60fd981423a768bc1c0ded35ed147d0c" +dependencies = [ + "kqueue-sys", + "libc", +] + +[[package]] +name = "kqueue-sys" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed9625ffda8729b85e45cf04090035ac368927b8cebc34898e7c120f52e4838b" +dependencies = [ + "bitflags 1.3.2", + "libc", +] + [[package]] name = "lazy_static" version = "1.4.0" @@ -2107,6 +2195,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c" dependencies = [ "libc", + "log", "wasi", "windows-sys 0.48.0", ] @@ -2349,6 +2438,36 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "995defdca0a589acfdd1bd2e8e3b896b4d4f7675a31fd14c32611440c7f608e6" +[[package]] +name = "notify" +version = "6.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6205bd8bb1e454ad2e27422015fb5e4f2bcc7e08fa8f27058670d208324a4d2d" +dependencies = [ + "bitflags 2.4.2", + "crossbeam-channel", + "filetime", + "fsevent-sys", + "inotify", + "kqueue", + "libc", + "log", + "mio", + "walkdir", + "windows-sys 0.48.0", +] + +[[package]] +name = "notify-debouncer-mini" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d40b221972a1fc5ef4d858a2f671fb34c75983eb385463dff3780eeff6a9d43" +dependencies = [ + "crossbeam-channel", + "log", + "notify", +] + [[package]] name = "ntapi" version = "0.4.1" @@ -3545,6 +3664,15 @@ version = "1.0.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f98d2aa92eebf49b69786be48e4477826b256916e84a57ff2a4f21923b48eb4c" +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + [[package]] name = "schannel" version = "0.1.23" @@ -4699,6 +4827,16 @@ dependencies = [ "libc", ] +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + [[package]] name = "want" version = "0.3.1" @@ -4837,6 +4975,15 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" +[[package]] +name = "winapi-util" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d4cc384e1e73b93bafa6fb4f1df8c41695c8a91cf9c4c64358067d15a7b6c6b" +dependencies = [ + "windows-sys 0.52.0", +] + [[package]] name = "winapi-x86_64-pc-windows-gnu" version = "0.4.0" @@ -4869,7 +5016,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e48a53791691ab099e5e2ad123536d0fff50652600abaf43bbf952894110d0be" dependencies = [ "windows-core 0.52.0", - "windows-targets 0.52.0", + "windows-targets 0.52.5", +] + +[[package]] +name = "windows" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12342cb4d8e3b046f3d80effd474a7a02447231330ef77d71daa6fbc40681143" +dependencies = [ + "windows-core 0.57.0", + "windows-targets 0.52.5", ] [[package]] @@ -4887,7 +5044,50 @@ version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "33ab640c8d7e35bf8ba19b884ba838ceb4fba93a4e8c65a9059d08afcfc683d9" dependencies = [ - "windows-targets 0.52.0", + "windows-targets 0.52.5", +] + +[[package]] +name = "windows-core" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2ed2439a290666cd67ecce2b0ffaad89c2a56b976b736e6ece670297897832d" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-result", + "windows-targets 0.52.5", +] + +[[package]] +name = "windows-implement" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9107ddc059d5b6fbfbffdfa7a7fe3e22a226def0b2608f72e9d552763d3e1ad7" +dependencies = [ + "proc-macro2 1.0.78", + "quote 1.0.35", + "syn 2.0.49", +] + +[[package]] +name = "windows-interface" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29bee4b38ea3cde66011baa44dba677c432a78593e202392d1e9070cf2a7fca7" +dependencies = [ + "proc-macro2 1.0.78", + "quote 1.0.35", + "syn 2.0.49", +] + +[[package]] +name = "windows-result" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e383302e8ec8515204254685643de10811af0ed97ea37210dc26fb0032647f8" +dependencies = [ + "windows-targets 0.52.5", ] [[package]] @@ -4914,7 +5114,7 @@ version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" dependencies = [ - "windows-targets 0.52.0", + "windows-targets 0.52.5", ] [[package]] @@ -4949,17 +5149,18 @@ dependencies = [ [[package]] name = "windows-targets" -version = "0.52.0" +version = "0.52.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a18201040b24831fbb9e4eb208f8892e1f50a37feb53cc7ff887feb8f50e7cd" +checksum = "6f0713a46559409d202e70e28227288446bf7841d3211583a4b53e3f6d96e7eb" dependencies = [ - "windows_aarch64_gnullvm 0.52.0", - "windows_aarch64_msvc 0.52.0", - "windows_i686_gnu 0.52.0", - "windows_i686_msvc 0.52.0", - "windows_x86_64_gnu 0.52.0", - "windows_x86_64_gnullvm 0.52.0", - "windows_x86_64_msvc 0.52.0", + "windows_aarch64_gnullvm 0.52.5", + "windows_aarch64_msvc 0.52.5", + "windows_i686_gnu 0.52.5", + "windows_i686_gnullvm", + "windows_i686_msvc 0.52.5", + "windows_x86_64_gnu 0.52.5", + "windows_x86_64_gnullvm 0.52.5", + "windows_x86_64_msvc 0.52.5", ] [[package]] @@ -4976,9 +5177,9 @@ checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" [[package]] name = "windows_aarch64_gnullvm" -version = "0.52.0" +version = "0.52.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb7764e35d4db8a7921e09562a0304bf2f93e0a51bfccee0bd0bb0b666b015ea" +checksum = "7088eed71e8b8dda258ecc8bac5fb1153c5cffaf2578fc8ff5d61e23578d3263" [[package]] name = "windows_aarch64_msvc" @@ -4994,9 +5195,9 @@ checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" [[package]] name = "windows_aarch64_msvc" -version = "0.52.0" +version = "0.52.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbaa0368d4f1d2aaefc55b6fcfee13f41544ddf36801e793edbbfd7d7df075ef" +checksum = "9985fd1504e250c615ca5f281c3f7a6da76213ebd5ccc9561496568a2752afb6" [[package]] name = "windows_i686_gnu" @@ -5012,9 +5213,15 @@ checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" [[package]] name = "windows_i686_gnu" -version = "0.52.0" +version = "0.52.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88ba073cf16d5372720ec942a8ccbf61626074c6d4dd2e745299726ce8b89670" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a28637cb1fa3560a16915793afb20081aba2c92ee8af57b4d5f28e4b3e7df313" +checksum = "87f4261229030a858f36b459e748ae97545d6f1ec60e5e0d6a3d32e0dc232ee9" [[package]] name = "windows_i686_msvc" @@ -5030,9 +5237,9 @@ checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" [[package]] name = "windows_i686_msvc" -version = "0.52.0" +version = "0.52.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ffe5e8e31046ce6230cc7215707b816e339ff4d4d67c65dffa206fd0f7aa7b9a" +checksum = "db3c2bf3d13d5b658be73463284eaf12830ac9a26a90c717b7f771dfe97487bf" [[package]] name = "windows_x86_64_gnu" @@ -5048,9 +5255,9 @@ checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" [[package]] name = "windows_x86_64_gnu" -version = "0.52.0" +version = "0.52.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d6fa32db2bc4a2f5abeacf2b69f7992cd09dca97498da74a151a3132c26befd" +checksum = "4e4246f76bdeff09eb48875a0fd3e2af6aada79d409d33011886d3e1581517d9" [[package]] name = "windows_x86_64_gnullvm" @@ -5066,9 +5273,9 @@ checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" [[package]] name = "windows_x86_64_gnullvm" -version = "0.52.0" +version = "0.52.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a657e1e9d3f514745a572a6846d3c7aa7dbe1658c056ed9c3344c4109a6949e" +checksum = "852298e482cd67c356ddd9570386e2862b5673c85bd5f88df9ab6802b334c596" [[package]] name = "windows_x86_64_msvc" @@ -5084,9 +5291,9 @@ checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" [[package]] name = "windows_x86_64_msvc" -version = "0.52.0" +version = "0.52.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dff9641d1cd4be8d1a070daf9e3773c5f67e78b4d9d42263020c057706765c04" +checksum = "bec47e5bfd1bff0eeaf6d8b485cc1074891a197ab4225d504cb7a1ab88b02bf0" [[package]] name = "winnow" diff --git a/crates/devolutions-agent-shared/Cargo.toml b/crates/devolutions-agent-shared/Cargo.toml new file mode 100644 index 000000000..ed326a1f9 --- /dev/null +++ b/crates/devolutions-agent-shared/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "devolutions-agent-shared" +version = "0.0.0" +authors = ["Devolutions Inc. "] +description = "Shared code between the Devolutions Agent and the Devolutions Gateway" +edition = "2021" +publish = false + + +[dependencies] +camino = { version = "1.1" } +cfg-if = "1.0" +serde = { version = "1.0", features = ["derive"] } +thiserror = "1.0" + +[dev-dependencies] +serde_json = "1.0" \ No newline at end of file diff --git a/crates/devolutions-agent-shared/src/date_version.rs b/crates/devolutions-agent-shared/src/date_version.rs new file mode 100644 index 000000000..eadbd8e7f --- /dev/null +++ b/crates/devolutions-agent-shared/src/date_version.rs @@ -0,0 +1,110 @@ +use std::{fmt, str::FromStr}; + +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +#[derive(Debug, Error)] +#[error("invalid date (`YYYY.MM.DD.R`) version")] +pub struct DateVersionError; + +/// Parsed application version represented in the format `YYYY.MM.DD.R` +#[derive(Debug, Default, Eq, PartialEq, PartialOrd, Ord, Clone, Copy)] +pub struct DateVersion { + // NOTE: Field order is important for `PartialOrd` and `Ord` derives + pub year: u32, + pub month: u32, + pub day: u32, + pub revision: u32, +} + +impl Serialize for DateVersion { + fn serialize(&self, serializer: S) -> Result + where + S: serde::ser::Serializer, + { + self.to_string().serialize(serializer) + } +} + +impl<'de> Deserialize<'de> for DateVersion { + fn deserialize(deserializer: D) -> Result + where + D: serde::de::Deserializer<'de>, + { + let s = String::deserialize(deserializer)?; + DateVersion::from_str(&s).map_err(serde::de::Error::custom) + } +} + +impl FromStr for DateVersion { + type Err = DateVersionError; + + fn from_str(s: &str) -> Result { + let mut parts = s.splitn(4, '.'); + + let mut next_part = || parts.next().and_then(|s| u32::from_str(s).ok()).ok_or(DateVersionError); + + let year = next_part()?; + let month = next_part()?; + let day = next_part()?; + // Allow version without revision + let revision = next_part().unwrap_or(0); + + if parts.next().is_some() { + return Err(DateVersionError); + } + + Ok(DateVersion { + year, + month, + day, + revision, + }) + } +} + +impl fmt::Display for DateVersion { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}.{}.{}.{}", self.year, self.month, self.day, self.revision) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn date_version_rountrip() { + let version = DateVersion { + year: 2022, + month: 10, + day: 1, + revision: 2, + }; + + let version_str = version.to_string(); + assert_eq!(version_str, "2022.10.1.2"); + let parsed_version = DateVersion::from_str(&version_str).unwrap(); + + assert_eq!(version, parsed_version); + } + + // Regression test in case field order gets changed + #[test] + fn date_version_ordering() { + const VERSIONS_ASCENDING_PAIRS: &[(&'static str, &'static str)] = &[ + // cases (>) for fields in order + ("2022.10.1.2", "2022.10.1.1"), + ("2022.10.2.1", "2022.10.1.1"), + ("2022.11.1.1", "2022.10.1.1"), + ("2023.10.1.1", "2022.10.1.1"), + ]; + + for (v1, v2) in VERSIONS_ASCENDING_PAIRS { + let greater = DateVersion::from_str(v1).unwrap(); + let lesser = DateVersion::from_str(v2).unwrap(); + + assert!(greater > lesser); + } + } +} diff --git a/crates/devolutions-agent-shared/src/lib.rs b/crates/devolutions-agent-shared/src/lib.rs new file mode 100644 index 000000000..98e0086b7 --- /dev/null +++ b/crates/devolutions-agent-shared/src/lib.rs @@ -0,0 +1,54 @@ +mod date_version; +mod update_json; + +use std::env; + +use camino::Utf8PathBuf; +use cfg_if::cfg_if; + +pub use date_version::{DateVersion, DateVersionError}; +pub use update_json::{ProductUpdateInfo, UpdateJson, VersionSpecification}; + +cfg_if! { + if #[cfg(target_os = "windows")] { + const COMPANY_DIR: &str = "Devolutions"; + const PROGRAM_DIR: &str = "Agent"; + const APPLICATION_DIR: &str = "Devolutions\\Agent"; + } else if #[cfg(target_os = "macos")] { + const COMPANY_DIR: &str = "Devolutions"; + const PROGRAM_DIR: &str = "Agent"; + const APPLICATION_DIR: &str = "Devolutions Agent"; + } else { + const COMPANY_DIR: &str = "devolutions"; + const PROGRAM_DIR: &str = "agent"; + const APPLICATION_DIR: &str = "devolutions-agent"; + } +} + +pub fn get_data_dir() -> Utf8PathBuf { + if let Ok(config_path_env) = env::var("DAGENT_CONFIG_PATH") { + Utf8PathBuf::from(config_path_env) + } else { + let mut config_path = Utf8PathBuf::new(); + + if cfg!(target_os = "windows") { + let program_data_env = env::var("ProgramData").expect("ProgramData env variable"); + config_path.push(program_data_env); + config_path.push(COMPANY_DIR); + config_path.push(PROGRAM_DIR); + } else if cfg!(target_os = "macos") { + config_path.push("/Library/Application Support"); + config_path.push(APPLICATION_DIR); + } else { + config_path.push("/etc"); + config_path.push(APPLICATION_DIR); + } + + config_path + } +} + +/// Returns the path to the `update.json` file +pub fn get_updater_file_path() -> Utf8PathBuf { + get_data_dir().join("update.json") +} diff --git a/crates/devolutions-agent-shared/src/update_json.rs b/crates/devolutions-agent-shared/src/update_json.rs new file mode 100644 index 000000000..9164d3dec --- /dev/null +++ b/crates/devolutions-agent-shared/src/update_json.rs @@ -0,0 +1,68 @@ +use crate::DateVersion; +use serde::{Deserialize, Serialize}; +use std::fmt; + +/// Example JSON structure: +/// +/// ```json +/// { +/// "Gateway": { +/// "TargetVersion": "1.2.3.4" +/// } +/// } +/// ``` +/// +#[derive(Debug, Default, Deserialize, Serialize)] +#[serde(rename_all = "PascalCase")] +pub struct UpdateJson { + #[serde(skip_serializing_if = "Option::is_none")] + pub gateway: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum VersionSpecification { + Latest, + #[serde(untagged)] + Specific(DateVersion), +} + +impl fmt::Display for VersionSpecification { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + VersionSpecification::Latest => write!(f, "latest"), + VersionSpecification::Specific(version) => write!(f, "{}", version), + } + } +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "PascalCase")] +pub struct ProductUpdateInfo { + /// The version of the product to update to. + pub target_version: VersionSpecification, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn version_specification_roundtrip() { + let cases: &[(&'static str, VersionSpecification)] = &[ + ( + "2022.2.24.0", + VersionSpecification::Specific("2022.2.24.0".parse().unwrap()), + ), + ("latest", VersionSpecification::Latest), + ]; + + for (serialized, deserizlized) in cases { + let parsed = serde_json::from_str::(&format!("\"{}\"", serialized)).unwrap(); + assert_eq!(parsed, *deserizlized); + + let reserialized = serde_json::to_string(&parsed).unwrap(); + assert_eq!(reserialized, format!("\"{}\"", serialized)); + } + } +} diff --git a/devolutions-agent/Cargo.toml b/devolutions-agent/Cargo.toml index b5b832239..a34dcf729 100644 --- a/devolutions-agent/Cargo.toml +++ b/devolutions-agent/Cargo.toml @@ -4,38 +4,63 @@ version = "2024.1.2" edition = "2021" license = "MIT/Apache-2.0" authors = ["Devolutions Inc. "] +description = "Agent companion service for Devolutions Gateway" build = "build.rs" publish = false [dependencies] -# In-house -devolutions-gateway-task = { path = "../crates/devolutions-gateway-task" } -devolutions-log = { path = "../crates/devolutions-log" } - -# Lifecycle +anyhow = "1.0" +async-trait = "0.1" +# TODO: serde not needed? +camino = { version = "1.1", features = ["serde1"] } ceviche = "0.6" ctrlc = "3.1" - -# Serialization +devolutions-agent-shared = { path = "../crates/devolutions-agent-shared" } +devolutions-gateway-task = { path = "../crates/devolutions-gateway-task" } +devolutions-log = { path = "../crates/devolutions-log" } +futures = "0.3" +hex = "0.4" +notify-debouncer-mini = "0.4.1" +parking_lot = "0.12" +reqwest = { version = "0.12", default-features = false, features = ["rustls-tls-native-roots"] } serde = "1.0" serde_derive = "1.0" serde_json = "1.0" - -# Error handling -anyhow = "1.0" - -# Utils, misc -camino = { version = "1.1", features = ["serde1"] } -cfg-if = "1.0" -parking_lot = "0.12" +sha2 = "0.10" +smallvec = "1" tap = "1.0" +thiserror = "1" +tracing = "0.1" +uuid = { version = "1.5", features = ["v4"] } -# Async -futures = "0.3" -tokio = { version = "1.37", features = ["signal", "net", "io-util", "time", "rt", "rt-multi-thread", "sync", "macros", "parking_lot", "fs"] } +[dependencies.tokio] +version = "1.37" +features = [ + "signal", + "net", + "io-util", + "rt", + "rt-multi-thread", + "macros", + "parking_lot", + "fs", + "process" +] -# Logging -tracing = "0.1" +[target.'cfg(windows)'.dependencies] +winreg = "0.52" + +[target.'cfg(windows)'.dependencies.windows] +version = "0.57" +features = [ + "Win32_Foundation", + "Win32_Storage_FileSystem", + "Win32_Security", + "Win32_System_Threading", + "Win32_Security_Cryptography", + "Win32_Security_Authorization", + "Win32_System_ApplicationInstallationAndServicing" +] [target.'cfg(windows)'.build-dependencies] -embed-resource = "2.4" +embed-resource = "2.4" \ No newline at end of file diff --git a/devolutions-agent/src/config.rs b/devolutions-agent/src/config.rs index 64707e514..b363f8b69 100644 --- a/devolutions-agent/src/config.rs +++ b/devolutions-agent/src/config.rs @@ -1,34 +1,18 @@ -use std::env; use std::fs::File; use std::io::BufReader; use std::sync::Arc; use anyhow::{bail, Context}; use camino::{Utf8Path, Utf8PathBuf}; -use cfg_if::cfg_if; +use devolutions_agent_shared::get_data_dir; use serde::{Deserialize, Serialize}; use tap::prelude::*; -cfg_if! { - if #[cfg(target_os = "windows")] { - const COMPANY_DIR: &str = "Devolutions"; - const PROGRAM_DIR: &str = "Agent"; - const APPLICATION_DIR: &str = "Devolutions\\Agent"; - } else if #[cfg(target_os = "macos")] { - const COMPANY_DIR: &str = "Devolutions"; - const PROGRAM_DIR: &str = "Agent"; - const APPLICATION_DIR: &str = "Devolutions Agent"; - } else { - const COMPANY_DIR: &str = "devolutions"; - const PROGRAM_DIR: &str = "agent"; - const APPLICATION_DIR: &str = "devolutions-agent"; - } -} - #[derive(Debug, Clone)] pub struct Conf { pub log_file: Utf8PathBuf, pub verbosity_profile: dto::VerbosityProfile, + pub updater: dto::UpdaterConf, pub debug: dto::DebugConf, } @@ -45,6 +29,7 @@ impl Conf { Ok(Conf { log_file, verbosity_profile: conf_file.verbosity_profile.unwrap_or_default(), + updater: conf_file.updater.clone().unwrap_or_default(), debug: conf_file.debug.clone().unwrap_or_default(), }) } @@ -95,29 +80,6 @@ fn save_config(conf: &dto::ConfFile) -> anyhow::Result<()> { Ok(()) } -fn get_data_dir() -> Utf8PathBuf { - if let Ok(config_path_env) = env::var("DAGENT_CONFIG_PATH") { - Utf8PathBuf::from(config_path_env) - } else { - let mut config_path = Utf8PathBuf::new(); - - if cfg!(target_os = "windows") { - let program_data_env = env::var("ProgramData").expect("ProgramData env variable"); - config_path.push(program_data_env); - config_path.push(COMPANY_DIR); - config_path.push(PROGRAM_DIR); - } else if cfg!(target_os = "macos") { - config_path.push("/Library/Application Support"); - config_path.push(APPLICATION_DIR); - } else { - config_path.push("/etc"); - config_path.push(APPLICATION_DIR); - } - - config_path - } -} - fn get_conf_file_path() -> Utf8PathBuf { get_data_dir().join("agent.json") } @@ -160,6 +122,14 @@ pub fn load_conf_file_or_generate_new() -> anyhow::Result { pub mod dto { use super::*; + #[derive(PartialEq, Eq, Debug, Default, Clone, Serialize, Deserialize)] + #[serde(rename_all = "PascalCase")] + pub struct UpdaterConf { + /// Enable updater module (enabled by default) + #[serde(default)] + pub disable: bool, + } + /// Source of truth for Agent configuration /// /// This struct represents the JSON file used for configuration as close as possible @@ -177,13 +147,16 @@ pub mod dto { #[serde(skip_serializing_if = "Option::is_none")] pub log_file: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub updater: Option, + /// (Unstable) Unsafe debug options for developers #[serde(default, rename = "__debug__", skip_serializing_if = "Option::is_none")] pub debug: Option, - // Other unofficial options. - // This field is useful so that we can deserialize - // and then losslessly serialize back all root keys of the config file. + /// Other unofficial options. + /// This field is useful so that we can deserialize + /// and then losslessly serialize back all root keys of the config file. #[serde(flatten)] pub rest: serde_json::Map, } @@ -193,6 +166,7 @@ pub mod dto { Self { verbosity_profile: None, log_file: None, + updater: None, debug: None, rest: serde_json::Map::new(), } @@ -233,14 +207,22 @@ pub mod dto { #[derive(PartialEq, Eq, Debug, Clone, Serialize, Deserialize)] pub struct DebugConf { /// Directives string in the same form as the RUST_LOG environment variable + #[serde(default, skip_serializing_if = "Option::is_none")] pub log_directives: Option, + /// Skip MSI installation in updater module. Useful for debugging updater logic + /// without actually changing the system. + #[serde(default)] + pub skip_msi_install: bool, } /// Manual Default trait implementation just to make sure default values are deliberates #[allow(clippy::derivable_impls)] impl Default for DebugConf { fn default() -> Self { - Self { log_directives: None } + Self { + log_directives: None, + skip_msi_install: false, + } } } diff --git a/devolutions-agent/src/main.rs b/devolutions-agent/src/main.rs index 06da0d61a..9c6e4d7f2 100644 --- a/devolutions-agent/src/main.rs +++ b/devolutions-agent/src/main.rs @@ -5,6 +5,9 @@ mod config; mod log; mod service; +#[cfg(windows)] +mod updater; + use std::env; use std::sync::mpsc; diff --git a/devolutions-agent/src/service.rs b/devolutions-agent/src/service.rs index 1303ff94b..4a9e9cbc2 100644 --- a/devolutions-agent/src/service.rs +++ b/devolutions-agent/src/service.rs @@ -1,9 +1,11 @@ use tokio::runtime::{self, Runtime}; +#[cfg(windows)] +use crate::updater::UpdaterTask; use crate::{config::ConfHandle, log::AgentLog}; use anyhow::Context; use devolutions_gateway_task::{ChildTask, ShutdownHandle, ShutdownSignal}; -use devolutions_log::{self, LoggerGuard}; +use devolutions_log::{self, LogDeleterTask, LoggerGuard}; use std::time::Duration; pub const SERVICE_NAME: &str = "devolutions-agent"; @@ -174,7 +176,12 @@ async fn spawn_tasks(conf_handle: ConfHandle) -> anyhow::Result { let mut tasks = Tasks::new(); - tasks.register(devolutions_log::LogDeleterTask::::new(conf.log_file.clone())); + tasks.register(LogDeleterTask::::new(conf.log_file.clone())); + + #[cfg(windows)] + if !conf.updater.disable { + tasks.register(UpdaterTask::new(conf_handle.clone())); + } Ok(tasks) } diff --git a/devolutions-agent/src/updater/detect.rs b/devolutions-agent/src/updater/detect.rs new file mode 100644 index 000000000..1b55cd802 --- /dev/null +++ b/devolutions-agent/src/updater/detect.rs @@ -0,0 +1,64 @@ +//! Module which provides logic to detect installed products and their versions. + +use devolutions_agent_shared::DateVersion; + +use crate::updater::uuid::{reversed_hex_to_uuid, uuid_to_reversed_hex}; +use crate::updater::{Product, UpdaterError}; + +const GATEWAY_UPDATE_CODE: &str = "{db3903d6-c451-4393-bd80-eb9f45b90214}"; + +/// Get the installed version of a product. +pub fn get_installed_product_version(product: Product) -> Result, UpdaterError> { + match product { + Product::Gateway => get_instaled_product_version_winreg(GATEWAY_UPDATE_CODE), + } +} + +/// Get the installed version of a product using Windows registry. +fn get_instaled_product_version_winreg(update_code: &str) -> Result, UpdaterError> { + let reversed_hex_uuid = uuid_to_reversed_hex(update_code)?; + + const REG_CURRENT_VERSION: &str = "SOFTWARE\\Microsoft\\Windows\\CurrentVersion"; + + let update_code_key = winreg::RegKey::predef(winreg::enums::HKEY_LOCAL_MACHINE) + .open_subkey(format!( + "{REG_CURRENT_VERSION}\\Installer\\UpgradeCodes\\{reversed_hex_uuid}" + )) + .ok(); + + // Product not installed if no key found. + let update_code_key = match update_code_key { + Some(key) => key, + None => return Ok(None), + }; + + // Product code is the name of the only value in the registry key. + let (product_code, _) = match update_code_key.enum_values().next() { + Some(value) => value.map_err(UpdaterError::WindowsRegistry)?, + None => return Err(UpdaterError::MissingRegistryValue), + }; + + let product_code_uuid = reversed_hex_to_uuid(&product_code)?; + + // Now we know the product code of installed MSI, we could read its version. + let product_tree = winreg::RegKey::predef(winreg::enums::HKEY_LOCAL_MACHINE) + .open_subkey(format!("{REG_CURRENT_VERSION}\\Uninstall\\{product_code_uuid}")) + .map_err(UpdaterError::WindowsRegistry)?; + + let product_version: u32 = product_tree + .get_value("Version") + .map_err(UpdaterError::WindowsRegistry)?; + + // Convert encoded MSI version number to human-readable date. + let short_year = (product_version >> 24) + 2000; + let month = (product_version >> 16) & 0xFF; + let day = product_version & 0xFFFF; + + Ok(Some(DateVersion { + year: short_year, + month, + day, + // NOTE: Windows apps could only have 3 version numbers (major, minor, patch). + revision: 0, + })) +} diff --git a/devolutions-agent/src/updater/error.rs b/devolutions-agent/src/updater/error.rs new file mode 100644 index 000000000..a650f359c --- /dev/null +++ b/devolutions-agent/src/updater/error.rs @@ -0,0 +1,44 @@ +use camino::Utf8PathBuf; +use thiserror::Error; + +use crate::updater::Product; + +#[derive(Debug, Error)] +pub enum UpdaterError { + #[error("queried `{product}` artifact hash has invalid format: `{hash}`")] + HashEncoding { product: Product, hash: String }, + #[error("integrity check for downloaded `{product}` artifact has failed, expected hash: `{expected_hash}`, actual hash: `{actual_hash}`")] + IntegrityCheck { + product: Product, + expected_hash: String, + actual_hash: String, + }, + #[error("failed to validate `{product}` MSI signature. MSI path: `{msi_path}`")] + MsiSignature { product: Product, msi_path: Utf8PathBuf }, + #[error("failed to calculate MSI certificate hash for `{product}`. MSI path: `{msi_path}`")] + MsiCertHash { product: Product, msi_path: Utf8PathBuf }, + #[error("MSI for `{product}` is signed with invalid non-Devolutions certificate. Certificate thumbprint: `{thumbprint}`")] + MsiCertificateThumbprint { product: Product, thumbprint: String }, + #[error("failed to install `{product}` MSI. Path: `{msi_path}")] + MsiInstall { product: Product, msi_path: Utf8PathBuf }, + #[error("ACL string `{acl}` is invalid")] + AclString { acl: String }, + #[error("failed to set permissions for file: `{file_path}`")] + SetFilePermissions { file_path: Utf8PathBuf }, + #[error("invalid UUID `{uuid}`")] + Uuid { uuid: String }, + #[error("invalid productinfo.htm format")] + ProductInfo, + #[error("windows registry error")] + WindowsRegistry(std::io::Error), + #[error("missing registry value")] + MissingRegistryValue, + #[error("failed to download update")] + FileDownload { source: reqwest::Error, file_path: String }, + #[error("invalid UTF-8")] + Utf8, + #[error("IO error")] + Io(#[from] std::io::Error), + #[error("process does not have required rights to install MSI")] + NotElevated, +} diff --git a/devolutions-agent/src/updater/integrity.rs b/devolutions-agent/src/updater/integrity.rs new file mode 100644 index 000000000..fbadee708 --- /dev/null +++ b/devolutions-agent/src/updater/integrity.rs @@ -0,0 +1,25 @@ +//! File integrity validation utilities. + +use sha2::{Digest as _, Sha256}; + +use crate::updater::{UpdaterCtx, UpdaterError}; + +/// Validate the hash of downloaded artifact (Hash should be provided in encoded hex string). +pub fn validate_artifact_hash(ctx: &UpdaterCtx, data: &[u8], hash: &str) -> Result<(), UpdaterError> { + let expected_hash_bytes = hex::decode(hash).map_err(|_| UpdaterError::HashEncoding { + product: ctx.product, + hash: hash.to_owned(), + })?; + + let actual_hash_bytes = Sha256::digest(data); + + if expected_hash_bytes != actual_hash_bytes.as_slice() { + return Err(UpdaterError::IntegrityCheck { + product: ctx.product, + expected_hash: hex::encode(expected_hash_bytes), + actual_hash: hex::encode(actual_hash_bytes.as_slice()), + }); + } + + Ok(()) +} diff --git a/devolutions-agent/src/updater/io.rs b/devolutions-agent/src/updater/io.rs new file mode 100644 index 000000000..875627023 --- /dev/null +++ b/devolutions-agent/src/updater/io.rs @@ -0,0 +1,76 @@ +//! IO utilities for the updater logic + +use camino::Utf8Path; +use camino::Utf8PathBuf; +use futures::TryFutureExt; +use tokio::{fs::File, io::AsyncWriteExt}; + +use crate::updater::UpdaterError; + +/// Download binary file to memory +pub async fn download_bytes(url: &str) -> Result, UpdaterError> { + info!(%url, "Downloading file from network..."); + + let body = reqwest::get(url) + .and_then(|response| response.bytes()) + .map_err(|source| UpdaterError::FileDownload { + source, + file_path: url.to_string(), + }) + .await?; + Ok(body.to_vec()) +} + +/// Download UTF-8 file to memory +pub async fn download_text(url: &str) -> Result { + let bytes = download_bytes(url).await?; + String::from_utf8(bytes).map_err(|_| UpdaterError::Utf8) +} + +/// Save data to a temporary file +pub async fn save_to_temp_file(data: &[u8], extension: Option<&str>) -> Result { + let uuid = uuid::Uuid::new_v4(); + + let file_name = match extension { + Some(ext) => format!("{uuid}.{}", ext), + None => uuid.to_string(), + }; + + let file_path = Utf8PathBuf::from_path_buf(std::env::temp_dir()) + .expect("BUG: OS Should always return valid UTF-8 temp path") + .join(file_name); + + let mut file = File::create(&file_path).await?; + file.write_all(data).await?; + + remove_file_on_reboot(&file_path)?; + + Ok(file_path) +} + +/// Mark file to be removed on next reboot. +pub fn remove_file_on_reboot(file_path: &Utf8Path) -> Result<(), UpdaterError> { + _impl_remove_file_on_reboot(file_path) +} + +#[cfg(windows)] +pub fn _impl_remove_file_on_reboot(file_path: &Utf8Path) -> Result<(), UpdaterError> { + use windows::core::HSTRING; + use windows::Win32::Storage::FileSystem::{MoveFileExW, MOVEFILE_DELAY_UNTIL_REBOOT}; + + let hstring_file_path = HSTRING::from(file_path.as_str()); + + let move_result = unsafe { MoveFileExW(&hstring_file_path, None, MOVEFILE_DELAY_UNTIL_REBOOT) }; + + if let Err(err) = move_result { + warn!(%err, %file_path, "Failed to mark file for deletion on reboot"); + } + + Ok(()) +} + +#[cfg(not(windows))] +pub fn _impl_remove_file_on_reboot(_file_path: &Utf8Path) -> Result<(), UpdaterError> { + // NOTE: On UNIX-like platforms /tmp filder is used which is cleared by OS automatically. + Ok(()) +} diff --git a/devolutions-agent/src/updater/mod.rs b/devolutions-agent/src/updater/mod.rs new file mode 100644 index 000000000..d114e8231 --- /dev/null +++ b/devolutions-agent/src/updater/mod.rs @@ -0,0 +1,284 @@ +mod detect; +mod error; +mod integrity; +mod io; +mod package; +mod product; +mod productinfo; +mod security; +mod uuid; + +use std::sync::Arc; +use std::time::Duration; + +use anyhow::{anyhow, Context}; +use async_trait::async_trait; +use camino::{Utf8Path, Utf8PathBuf}; +use notify_debouncer_mini::notify::RecursiveMode; +use tokio::fs; + +use devolutions_agent_shared::{get_updater_file_path, DateVersion}; +use devolutions_agent_shared::{UpdateJson, VersionSpecification}; +use devolutions_gateway_task::{ShutdownSignal, Task}; + +use crate::config::ConfHandle; + +use integrity::validate_artifact_hash; +use io::{download_bytes, download_text, save_to_temp_file}; +use package::{install_package, validate_package}; +use productinfo::DEVOLUTIONS_PRODUCTINFO_URL; +use security::set_file_dacl; + +pub use error::UpdaterError; +pub use product::Product; + +const UPDATE_JSON_WATCH_INTERVAL: Duration = Duration::from_secs(3); + +// List of updateable products could be extended in future +const PRODUCTS: &[Product] = &[Product::Gateway]; + +/// Context for updater task +struct UpdaterCtx { + product: Product, + conf: ConfHandle, +} + +struct UpdateOrder { + target_version: DateVersion, + package_url: String, + hash: String, +} + +pub struct UpdaterTask { + conf_handle: ConfHandle, +} + +impl UpdaterTask { + pub fn new(conf_handle: ConfHandle) -> Self { + Self { conf_handle } + } +} + +#[async_trait] +impl Task for UpdaterTask { + type Output = anyhow::Result<()>; + + const NAME: &'static str = "updater"; + + async fn run(self, mut shutdown_signal: ShutdownSignal) -> anyhow::Result<()> { + let conf = self.conf_handle.clone(); + + // Initialize update.json file if does not exist + let update_file_path = init_update_json().await?; + + let file_change_notification = Arc::new(tokio::sync::Notify::new()); + let file_change_tx = file_change_notification.clone(); + + let mut notify_debouncer = + notify_debouncer_mini::new_debouncer(UPDATE_JSON_WATCH_INTERVAL, move |result| match result { + Ok(_) => { + let _ = file_change_tx.notify_waiters(); + } + Err(err) => { + error!(%err, "Failed to watch update.json file"); + } + }) + .context("Failed to create file notify debouncer")?; + + notify_debouncer + .watcher() + .watch(update_file_path.as_std_path(), RecursiveMode::NonRecursive) + .context("Failed to start update file watcher")?; + + // Trigger initial check during task startup + file_change_notification.notify_waiters(); + + loop { + tokio::select! { + _ = file_change_notification.notified() => { + info!("update.json file changed, checking for updates..."); + + + let update_json = match read_update_json(&update_file_path).await { + Ok(update_json) => update_json, + Err(err) => { + error!(%err, "Failed to parse `update.json`"); + // Allow this error to be non-critical, as this file could be + // updated later to be valid again + continue; + } + }; + + let mut update_orders = vec![]; + + for product in PRODUCTS { + let update_order = match check_for_updates(*product, &update_json).await { + Ok(order) => order, + Err(err) => { + error!(%product, %err, "Failed to check for updates for a product."); + continue; + } + }; + + if let Some(order) = update_order { + update_orders.push((*product, order)); + } + } + + if update_orders.is_empty() { + info!("No updates available for any product"); + } + + for (product, order) in update_orders { + if let Err(err) = update_product(conf.clone(), product, order).await { + error!(%product, %err, "Failed to update product"); + } + } + } + _ = shutdown_signal.wait() => { + break; + } + } + } + + Ok(()) + } +} + +async fn update_product(conf: ConfHandle, product: Product, order: UpdateOrder) -> anyhow::Result<()> { + let target_version = order.target_version; + let hash = order.hash; + + let package_data = download_bytes(&order.package_url) + .await + .with_context(|| format!("Failed to download package file for `{product}`"))?; + + let package_path = save_to_temp_file(&package_data, Some(product.get_package_extension())).await?; + + info!(%product, %target_version, %package_path, "Downloaded product Installer"); + + let ctx = UpdaterCtx { product, conf }; + + validate_artifact_hash(&ctx, &package_data, &hash).context("Failed to validate package file integrity")?; + + validate_package(&ctx, &package_path).context("Failed to validate package contents")?; + + if ctx.conf.get_conf().debug.skip_msi_install { + warn!(%product, "DEBUG MODE: Skipping package installation due to debug configuration"); + return Ok(()); + } + + install_package(&ctx, &package_path) + .await + .context("Failed to install package")?; + + info!(%product, "Product updated to v{target_version}!"); + + Ok(()) +} + +async fn read_update_json(update_file_path: &Utf8Path) -> anyhow::Result { + let update_json_data = tokio::fs::read(update_file_path) + .await + .context("Failed to read update.json file")?; + let update_json: UpdateJson = + serde_json::from_slice(&update_json_data).context("Failed to parse update.json file")?; + + Ok(update_json) +} + +async fn check_for_updates(product: Product, update_json: &UpdateJson) -> anyhow::Result> { + let target_version = match product.get_update_info(update_json).map(|info| info.target_version) { + Some(version) => version, + None => { + trace!(%product, "No target version specified in update.json, skipping update check"); + return Ok(None); + } + }; + + let detected_version = match detect::get_installed_product_version(product) { + Ok(Some(version)) => version, + Ok(None) => { + trace!(%product, "Product is not installed, skipping update check"); + return Ok(None); + } + Err(err) => { + return Err(err.into()); + } + }; + + trace!(%product, %detected_version, "Detected installed product version"); + + let is_target_version_newer = match target_version { + VersionSpecification::Latest => true, + VersionSpecification::Specific(target) => target > detected_version, + }; + + if !is_target_version_newer { + info!(%product, %detected_version, "Product is up to date, skipping update"); + return Ok(None); + } + + info!(%product, %target_version, "Ready to update the product"); + + let product_info_db = download_text(DEVOLUTIONS_PRODUCTINFO_URL) + .await + .context("Failed to download productinfo database")?; + + let product_info_db: productinfo::ProductInfoDb = product_info_db.parse()?; + + let product_info = product_info_db + .get(product.get_productinfo_id()) + .ok_or_else(|| anyhow!("Product `{product}` info not found in remote database"))?; + + let remote_version = product_info.version.parse::()?; + + match target_version { + VersionSpecification::Latest => { + if remote_version <= detected_version { + info!(%product, %detected_version, "Product is up to date, skipping update (update to `latest` requested)"); + return Ok(None); + } + } + VersionSpecification::Specific(version) => { + if version != remote_version { + warn!(%product, %version, "Product uptate target version does not match available version on devolutions.net, skipping update"); + return Ok(None); + } + } + } + + Ok(Some(UpdateOrder { + target_version: remote_version, + package_url: product_info.url.clone(), + hash: product_info.hash.clone(), + })) +} + +async fn init_update_json() -> anyhow::Result { + let update_file_path = get_updater_file_path(); + + let default_update_json = + serde_json::to_string_pretty(&UpdateJson::default()).context("Failed to serialize default update.json")?; + + fs::write(&update_file_path, default_update_json) + .await + .context("Failed to write default update.json file")?; + + // Set permissions for update.json file: + match set_file_dacl(&update_file_path, security::UPDATE_JSON_DACL) { + Ok(_) => { + info!("Created new `update.json` and set permissions successfully"); + } + Err(err) => { + // Remove update.json file if failed to set permissions + std::fs::remove_file(update_file_path.as_std_path()) + .unwrap_or_else(|err| warn!(%err, "Failed to remove update.json file after failed permissions set")); + + // Treat as fatal error + return Err(anyhow!(err).context("Failed to set update.json file permissions")); + } + } + + Ok(update_file_path) +} diff --git a/devolutions-agent/src/updater/package.rs b/devolutions-agent/src/updater/package.rs new file mode 100644 index 000000000..c5c08188d --- /dev/null +++ b/devolutions-agent/src/updater/package.rs @@ -0,0 +1,196 @@ +//! Package installation and validation logic + +use std::ops::DerefMut; + +use camino::Utf8Path; + +use crate::updater::io::remove_file_on_reboot; +use crate::updater::{Product, UpdaterCtx, UpdaterError}; + +/// List of allowed thumbprints for Devolutions code signing certificates +const DEVOLUTIONS_CERT_THUMBPRINTS: &[&str] = &[ + "3f5202a9432d54293bdfe6f7e46adb0a6f8b3ba6", + "8db5a43bb8afe4d2ffb92da9007d8997a4cc4e13", +]; + +pub async fn install_package(ctx: &UpdaterCtx, path: &Utf8Path) -> Result<(), UpdaterError> { + match ctx.product { + Product::Gateway => install_msi(ctx, path).await, + } +} + +async fn install_msi(ctx: &UpdaterCtx, path: &Utf8Path) -> Result<(), UpdaterError> { + // When running in service, we do always have enough rights to install MSI. However, for ease + // of testing, we can skip MSI installation. + ensure_enough_rights()?; + + info!("Installing MSI from path: {}", path); + + let log_path = path.with_extension("log"); + + let msi_install_result = tokio::process::Command::new("msiexec") + .arg("/i") + .arg(path.as_str()) + .arg("/quiet") + .arg("/l*v") + .arg(log_path.as_str()) + .status() + .await; + + if log_path.exists() { + info!("MSI installation log: {log_path}"); + + // Schedule log file for deletion on reboot + if let Err(err) = remove_file_on_reboot(&log_path) { + error!(%err, "Failed to schedule log file for deletion on reboot"); + } + } + + if msi_install_result.is_err() { + return Err(UpdaterError::MsiInstall { + product: ctx.product, + msi_path: path.to_owned(), + }); + } + + Ok(()) +} + +fn ensure_enough_rights() -> Result<(), UpdaterError> { + use windows::core::Owned; + use windows::Win32::Foundation::INVALID_HANDLE_VALUE; + use windows::Win32::Security::{GetTokenInformation, TokenElevation, TOKEN_ELEVATION, TOKEN_QUERY}; + use windows::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken}; + + // SAFETY: `GetCurrentProcess` returns a "pseudo handle" that does not need to be closed. + let process_handle = unsafe { GetCurrentProcess() }; + + // SAFETY: `INVALID_HANDLE_VALUE` are predefined values that represent an invalid handle, + // and thus safe to use as a default value. + let mut token_handle = unsafe { Owned::new(INVALID_HANDLE_VALUE) }; + + // SAFETY: Called with valid process handle, should not fail. + let open_token_result = + unsafe { OpenProcessToken(process_handle, TOKEN_QUERY, token_handle.deref_mut() as *mut _) }; + + if open_token_result.is_err() || token_handle.is_invalid() { + // Should never happen, as we are passing a valid handle, but just in case, handle this case + // as non-elevated access. + return Err(UpdaterError::NotElevated); + } + + let mut token_elevation = TOKEN_ELEVATION::default(); + let mut return_size = 0u32; + + // SAFETY: Called with valid token and pre-allocated buffer. + let token_query_result = unsafe { + GetTokenInformation( + *token_handle, + TokenElevation, + Some(&mut token_elevation as *mut _ as _), + std::mem::size_of::().try_into().unwrap(), + &mut return_size as _, + ) + }; + + if token_query_result.is_err() || token_elevation.TokenIsElevated == 0 { + return Err(UpdaterError::NotElevated); + } + + Ok(()) +} + +pub fn validate_package(ctx: &UpdaterCtx, path: &Utf8Path) -> Result<(), UpdaterError> { + match ctx.product { + Product::Gateway => validate_msi(ctx, path), + } +} + +fn validate_msi(ctx: &UpdaterCtx, path: &Utf8Path) -> Result<(), UpdaterError> { + use windows::core::HSTRING; + use windows::Win32::Security::Cryptography::{ + CertFreeCertificateContext, CryptHashCertificate, CALG_SHA1, CERT_CONTEXT, + }; + use windows::Win32::System::ApplicationInstallationAndServicing::{ + MsiGetFileSignatureInformationW, MSI_INVALID_HASH_IS_FATAL, + }; + + // Wrapper type to free CERT_CONTEXT retrieved via `MsiGetFileSignatureInformationW`` + struct OwnedCertContext(pub *mut CERT_CONTEXT); + + impl Drop for OwnedCertContext { + fn drop(&mut self) { + if !self.0.is_null() { + // SAFETY: inner pointer is always valid, as it is only set + // via `MsiGetFileSignatureInformationW` call + let _ = unsafe { CertFreeCertificateContext(Some(self.0)) }; + } + } + } + + let msi_path_hstring = HSTRING::from(path.as_str()); + let mut cert_context = OwnedCertContext(std::ptr::null_mut()); + + // SAFETY: `msi_path_hstring` is a valid reference-counted UTF16 string, and `cert_context` is + // initialized and will be freed on drop. + let result = unsafe { + MsiGetFileSignatureInformationW( + &msi_path_hstring, + MSI_INVALID_HASH_IS_FATAL, // Validate signature + &mut cert_context.0 as _, + None, + None, + ) + }; + + let mut validation_failed = result.is_err() || cert_context.0.is_null(); + + if !validation_failed { + // SAFETY: `cert_context.0` is not null if this block is reached. + validation_failed |= unsafe { (*cert_context.0).pbCertEncoded.is_null() }; + } + + if validation_failed { + return Err(UpdaterError::MsiSignature { + product: ctx.product, + msi_path: path.to_owned(), + }); + } + + let mut calculated_cert_sha1 = [0u8; 20]; + let mut calculated_cert_sha1_size = calculated_cert_sha1.len() as u32; + + // SAFETY: cert_context.0.pbCertEncoded is a valid pointer to the certificate bytes retrieved + // via `MsiGetFileSignatureInformationW` call and validated above. + unsafe { + CryptHashCertificate( + None, + CALG_SHA1, + 0, + core::slice::from_raw_parts((*cert_context.0).pbCertEncoded, (*cert_context.0).cbCertEncoded as _), + Some(&mut calculated_cert_sha1 as _), + &mut calculated_cert_sha1_size as _, + ) + } + .map_err(|_| UpdaterError::MsiCertHash { + product: ctx.product, + msi_path: path.to_owned(), + })?; + + let is_thumbprint_valid = DEVOLUTIONS_CERT_THUMBPRINTS.iter().any(|thumbprint| { + let mut thumbprint_bytes = [0u8; 20]; + hex::decode_to_slice(thumbprint, &mut thumbprint_bytes) + .expect("BUG: Invalid thumbprint in `DEVOLUTIONS_CERT_THUMBPRINTS`"); + + thumbprint_bytes == calculated_cert_sha1 + }); + + if !is_thumbprint_valid { + return Err(UpdaterError::MsiCertificateThumbprint { + product: ctx.product, + thumbprint: hex::encode(calculated_cert_sha1), + }); + } + + Ok(()) +} diff --git a/devolutions-agent/src/updater/product.rs b/devolutions-agent/src/updater/product.rs new file mode 100644 index 000000000..bbe661c4c --- /dev/null +++ b/devolutions-agent/src/updater/product.rs @@ -0,0 +1,51 @@ +use std::fmt; +use std::str::FromStr; + +use devolutions_agent_shared::{ProductUpdateInfo, UpdateJson}; + +use crate::updater::productinfo::GATEWAY_PRODUCT_ID; + +/// Product IDs to track updates for +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Product { + Gateway, +} + +impl fmt::Display for Product { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Product::Gateway => write!(f, "Gateway"), + } + } +} + +impl FromStr for Product { + type Err = (); + + fn from_str(s: &str) -> Result { + match s { + "Gateway" => Ok(Product::Gateway), + _ => Err(()), + } + } +} + +impl Product { + pub fn get_update_info(self, update_json: &UpdateJson) -> Option { + match self { + Product::Gateway => update_json.gateway.clone(), + } + } + + pub const fn get_productinfo_id(self) -> &'static str { + match self { + Product::Gateway => GATEWAY_PRODUCT_ID, + } + } + + pub const fn get_package_extension(self) -> &'static str { + match self { + Product::Gateway => "msi", + } + } +} diff --git a/devolutions-agent/src/updater/productinfo/db.rs b/devolutions-agent/src/updater/productinfo/db.rs new file mode 100644 index 000000000..7e71d1e3b --- /dev/null +++ b/devolutions-agent/src/updater/productinfo/db.rs @@ -0,0 +1,114 @@ +//! Devolutions product information (https://devolutions.net/productinfo.htm) parser + +use std::{collections::HashMap, str::FromStr}; + +use crate::updater::UpdaterError; + +#[derive(Debug, Clone, Default)] +pub struct ProductInfo { + pub version: String, + pub hash: String, + pub url: String, +} + +pub struct ProductInfoDb { + pub records: HashMap, +} + +impl FromStr for ProductInfoDb { + type Err = UpdaterError; + + fn from_str(s: &str) -> Result { + let mut records = HashMap::new(); + + for line in s.lines() { + if line.is_empty() { + continue; + } + + let (key, value) = line.split_once('=').ok_or(UpdaterError::ProductInfo)?; + let (product_id, property) = key.split_once('.').ok_or(UpdaterError::ProductInfo)?; + + let entry = records + .entry(product_id.to_string()) + .or_insert_with(ProductInfo::default); + + match property { + "Version" => entry.version = value.to_string(), + "Url" => entry.url = value.to_string(), + "hash" => entry.hash = value.to_string(), + _ => { + trace!(%product_id, %property, "Unknown productinfo property"); + continue; + } + } + } + + Ok(ProductInfoDb { records }) + } +} + +impl ProductInfoDb { + /// Get product information by product ID + pub fn get(&self, product_id: &str) -> Option<&ProductInfo> { + self.records.get(product_id) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_productinfo_parse() { + let input = include_str!("test_asset_db"); + let db: ProductInfoDb = input.parse().unwrap(); + + assert_eq!(db.get("Gatewaybin").unwrap().version, "2024.2.1.0"); + assert_eq!( + db.get("Gatewaybin").unwrap().url, + "https://cdn.devolutions.net/download/DevolutionsGateway-x86_64-2024.2.1.0.msi" + ); + assert_eq!( + db.get("Gatewaybin").unwrap().hash, + "BD2805075FCD78AC339126F4C4D9E6773DC3127CBE7DF48256D6910FA0C59C35" + ); + + assert_eq!(db.get("GatewaybinBeta").unwrap().version, "2024.2.1.0"); + assert_eq!( + db.get("GatewaybinBeta").unwrap().url, + "https://cdn.devolutions.net/download/DevolutionsGateway-x86_64-2024.2.1.0.msi" + ); + assert_eq!( + db.get("GatewaybinBeta").unwrap().hash, + "BD2805075FCD78AC339126F4C4D9E6773DC3127CBE7DF48256D6910FA0C59C35" + ); + + assert_eq!(db.get("GatewaybinDebX64").unwrap().version, "2024.2.1.0"); + assert_eq!( + db.get("GatewaybinDebX64").unwrap().url, + "https://cdn.devolutions.net/download/devolutions-gateway_2024.2.1.0_amd64.deb" + ); + assert_eq!( + db.get("GatewaybinDebX64").unwrap().hash, + "72D7A836A6AF221D4E7631D27B91A358915CF985AA544CC0F7F5612B85E989AA" + ); + + assert_eq!(db.get("GatewaybinDebX64Beta").unwrap().version, "2024.2.1.0"); + assert_eq!( + db.get("GatewaybinDebX64Beta").unwrap().url, + "https://cdn.devolutions.net/download/devolutions-gateway_2024.2.1.0_amd64.deb" + ); + assert_eq!( + db.get("GatewaybinDebX64Beta").unwrap().hash, + "72D7A836A6AF221D4E7631D27B91A358915CF985AA544CC0F7F5612B85E989AA" + ); + + assert_eq!(db.get("DevoCLIbin").unwrap().version, "2023.3.0.0"); + assert_eq!( + db.get("DevoCLIbin").unwrap().url, + "https://cdn.devolutions.net/download/DevoCLI.2023.3.0.0.zip" + ); + assert_eq!(db.get("DevoCLIbin").unwrap().hash, ""); + } +} diff --git a/devolutions-agent/src/updater/productinfo/mod.rs b/devolutions-agent/src/updater/productinfo/mod.rs new file mode 100644 index 000000000..6d853257f --- /dev/null +++ b/devolutions-agent/src/updater/productinfo/mod.rs @@ -0,0 +1,10 @@ +mod db; + +pub const DEVOLUTIONS_PRODUCTINFO_URL: &str = "https://devolutions.net/productinfo.htm"; + +#[cfg(windows)] +pub const GATEWAY_PRODUCT_ID: &str = "Gatewaybin"; +#[cfg(not(windows))] +pub const GATEWAY_PRODUCT_ID: &str = "GatewaybinDebX64"; + +pub use db::ProductInfoDb; diff --git a/devolutions-agent/src/updater/productinfo/test_asset_db b/devolutions-agent/src/updater/productinfo/test_asset_db new file mode 100644 index 000000000..da0b1e410 --- /dev/null +++ b/devolutions-agent/src/updater/productinfo/test_asset_db @@ -0,0 +1,17 @@ +Gatewaybin.Version=2024.2.1.0 +Gatewaybin.Url=https://cdn.devolutions.net/download/DevolutionsGateway-x86_64-2024.2.1.0.msi +Gatewaybin.hash=BD2805075FCD78AC339126F4C4D9E6773DC3127CBE7DF48256D6910FA0C59C35 +GatewaybinBeta.Version=2024.2.1.0 +GatewaybinBeta.Url=https://cdn.devolutions.net/download/DevolutionsGateway-x86_64-2024.2.1.0.msi +GatewaybinBeta.hash=BD2805075FCD78AC339126F4C4D9E6773DC3127CBE7DF48256D6910FA0C59C35 +GatewaybinDebX64.Version=2024.2.1.0 +GatewaybinDebX64.Url=https://cdn.devolutions.net/download/devolutions-gateway_2024.2.1.0_amd64.deb +GatewaybinDebX64.hash=72D7A836A6AF221D4E7631D27B91A358915CF985AA544CC0F7F5612B85E989AA +GatewaybinDebX64Beta.Version=2024.2.1.0 +GatewaybinDebX64Beta.Url=https://cdn.devolutions.net/download/devolutions-gateway_2024.2.1.0_amd64.deb +GatewaybinDebX64Beta.hash=72D7A836A6AF221D4E7631D27B91A358915CF985AA544CC0F7F5612B85E989AA + +DevoCLIbin.Version=2023.3.0.0 +DevoCLIbin.Url=https://cdn.devolutions.net/download/DevoCLI.2023.3.0.0.zip +DevoCLIbinBeta.Version=2023.3.0.0 +DevoCLIBeta.Url=https://cdn.devolutions.net/download/DevoCLI.2023.3.0.0.zip diff --git a/devolutions-agent/src/updater/security.rs b/devolutions-agent/src/updater/security.rs new file mode 100644 index 000000000..bffe09a43 --- /dev/null +++ b/devolutions-agent/src/updater/security.rs @@ -0,0 +1,95 @@ +//! Security-related functions for the updater (e.g. file permission settings). + +use camino::Utf8Path; + +use crate::updater::UpdaterError; + +/// DACL for the update.json file: +/// Owner: SYSTEM +/// Group: SYSTEM +/// Access: +/// - SYSTEM: Full control +/// - NETWORK SERVICE: Write, Read (Allow Devolutions Gateway service to update the file) +/// - Administrators: Full control +/// - Users: Read +pub const UPDATE_JSON_DACL: &str = "D:PAI(A;;FA;;;SY)(A;;0x1201bf;;;NS)(A;;FA;;;BA)(A;;FR;;;BU)"; + +/// Set DACL (Discretionary Access Control List) on a specified file. +pub fn set_file_dacl(file_path: &Utf8Path, acl: &str) -> Result<(), UpdaterError> { + use windows::core::HSTRING; + use windows::Win32::Foundation::{LocalFree, ERROR_SUCCESS, FALSE, HLOCAL, PSID}; + use windows::Win32::Security::Authorization::{ + ConvertStringSecurityDescriptorToSecurityDescriptorW, SetNamedSecurityInfoW, SDDL_REVISION_1, SE_FILE_OBJECT, + }; + use windows::Win32::Security::PSECURITY_DESCRIPTOR; + use windows::Win32::Security::{GetSecurityDescriptorDacl, ACL, DACL_SECURITY_INFORMATION}; + + struct OwnedPSecurityDescriptor(PSECURITY_DESCRIPTOR); + + impl Drop for OwnedPSecurityDescriptor { + fn drop(&mut self) { + if self.0 .0.is_null() { + return; + } + unsafe { LocalFree(HLOCAL(self.0 .0)) }; + } + } + + // Decode ACL string into a security descriptor and get PACL instance. + + let acl_hstring = HSTRING::from(acl); + let mut psecurity_descriptor = OwnedPSecurityDescriptor(PSECURITY_DESCRIPTOR::default()); + + unsafe { + ConvertStringSecurityDescriptorToSecurityDescriptorW( + &acl_hstring, + SDDL_REVISION_1, + &mut psecurity_descriptor.0 as _, + None, + ) + } + .map_err(|_| UpdaterError::AclString { acl: acl.to_owned() })?; + + let mut sec_present = FALSE; + let mut sec_defaulted = FALSE; + + let mut dacl: *mut ACL = std::ptr::null_mut(); + + unsafe { + GetSecurityDescriptorDacl( + psecurity_descriptor.0, + &mut sec_present as _, + &mut dacl as _, + &mut sec_defaulted as _, + ) + } + .map_err(|_| UpdaterError::AclString { acl: acl.to_owned() })?; + + if dacl.is_null() { + return Err(UpdaterError::AclString { acl: acl.to_owned() }); + } + + let file_path_hstring = HSTRING::from(file_path.as_str()); + + let set_permissions_result = unsafe { + SetNamedSecurityInfoW( + &file_path_hstring, + SE_FILE_OBJECT, + DACL_SECURITY_INFORMATION, + PSID::default(), + PSID::default(), + Some(dacl), + None, + ) + }; + + if set_permissions_result != ERROR_SUCCESS { + return Err(UpdaterError::SetFilePermissions { + file_path: file_path.to_owned(), + }); + } + + info!("Changed DACL on `{file_path}` to `{acl}`"); + + Ok(()) +} diff --git a/devolutions-agent/src/updater/uuid.rs b/devolutions-agent/src/updater/uuid.rs new file mode 100644 index 000000000..332b56241 --- /dev/null +++ b/devolutions-agent/src/updater/uuid.rs @@ -0,0 +1,101 @@ +//! Windows-specific UUID format conversion functions. + +use smallvec::SmallVec; + +use crate::updater::UpdaterError; + +const UUID_CHARS: usize = 32; +const UUID_REVERSING_PATTERN: &[usize] = &[8, 4, 4, 2, 2, 2, 2, 2, 2, 2, 2]; +const UUID_ALPHABET: &[char] = &[ + '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'A', 'B', 'C', 'D', 'E', 'F', +]; + +/// Converts standard UUID to its reversed hex representation used in Windows Registry +/// for upgrade code table. +/// +/// e.g.: `{82318d3c-811f-4d5d-9a82-b7c31b076755}` => `C3D81328F118D5D4A9287B3CB1707655` +pub fn uuid_to_reversed_hex(uuid: &str) -> Result { + const IGNORED_CHARS: &[char] = &['-', '{', '}']; + + let hex_chars = uuid + .chars() + .filter_map(|ch| { + if IGNORED_CHARS.contains(&ch) { + return None; + } + + Some(ch.to_ascii_uppercase()) + }) + .collect::>(); + + let mut hex_chars_slice = hex_chars.as_slice(); + + let mut reversed_hex = String::with_capacity(UUID_CHARS); + + for block_len in UUID_REVERSING_PATTERN.iter() { + let (block, rest) = hex_chars_slice.split_at(*block_len); + reversed_hex.extend(block.iter().copied().rev()); + hex_chars_slice = rest; + } + + if reversed_hex.len() != 32 || reversed_hex.chars().any(|ch| !UUID_ALPHABET.contains(&ch)) { + return Err(UpdaterError::Uuid { uuid: uuid.to_string() }); + } + + Ok(reversed_hex) +} + +/// Converts reversed hex UUID back to standard Windows Registry format (upper case letters). +/// +/// e.g.: `C3D81328F118D5D4A9287B3CB1707655` => `{82318d3c-811f-4d5d-9a82-b7c31b076755}` +pub fn reversed_hex_to_uuid(mut hex: &str) -> Result { + if hex.len() != 32 || hex.chars().any(|ch| !UUID_ALPHABET.contains(&ch)) { + return Err(UpdaterError::Uuid { uuid: hex.to_string() }); + } + + const FORMATTED_UUID_LEN: usize = UUID_CHARS + + 4 // hyphens + + 2; // braces + + let mut formatted = String::with_capacity(FORMATTED_UUID_LEN); + + formatted.push('{'); + + // Hypen pattern is not same as reversing pattern blocks + const HYPEN_PATTERN: &[usize] = &[1, 1, 1, 0, 1, 0, 0, 0, 0, 0, 0]; + + for (pattern, hypen) in UUID_REVERSING_PATTERN.iter().zip(HYPEN_PATTERN) { + let (part, rest) = hex.split_at(*pattern); + formatted.extend(part.chars().rev()); + + if *hypen == 1 { + formatted.push('-'); + } + hex = rest; + } + + formatted.push('}'); + + Ok(formatted) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_uuid_to_reversed_hex() { + assert_eq!( + uuid_to_reversed_hex("{82318d3c-811f-4d5d-9a82-b7c31b076755}").unwrap(), + "C3D81328F118D5D4A9287B3CB1707655" + ); + } + + #[test] + fn test_format_win_hex_uuid() { + assert_eq!( + reversed_hex_to_uuid("C3D81328F118D5D4A9287B3CB1707655").unwrap(), + "{82318D3C-811F-4D5D-9A82-B7C31B076755}" + ); + } +} diff --git a/package/AgentWindowsManaged/Resources/Includes.cs b/package/AgentWindowsManaged/Resources/Includes.cs index ae719023c..947ba995a 100644 --- a/package/AgentWindowsManaged/Resources/Includes.cs +++ b/package/AgentWindowsManaged/Resources/Includes.cs @@ -35,21 +35,9 @@ internal static class Includes /// Access : /// NT AUTHORITY\SYSTEM Allow FullControl /// NT AUTHORITY\LOCAL SERVICE Allow Write, ReadAndExecute, Synchronize - /// NT AUTHORITY\NETWORK SERVICE Allow Modify, Synchronize /// BUILTIN\Administrators Allow FullControl /// BUILTIN\Users Allow ReadAndExecute, Synchronize /// - internal static string PROGRAM_DATA_SDDL = "O:SYG:SYD:PAI(A;OICI;FA;;;SY)(A;OICI;0x1201bf;;;LS)(A;OICI;0x1301bf;;;NS)(A;OICI;FA;;;BA)(A;OICI;0x1200a9;;;BU)"; - - /// - /// Owner : NT AUTHORITY\SYSTEM - /// Group : NT AUTHORITY\SYSTEM - /// Access : - /// NT AUTHORITY\SYSTEM Allow FullControl - /// NT AUTHORITY\LOCAL SERVICE Allow Write, ReadAndExecute, Synchronize - /// NT AUTHORITY\NETWORK SERVICE Allow Write, ReadAndExecute, Synchronize - /// BUILTIN\Administrators Allow FullControl - /// - internal static string USERS_FILE_SDDL = "O:SYG:SYD:PAI(A;;FA;;;SY)(A;;0x1201bf;;;LS)(A;;0x1201bf;;;NS)(A;;FA;;;BA)"; + internal static string PROGRAM_DATA_SDDL = "O:SYG:SYD:PAI(A;OICI;FA;;;SY)(A;OICI;0x1201bf;;;LS)(A;OICI;FA;;;BA)(A;OICI;0x1200a9;;;BU)"; } }