diff --git a/Cargo.lock b/Cargo.lock index ec3bdc383b..591b8aa3c5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1933,6 +1933,7 @@ version = "2026.3.3" dependencies = [ "agent-channel-client", "agent-identity", + "agent-identity-httpsig", "agent-sysevent-codes", "agent-tunnel-proto", "anyhow", @@ -1987,6 +1988,7 @@ dependencies = [ "tokio-stream", "tonic 0.12.3", "tonic-build", + "tower 0.5.3", "tracing", "url", "uuid", diff --git a/crates/now-package-broker/src/command_builder/winget.rs b/crates/now-package-broker/src/command_builder/winget.rs index 6f8975967b..2b1b9b3003 100644 --- a/crates/now-package-broker/src/command_builder/winget.rs +++ b/crates/now-package-broker/src/command_builder/winget.rs @@ -75,6 +75,10 @@ pub fn build_winget_command(request: &PackageRequest) -> anyhow::Result metadata = 4; +} +message InventoryReport { + repeated InstalledPackage packages = 1; + // Package managers and sources that UniGetUI has on the machine. Each report replaces the previous ones. + repeated PackageManager managers = 2; + repeated PackageSource sources = 3; +} + +message InstalledPackage { + string manager = 1; + string source = 2; + string id = 3; + string name = 4; + string version = 5; + // Newer version reported by the package manager. Empty when the package is up to date. + string available_version = 6; +} + +message PackageManager { + string name = 1; + bool installed = 2; + bool enabled = 3; + string version = 4; +} + +message PackageSource { + string manager = 1; + string name = 2; + string url = 3; +} + +message ServerMessage { + reserved 2; + oneof payload { + SyncInventoryRequest sync_inventory = 1; + PackageOperationRequest package_operation = 3; + Challenge challenge = 4; + CatalogSync catalog_sync = 5; + } +} + +// First server message on the stream. +message Challenge { + string challenge_id = 1; + bytes challenge = 2; +} + +message SyncInventoryRequest {} + +enum PackageOperationType { + PACKAGE_OPERATION_TYPE_INSTALL = 0; + PACKAGE_OPERATION_TYPE_UPDATE = 1; + PACKAGE_OPERATION_TYPE_UNINSTALL = 2; +} + +// Installs, updates or uninstalls one package through UniGetUI. An empty version means the latest version. +message PackageOperationRequest { + string operation_id = 1; + PackageOperationType type = 2; + string manager = 3; + string source = 4; + string package_id = 5; + string version = 6; +} + +// Answer to PackageOperationRequest. Error is set when success is false. +message PackageOperationResult { + string operation_id = 1; + bool success = 2; + string error = 3; +} + +message CatalogSync { + int64 revision = 1; + oneof content { + string document = 2; + bool delete = 3; + } +} + +message CatalogSyncResult { + int64 revision = 1; + bool ok = 2; + string error = 3; +} \ No newline at end of file diff --git a/devolutions-agent/src/config.rs b/devolutions-agent/src/config.rs index 2eb2ff3585..dd2a0fdae2 100644 --- a/devolutions-agent/src/config.rs +++ b/devolutions-agent/src/config.rs @@ -28,6 +28,7 @@ pub struct Conf { pub package_broker: dto::PackageBrokerConf, pub tunnel: TunnelConf, pub psu_agent: Option, + pub device_management: Option, pub proxy: dto::ProxyConf, pub debug: dto::DebugConf, } @@ -287,6 +288,18 @@ impl TryFrom for Option { } } +/// Validated device management configuration. +/// +/// A disabled device management is `None`. The stream goes to the Agent Identity channel URL of the enrolled identity. +#[derive(Debug, Clone)] +pub struct DeviceManagementConf; + +impl From for Option { + fn from(conf: dto::DeviceManagementConf) -> Self { + conf.enabled.then_some(DeviceManagementConf) + } +} + impl Conf { pub fn from_conf_file(conf_file: &dto::ConfFile) -> anyhow::Result { let data_dir = get_data_dir(); @@ -323,6 +336,12 @@ impl Conf { .unwrap_or_default() .pipe(Option::::try_from) .context("invalid PSU agent config")?, + device_management: conf_file + .device_management + .clone() + .unwrap_or_default() + .pipe(Option::::from), + package_broker: conf_file.package_broker.clone().unwrap_or_default(), tunnel: conf_file .tunnel @@ -778,6 +797,15 @@ pub mod dto { } } + /// Device management configuration (Devolutions Server package inventory). + #[derive(PartialEq, Eq, Debug, Clone, Default, Serialize, Deserialize)] + #[serde(rename_all = "PascalCase")] + pub struct DeviceManagementConf { + /// Enable device management. It needs an enrolled Agent Identity. + pub enabled: bool, + } + + #[derive(PartialEq, Eq, Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "PascalCase")] pub struct PsuPowerShellConf { @@ -895,6 +923,10 @@ pub mod dto { #[serde(alias = "PsuGrpcAgent", skip_serializing_if = "Option::is_none")] pub psu_agent: Option, + /// Device management (Devolutions Server package inventory). + #[serde(skip_serializing_if = "Option::is_none")] + pub device_management: Option, + /// HTTP/SOCKS proxy configuration for outbound requests #[serde(skip_serializing_if = "Option::is_none")] pub proxy: Option, @@ -928,6 +960,7 @@ pub mod dto { package_broker: None, tunnel: None, psu_agent: None, + device_management: None, rest: serde_json::Map::new(), } } diff --git a/devolutions-agent/src/device_management/mod.rs b/devolutions-agent/src/device_management/mod.rs new file mode 100644 index 0000000000..ea9ac273c0 --- /dev/null +++ b/devolutions-agent/src/device_management/mod.rs @@ -0,0 +1,514 @@ +//! Device management prototype. +//! +//! The agent keeps a bidirectional gRPC stream open to Devolutions Server, on the Agent Identity channel URL. It +//! authenticates the stream with its Agent Identity: the opening request is signed with the identity key, and the first +//! message answers the server challenge with a proof (same handshake as the Agent Identity channel). Devolutions Server +//! asks for an inventory sync on this stream, and the agent reads the installed packages from the local UniGetUI IPC +//! API. DVLS also sends package operations (install, update, uninstall) on this stream. The agent runs them through +//! UniGetUI, then sends the result and a new inventory. + +mod software_catalog; +mod unigetui; + +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use agent_identity::schedule::{self, ChannelUrl}; +use agent_identity::state::{IDENTITY_KEY_PREFIX, Store}; +use agent_identity::{client, metadata}; +use agent_identity_httpsig::{ChannelLayer, OpeningNonce, RequestSigner}; +use anyhow::{Context as _, anyhow, bail}; +use async_trait::async_trait; +use backoff::backoff::Backoff as _; +use device_key::{DeviceKey, KeyBackend}; +use devolutions_agent_shared::get_data_dir; +use devolutions_gateway_task::{ShutdownSignal, Task}; +use tokio::sync::mpsc; +use tokio_stream::wrappers::ReceiverStream; +use tonic::Request; +use tonic::transport::{Certificate, ClientTlsConfig, Endpoint}; +use tower::Layer as _; +use url::Url; +use uuid::Uuid; + +use crate::config::{Conf, ConfHandle}; +use crate::identity::config::IdentityKeyBackend; + +#[allow(unused_qualifications, clippy::clone_on_ref_ptr, clippy::similar_names)] +pub mod protocol { + tonic::include_proto!("devolutions.device_management.v1"); +} + +use protocol::agent_message::Payload as AgentPayload; +use protocol::device_management_client::DeviceManagementClient; +use protocol::server_message::Payload as ServerPayload; +use protocol::{ + AgentMessage, Hello, InstalledPackage, InventoryReport, PackageOperationRequest, PackageOperationResult, + PackageOperationType, +}; + +const CHANNEL_PROOF_DOMAIN: &[u8] = b"devolutions-agent-identity/v1/channel-proof"; + +pub struct DeviceManagementTask { + conf_handle: ConfHandle, +} + +impl DeviceManagementTask { + pub fn new(conf_handle: ConfHandle) -> Self { + Self { conf_handle } + } +} + +#[async_trait] +impl Task for DeviceManagementTask { + type Output = anyhow::Result<()>; + + const NAME: &'static str = "device management"; + + async fn run(self, shutdown_signal: ShutdownSignal) -> anyhow::Result<()> { + let conf = self.conf_handle.get_conf(); + if conf.device_management.is_none() { + bail!("device management task started but device management is disabled"); + } + DeviceManagementAgent { conf }.run(shutdown_signal).await + } +} + +/// The enrolled Agent Identity that authenticates one device management session. +struct SessionIdentity { + identity_id: Uuid, + url: Url, + key: Arc, + leaf_certificate_der: Vec, +} + +struct DeviceManagementAgent { + conf: Arc, +} + +impl DeviceManagementAgent { + async fn run(self, mut shutdown_signal: ShutdownSignal) -> anyhow::Result<()> { + const RETRY_INITIAL_INTERVAL: Duration = Duration::from_secs(1); + const RETRY_MAX_INTERVAL: Duration = Duration::from_secs(60); + const CONNECTED_THRESHOLD: Duration = Duration::from_secs(30); + + let mut backoff = backoff::ExponentialBackoffBuilder::default() + .with_initial_interval(RETRY_INITIAL_INTERVAL) + .with_max_interval(RETRY_MAX_INTERVAL) + .with_multiplier(2.0) + .with_max_elapsed_time(None) + .build(); + + loop { + let start = Instant::now(); + + match self.run_single_session(&mut shutdown_signal).await { + Ok(()) => return Ok(()), + Err(error) => warn!(error = format!("{error:#}"), "Device management session failed"), + } + + if start.elapsed() > CONNECTED_THRESHOLD { + backoff.reset(); + } + + let wait = backoff.next_backoff().unwrap_or(RETRY_INITIAL_INTERVAL); + info!(?wait, "Reconnecting device management after backoff"); + + tokio::select! { + _ = shutdown_signal.wait() => return Ok(()), + _ = tokio::time::sleep(wait) => {} + } + } + } + + /// Reads the first usable Agent Identity from the store. The identity task owns enrollment and renewal. + fn session_identity(&self) -> anyhow::Result { + let settings = self.conf.debug.identity.clone().unwrap_or_default(); + let data_dir = get_data_dir(); + let backend = match self.conf.identity.key_backend { + IdentityKeyBackend::File => Store::file_backend(&data_dir), + IdentityKeyBackend::WindowsSoftwareProvider => KeyBackend::WindowsSoftwareProvider, + }; + let store = Store::new( + data_dir, + backend, + settings + .key_name_prefix + .clone() + .unwrap_or_else(|| IDENTITY_KEY_PREFIX.to_owned()), + settings.acl_grant_current_user.unwrap_or_default(), + ) + .context("open Agent Identity store")?; + + for identity in store.list_identities().context("list Agent Identities")? { + if identity.rejected.is_some() { + continue; + } + let ChannelUrl::Usable(url) = schedule::channel_url(&identity.config) else { + continue; + }; + let current = &identity.keys.current; + let chain = current + .certificate_chain + .as_deref() + .context("identity key has no certificate chain")?; + let key = device_key::open(store.key_backend(), ¤t.key_name)? + .context("Agent Identity key is missing")?; + return Ok(SessionIdentity { + identity_id: identity.identity_id, + url, + key: Arc::from(key), + leaf_certificate_der: client::leaf_certificate_der(chain)?, + }); + } + + bail!("waiting for an enrolled Agent Identity with a channel URL") + } + + /// Opens the authenticated stream, then serves it until it closes. + async fn run_single_session(&self, shutdown_signal: &mut ShutdownSignal) -> anyhow::Result<()> { + const CONNECT_TIMEOUT: Duration = Duration::from_secs(10); + const CALL_TIMEOUT: Duration = Duration::from_secs(30); + // A half-open connection does not fail by itself. Without pings, the stream read waits forever. + const KEEP_ALIVE_INTERVAL: Duration = Duration::from_secs(30); + const KEEP_ALIVE_TIMEOUT: Duration = Duration::from_secs(10); + + let identity = self.session_identity()?; + let settings = self.conf.debug.identity.clone().unwrap_or_default(); + + let host = identity.url.host_str().context("channel URL without host")?; + let port = identity.url.port_or_known_default().context("channel URL without port")?; + let origin = format!("https://{host}:{port}"); + + let mut tls = ClientTlsConfig::new().with_native_roots(); + if let Some(path) = &settings.extra_trusted_root { + let pem = std::fs::read(path).with_context(|| format!("failed to read {path}"))?; + tls = tls.ca_certificate(Certificate::from_pem(pem)); + } + + let channel = Endpoint::from_shared(origin.clone())? + .tls_config(tls)? + .connect_timeout(CONNECT_TIMEOUT) + .tcp_keepalive(Some(KEEP_ALIVE_INTERVAL)) + .http2_keep_alive_interval(KEEP_ALIVE_INTERVAL) + .keep_alive_timeout(KEEP_ALIVE_TIMEOUT) + .keep_alive_while_idle(true) + .connect() + .await + .with_context(|| format!("failed to connect device management endpoint at {origin}"))?; + let signer = RequestSigner::new(Arc::clone(&identity.key), &identity.leaf_certificate_der); + let mut client = DeviceManagementClient::new(ChannelLayer::new(signer, &identity.url).layer(channel)); + + let (outgoing_tx, outgoing_rx) = mpsc::channel(16); + let (handoff, nonce_rx) = OpeningNonce::new(); + let mut request = Request::new(ReceiverStream::new(outgoing_rx)); + request.extensions_mut().insert(handoff); + + let (mut response_stream, challenge) = tokio::time::timeout(CALL_TIMEOUT, async { + let mut response_stream = client + .connect(request) + .await + .context("failed to open device management stream")? + .into_inner(); + loop { + let message = response_stream + .message() + .await + .context("failed to read device management challenge")? + .context("Devolutions Server closed the stream before the challenge")?; + if let Some(ServerPayload::Challenge(challenge)) = message.payload { + return anyhow::Ok((response_stream, challenge)); + } + } + }) + .await + .context("opening the device management stream timed out")??; + let nonce = nonce_rx.await.context("opening signed without a nonce")?; + + let proof = identity + .key + .try_sign(&channel_proof_input(&challenge.challenge, &nonce)) + .map_err(|error| anyhow!("sign channel proof: {error}"))?; + let hello = Hello { + correlation_id: challenge.challenge_id, + proof: proof.to_bytes().to_vec(), + metadata: metadata::collect(env!("CARGO_PKG_VERSION"), settings.metadata_override_path.as_deref()) + .into_iter() + .collect(), + }; + outgoing_tx + .send(AgentMessage { + payload: Some(AgentPayload::Hello(hello)), + }) + .await + .context("failed to queue hello")?; + + info!(identity_id = %identity.identity_id, url = %identity.url, "Device management stream connected"); + + loop { + tokio::select! { + _ = shutdown_signal.wait() => return Ok(()), + message = response_stream.message() => { + let Some(message) = message.context("failed to read device management message")? else { + bail!("Devolutions Server closed the device management stream"); + }; + + match message.payload { + Some(ServerPayload::SyncInventory(_)) => { + if let Some(report) = collect_inventory().await { + outgoing_tx + .send(AgentMessage { payload: Some(AgentPayload::Inventory(report)) }) + .await + .context("failed to queue inventory report")?; + } + } + Some(ServerPayload::PackageOperation(request)) => { + // An installer can run for minutes. Do not block the stream while it runs. + let outgoing_tx = outgoing_tx.clone(); + tokio::spawn(async move { + let expected = ExpectedPackageState::from_request(&request); + let result = run_package_operation(request).await; + let succeeded = result.success; + if outgoing_tx + .send(AgentMessage { payload: Some(AgentPayload::OperationResult(result)) }) + .await + .is_err() + { + debug!("Device management stream closed before the operation result was sent"); + return; + } + + // The inventory report updates the package list of the machine in DVLS. + if succeeded { + if let Some(expected) = &expected { + wait_for_package_state(expected).await; + } + } + let Some(report) = collect_inventory().await else { + return; + }; + if outgoing_tx + .send(AgentMessage { payload: Some(AgentPayload::Inventory(report)) }) + .await + .is_err() + { + debug!("Device management stream closed before the inventory report was sent"); + } + }); + } + Some(ServerPayload::CatalogSync(request)) => { + let revision = request.revision; + let identity_id = identity.identity_id; + let result = tokio::task::spawn_blocking(move || software_catalog::apply(identity_id, request)) + .await + .context("catalog synchronization worker failed") + .and_then(std::convert::identity); + let error = match &result { + Ok(()) => String::new(), + Err(error) => { + warn!(revision, error = format!("{error:#}"), "Software catalog synchronization failed"); + format!("{error:#}") + } + }; + outgoing_tx + .send(AgentMessage { + payload: Some(AgentPayload::CatalogSyncResult(protocol::CatalogSyncResult { + revision, + ok: result.is_ok(), + error, + })), + }) + .await + .context("failed to queue catalog synchronization result")?; + } + Some(ServerPayload::Challenge(_)) | None => {} + } + } + } + } + } +} + +/// Returns the bytes that the channel proof signs: the domain, a separator, the challenge and the signed opening nonce. +fn channel_proof_input(challenge: &[u8], connect_nonce: &str) -> Vec { + let mut input = Vec::with_capacity(CHANNEL_PROOF_DOMAIN.len() + 1 + challenge.len() + connect_nonce.len()); + input.extend_from_slice(CHANNEL_PROOF_DOMAIN); + input.push(0x00); + input.extend_from_slice(challenge); + input.extend_from_slice(connect_nonce.as_bytes()); + input +} + +const PACKAGE_STATE_POLL_INTERVAL: Duration = Duration::from_secs(3); +const PACKAGE_STATE_MAX_ATTEMPTS: u32 = 10; + +/// The package state that the installed list must show after a successful operation. +struct ExpectedPackageState { + manager: String, + package_id: String, + version: String, + is_installed: bool, +} + +impl ExpectedPackageState { + fn from_request(request: &PackageOperationRequest) -> Option { + let is_installed = match PackageOperationType::try_from(request.r#type).ok()? { + PackageOperationType::Install | PackageOperationType::Update => true, + PackageOperationType::Uninstall => false, + }; + + Some(Self { + manager: request.manager.clone(), + package_id: request.package_id.clone(), + version: request.version.clone(), + is_installed, + }) + } + + fn is_met(&self, packages: &[InstalledPackage]) -> bool { + let package = packages + .iter() + .find(|package| package.manager.eq_ignore_ascii_case(&self.manager) && package.id == self.package_id); + + match package { + Some(package) => self.is_installed && (self.version.is_empty() || package.version == self.version), + None => !self.is_installed, + } + } +} + +/// UniGetUI refreshes its installed list some seconds after an installer exits. During that time, +/// a reinstalled package can be missing or show its old version. +async fn wait_for_package_state(expected: &ExpectedPackageState) { + for attempt in 1..=PACKAGE_STATE_MAX_ATTEMPTS { + match unigetui::installed_packages().await { + Ok(packages) if expected.is_met(&packages) => { + debug!(attempt, package_id = %expected.package_id, "UniGetUI installed list shows the operation result"); + return; + } + Ok(_) => {} + Err(error) => warn!(error = format!("{error:#}"), "Failed to read UniGetUI installed packages"), + } + + tokio::time::sleep(PACKAGE_STATE_POLL_INTERVAL).await; + } + + warn!( + package_id = %expected.package_id, + "UniGetUI installed list does not show the operation result; sending the inventory anyway" + ); +} + +/// Returns `None` when UniGetUI cannot be reached or its managers are not loaded yet, +/// so Devolutions Server keeps the last known inventory. +async fn collect_inventory() -> Option { + let managers = match unigetui::managers().await { + Ok(managers) => managers, + Err(error) => { + warn!( + error = format!("{error:#}"), + "Failed to collect UniGetUI package managers; skipping the inventory report" + ); + return None; + } + }; + + // UniGetUI returns an empty list for a manager that is not ready, for example just after logon or wake. + if !managers.iter().any(|manager| manager.enabled && manager.installed) { + warn!("No UniGetUI package manager is ready; skipping the inventory report"); + return None; + } + + let packages = match unigetui::installed_packages().await { + Ok(packages) => { + info!(count = packages.len(), "Collected UniGetUI inventory"); + packages + } + Err(error) => { + warn!( + error = format!("{error:#}"), + "Failed to collect UniGetUI inventory; skipping the inventory report" + ); + return None; + } + }; + + let sources = unigetui::sources().await.unwrap_or_else(|error| { + warn!( + error = format!("{error:#}"), + "Failed to collect UniGetUI package sources" + ); + Vec::new() + }); + + Some(InventoryReport { + packages, + managers, + sources, + }) +} + +async fn run_package_operation(request: PackageOperationRequest) -> PackageOperationResult { + let operation = match PackageOperationType::try_from(request.r#type) { + Ok(PackageOperationType::Install) => unigetui::PackageOperation::Install, + // `winget upgrade` ignores the version and cannot downgrade. `install --version --force` can. + Ok(PackageOperationType::Update) if !request.version.is_empty() => unigetui::PackageOperation::Install, + Ok(PackageOperationType::Update) => unigetui::PackageOperation::Update, + Ok(PackageOperationType::Uninstall) => unigetui::PackageOperation::Uninstall, + Err(_) => { + return PackageOperationResult { + operation_id: request.operation_id, + success: false, + error: format!("Unknown package operation type {}.", request.r#type), + }; + } + }; + + let started = Instant::now(); + info!( + operation_id = %request.operation_id, + ?operation, + manager = %request.manager, + package_id = %request.package_id, + version = %request.version, + "UniGetUI package operation started" + ); + + let result = unigetui::package_operation( + operation, + &request.manager, + &request.source, + &request.package_id, + &request.version, + ) + .await; + + match result { + Ok(()) => { + info!( + operation_id = %request.operation_id, + elapsed_ms = started.elapsed().as_millis(), + "UniGetUI package operation succeeded" + ); + PackageOperationResult { + operation_id: request.operation_id, + success: true, + error: String::new(), + } + } + Err(error) => { + let error = format!("{error:#}"); + warn!( + operation_id = %request.operation_id, + elapsed_ms = started.elapsed().as_millis(), + %error, + "UniGetUI package operation failed" + ); + PackageOperationResult { + operation_id: request.operation_id, + success: false, + error, + } + } + } +} diff --git a/devolutions-agent/src/device_management/software_catalog.rs b/devolutions-agent/src/device_management/software_catalog.rs new file mode 100644 index 0000000000..0b3527f32e --- /dev/null +++ b/devolutions-agent/src/device_management/software_catalog.rs @@ -0,0 +1,168 @@ +use std::fs; +use std::io::{self, Write as _}; +use std::path::{Path, PathBuf}; + +use anyhow::{Context as _, bail}; +use uuid::Uuid; + +use super::protocol::{CatalogSync, catalog_sync}; + +pub(super) fn apply(identity_id: Uuid, request: CatalogSync) -> anyhow::Result<()> { + if request.revision <= 0 { + bail!("invalid software catalog revision"); + } + + let directory = catalog_directory()?; + #[cfg(any(windows, target_os = "macos"))] + prepare_directory(directory.parent().context("catalog directory has no parent")?)?; + prepare_directory(&directory)?; + let revision_path = directory.join(format!(".devolutions-catalog-{identity_id}.revision")); + match fs::symlink_metadata(&revision_path) { + Ok(metadata) if !metadata.is_file() || is_link(&metadata) => { + bail!("stored catalog revision is not a regular file"); + } + Ok(_) => {} + Err(error) if error.kind() == io::ErrorKind::NotFound => {} + Err(error) => return Err(error).context("inspect stored catalog revision"), + } + let applied = match fs::read_to_string(&revision_path) { + Ok(value) => value.trim().parse::().context("invalid stored catalog revision")?, + Err(error) if error.kind() == io::ErrorKind::NotFound => 0, + Err(error) => return Err(error).context("read stored catalog revision"), + }; + + // UNTESTED: Revisions survive restarts so a delayed snapshot cannot replace a newer catalog; check restart and replay. + if request.revision < applied { + return Ok(()); + } + + let destination = directory.join("SoftwareCatalog.json"); + match request.content.context("catalog synchronization has no content")? { + catalog_sync::Content::Document(document) => { + let parsed: serde_json::Value = serde_json::from_str(&document).context("invalid catalog JSON")?; + if parsed["version"].as_u64() != Some(1) || !parsed["catalogs"].is_array() { + bail!("unsupported software catalog document"); + } + atomic_write(&destination, document.as_bytes())?; + } + catalog_sync::Content::Delete(true) => match fs::remove_file(&destination) { + Ok(()) => {} + Err(error) if error.kind() == io::ErrorKind::NotFound => {} + Err(error) => return Err(error).context("delete software catalog"), + }, + catalog_sync::Content::Delete(false) => bail!("invalid software catalog delete request"), + } + + atomic_write(&revision_path, request.revision.to_string().as_bytes())?; + debug!(revision = request.revision, "Applied software catalog snapshot"); + Ok(()) +} + +fn catalog_directory() -> anyhow::Result { + #[cfg(windows)] + { + let program_data = std::env::var_os("ProgramData").context("ProgramData is not set")?; + Ok(PathBuf::from(program_data).join("Devolutions").join("UniGetUI")) + } + #[cfg(target_os = "macos")] + { + Ok(PathBuf::from("/Library/Application Support/Devolutions/UniGetUI")) + } + #[cfg(target_os = "linux")] + { + Ok(PathBuf::from("/etc/UniGetUI")) + } +} + +fn prepare_directory(path: &Path) -> anyhow::Result<()> { + for ancestor in path.ancestors().skip(1) { + let metadata = fs::symlink_metadata(ancestor).context("inspect software catalog directory ancestor")?; + if !metadata.is_dir() || is_link(&metadata) { + bail!("software catalog directory ancestor is not a regular directory"); + } + } + match fs::symlink_metadata(path) { + Ok(metadata) if !metadata.is_dir() || is_link(&metadata) => { + bail!("catalog directory is not a regular directory"); + } + Ok(_) => {} + Err(error) if error.kind() == io::ErrorKind::NotFound => { + fs::create_dir(path).context("create software catalog directory")?; + } + Err(error) => return Err(error).context("inspect software catalog directory"), + } + #[cfg(unix)] + { + use std::os::unix::fs::{MetadataExt as _, PermissionsExt as _}; + let metadata = fs::metadata(path).context("inspect software catalog directory owner")?; + if metadata.uid() != 0 { + bail!("software catalog directory is not owned by root"); + } + fs::set_permissions(path, fs::Permissions::from_mode(0o755)).context("protect software catalog directory")?; + } + #[cfg(windows)] + { + use win_api_wrappers::identity::sid::Sid; + use win_api_wrappers::raw::Win32::Security::Authorization::SE_FILE_OBJECT; + use win_api_wrappers::raw::Win32::Security::{WinBuiltinAdministratorsSid, WinLocalSystemSid}; + use win_api_wrappers::security::descriptor::SecurityDescriptor; + use win_api_wrappers::str::U16CString; + + let wide_path = U16CString::from_os_str(path.as_os_str()).context("invalid catalog directory path")?; + let descriptor = SecurityDescriptor::of_path(&wide_path, SE_FILE_OBJECT) + .context("inspect software catalog directory owner")?; + let owner = descriptor.owner()?.context("software catalog directory has no owner")?; + let system = Sid::from_well_known(WinLocalSystemSid, None).context("get SYSTEM SID")?; + let administrators = + Sid::from_well_known(WinBuiltinAdministratorsSid, None).context("get Administrators SID")?; + if owner != system && owner != administrators { + bail!("software catalog directory is not owned by SYSTEM or Administrators"); + } + let path = camino::Utf8Path::from_path(path).context("catalog directory is not UTF-8")?; + crate::updater::set_file_dacl(path, "D:PAI(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)(A;OICI;0x1200a9;;;BU)") + .context("protect software catalog directory")?; + } + Ok(()) +} + +fn is_link(metadata: &fs::Metadata) -> bool { + #[cfg(windows)] + { + use std::os::windows::fs::MetadataExt as _; + metadata.file_attributes() & 0x400 != 0 + } + #[cfg(unix)] + { + metadata.file_type().is_symlink() + } +} + +fn atomic_write(destination: &Path, contents: &[u8]) -> anyhow::Result<()> { + let parent = destination.parent().context("catalog path has no parent")?; + let mut temporary = tempfile::NamedTempFile::new_in(parent).context("create temporary catalog file")?; + temporary.write_all(contents).context("write temporary catalog file")?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + temporary + .as_file() + .set_permissions(fs::Permissions::from_mode(0o644)) + .context("set catalog read permissions")?; + } + #[cfg(windows)] + { + let path = camino::Utf8Path::from_path(temporary.path()).context("temporary catalog path is not UTF-8")?; + crate::updater::set_file_dacl(path, "D:PAI(A;;FA;;;SY)(A;;FA;;;BA)(A;;FR;;;BU)") + .context("set catalog read permissions")?; + } + temporary.as_file().sync_all().context("flush temporary catalog file")?; + // UNTESTED: Persist replaces the file atomically on each OS; check readers see only complete JSON during deployment. + temporary + .persist(destination) + .context("replace software catalog file")?; + #[cfg(unix)] + fs::File::open(parent)? + .sync_all() + .context("flush software catalog directory")?; + Ok(()) +} diff --git a/devolutions-agent/src/device_management/unigetui.rs b/devolutions-agent/src/device_management/unigetui.rs new file mode 100644 index 0000000000..66d8ac4c90 --- /dev/null +++ b/devolutions-agent/src/device_management/unigetui.rs @@ -0,0 +1,527 @@ +//! Reads packages, managers and sources from the UniGetUI IPC API running on this machine, and installs, updates and +//! uninstalls packages through it. +//! +//! UniGetUI writes one endpoint registration file per running session under +//! `/UniGetUI/Configuration/IpcApiEndpoints` (`%LOCALAPPDATA%` on Windows, `~/.local/share` on Unix). +//! The agent runs as SYSTEM or root, so it scans every user profile and uses the most recent registration that +//! answers. The named-pipe transport is a Windows named pipe, or a Unix domain socket on Unix. + +use std::path::{Path, PathBuf}; +use std::time::{Duration, Instant}; + +use anyhow::{Context as _, bail}; +use serde::Deserialize; +use serde::de::DeserializeOwned; + +use super::protocol::{InstalledPackage, PackageManager, PackageSource}; + +const INSTALLED_PACKAGES_PATH: &str = "/uniget/v1/packages/installed"; +const UPGRADABLE_PACKAGES_PATH: &str = "/uniget/v1/packages/updates"; +const MANAGERS_PATH: &str = "/uniget/v1/managers"; +const SOURCES_PATH: &str = "/uniget/v1/sources"; +const DEFAULT_PIPE_NAME: &str = "UniGetUI.IPC"; +const REQUEST_TIMEOUT: Duration = Duration::from_secs(60); +// UniGetUI answers a package operation only when the installer is finished. +const OPERATION_TIMEOUT: Duration = Duration::from_secs(30 * 60); + +#[derive(Debug, Clone, Copy)] +enum Method { + Get, + Post, +} + +impl Method { + fn as_str(self) -> &'static str { + match self { + Self::Get => "GET", + Self::Post => "POST", + } + } +} + +#[derive(Debug, Clone, Copy)] +pub(super) enum PackageOperation { + Install, + Update, + Uninstall, +} + +impl PackageOperation { + fn path(self) -> &'static str { + match self { + Self::Install => "/uniget/v1/packages/install", + Self::Update => "/uniget/v1/packages/update", + Self::Uninstall => "/uniget/v1/packages/uninstall", + } + } +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct IpcOperationResult { + #[serde(default)] + status: String, + #[serde(default)] + operation_status: String, + message: Option, + #[serde(default)] + output: Vec, +} + +#[derive(Debug, Clone)] +enum Transport { + Tcp(u16), + NamedPipe(String), +} + +#[derive(Debug, Clone)] +struct Endpoint { + file: PathBuf, + token: String, + transport: Transport, + persisted_at: String, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct Registration { + token: Option, + transport: Option, + tcp_port: Option, + named_pipe_name: Option, + #[cfg_attr(windows, allow(dead_code))] + named_pipe_path: Option, + persisted_at_utc: Option, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct IpcPackage { + #[serde(default)] + name: String, + #[serde(default)] + id: String, + #[serde(default)] + version: String, + #[serde(default)] + source: String, + #[serde(default)] + manager: String, + #[serde(default)] + new_version: String, +} + +impl From for InstalledPackage { + fn from(package: IpcPackage) -> Self { + Self { + manager: package.manager, + source: package.source, + id: package.id, + name: package.name, + version: package.version, + available_version: package.new_version, + } + } +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct IpcManager { + #[serde(default)] + name: String, + #[serde(default)] + enabled: bool, + #[serde(default)] + ready: bool, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct IpcSource { + #[serde(default)] + manager: String, + #[serde(default)] + name: String, + #[serde(default)] + url: String, +} + +pub(super) async fn installed_packages() -> anyhow::Result> { + let packages: Vec = get_json(INSTALLED_PACKAGES_PATH, &[]).await?; + let mut packages: Vec = packages.into_iter().map(Into::into).collect(); + + // The installed list does not include the newer version, so merge it from the updates list. + // A failure here only hides the available versions; the inventory is still valid. + match get_json::>(UPGRADABLE_PACKAGES_PATH, &[]).await { + Ok(upgradable) => merge_available_versions(&mut packages, upgradable), + Err(error) => warn!(error = format!("{error:#}"), "Failed to read UniGetUI upgradable packages"), + } + + Ok(packages) +} + +fn merge_available_versions(packages: &mut [InstalledPackage], upgradable: Vec) { + for update in upgradable.into_iter().filter(|update| !update.new_version.is_empty()) { + let same_package = |package: &InstalledPackage| package.manager == update.manager && package.id == update.id; + // Match the source first; fall back to manager + id when UniGetUI reports a different source name. + let index = packages + .iter() + .position(|package| same_package(package) && package.source == update.source) + .or_else(|| packages.iter().position(|package| same_package(package))); + if let Some(index) = index { + packages[index].available_version = update.new_version; + } + } +} + +/// Runs one package operation and waits for the end of the installer. An empty version means the latest version. +pub(super) async fn package_operation( + operation: PackageOperation, + manager: &str, + source: &str, + package_id: &str, + version: &str, +) -> anyhow::Result<()> { + let mut parameters = vec![("packageId", package_id), ("manager", manager)]; + if !source.is_empty() { + parameters.push(("packageSource", source)); + } + if matches!(operation, PackageOperation::Uninstall) { + // Uninstallers come from the ARP entry, so winget cannot detect a machine-wide uninstaller that needs admin rights. + parameters.push(("elevated", "true")); + } else if !version.is_empty() { + parameters.push(("version", version)); + } + + let result: IpcOperationResult = + request_json(Method::Post, operation.path(), ¶meters, OPERATION_TIMEOUT).await?; + + if result.status.eq_ignore_ascii_case("success") { + return Ok(()); + } + + let detail = result + .message + .filter(|message| !message.is_empty()) + .or_else(|| result.output.last().cloned()) + .unwrap_or_else(|| result.operation_status.clone()); + + // An upgrade of a package that is already on the latest version is not an error. + if matches!(operation, PackageOperation::Update) && detail.contains(WINGET_UPDATE_NOT_APPLICABLE) { + info!(package_id, "Package is already on the latest version"); + return Ok(()); + } + + bail!("UniGetUI operation failed: {detail}") +} + +const WINGET_UPDATE_NOT_APPLICABLE: &str = "0x8A15002B"; + +pub(super) async fn managers() -> anyhow::Result> { + let managers: Vec = get_json(MANAGERS_PATH, &[]).await?; + // The IPC API does not expose the manager version. "Ready" means that UniGetUI found the manager executable. + Ok(managers + .into_iter() + .map(|manager| PackageManager { + name: manager.name, + installed: manager.ready, + enabled: manager.enabled, + version: String::new(), + }) + .collect()) +} + +pub(super) async fn sources() -> anyhow::Result> { + let sources: Vec = get_json(SOURCES_PATH, &[]).await?; + Ok(sources + .into_iter() + .map(|source| PackageSource { + manager: source.manager, + name: source.name, + url: source.url, + }) + .collect()) +} + +/// Sends a GET to the first UniGetUI endpoint that answers. +async fn get_json(path: &str, parameters: &[(&str, &str)]) -> anyhow::Result { + request_json(Method::Get, path, parameters, REQUEST_TIMEOUT).await +} + +/// Sends a request to the first UniGetUI endpoint that answers. +async fn request_json( + method: Method, + path: &str, + parameters: &[(&str, &str)], + timeout: Duration, +) -> anyhow::Result { + let endpoints = discover_endpoints(); + if endpoints.is_empty() { + bail!("no UniGetUI IPC endpoint registration found; is UniGetUI running?"); + } + + let mut last_error = None; + for endpoint in endpoints { + let query = { + let mut serializer = url::form_urlencoded::Serializer::new(String::new()); + serializer.append_pair("token", &endpoint.token); + for (name, value) in parameters { + serializer.append_pair(name, value); + } + serializer.finish() + }; + let path_and_query = format!("{path}?{query}"); + + let started = Instant::now(); + match query_endpoint(&endpoint, method, &path_and_query, timeout).await { + Ok(body) => { + let request_ms = started.elapsed().as_millis(); + let parse_started = Instant::now(); + let result = + serde_json::from_str(&body).with_context(|| format!("invalid UniGetUI response for {path}")); + info!( + path, + transport = ?endpoint.transport, + request_ms, + parse_ms = parse_started.elapsed().as_millis(), + bytes = body.len(), + "UniGetUI IPC request completed" + ); + return result; + } + Err(error) => { + warn!( + path, + file = %endpoint.file.display(), + transport = ?endpoint.transport, + elapsed_ms = started.elapsed().as_millis(), + error = format!("{error:#}"), + "UniGetUI endpoint failed" + ); + last_error = Some(error); + } + } + } + + Err(last_error.unwrap_or_else(|| anyhow::anyhow!("no UniGetUI endpoint answered"))) +} + +async fn query_endpoint( + endpoint: &Endpoint, + method: Method, + path_and_query: &str, + timeout: Duration, +) -> anyhow::Result { + match &endpoint.transport { + Transport::Tcp(port) => { + let url = format!("http://localhost:{port}{path_and_query}"); + let client = reqwest::Client::builder().timeout(timeout).build()?; + let request = match method { + Method::Get => client.get(url), + Method::Post => client.post(url).header(reqwest::header::CONTENT_LENGTH, 0), + }; + let response = request.send().await.context("UniGetUI TCP request failed")?; + let status = response.status(); + let body = response.text().await.context("failed to read UniGetUI response")?; + if !status.is_success() { + bail!("UniGetUI returned {status}: {}", body.trim()); + } + Ok(body) + } + Transport::NamedPipe(name) => tokio::time::timeout(timeout, named_pipe_request(name, method, path_and_query)) + .await + .context("UniGetUI named pipe request timed out")?, + } +} + +fn discover_endpoints() -> Vec { + let mut endpoints: Vec = registration_directories() + .iter() + .filter_map(|dir| std::fs::read_dir(dir).ok()) + .flatten() + .filter_map(Result::ok) + .map(|entry| entry.path()) + .filter(|path| path.extension().is_some_and(|ext| ext.eq_ignore_ascii_case("json"))) + .filter_map(|path| parse_registration(&path)) + .collect(); + + endpoints.sort_by(|a, b| b.persisted_at.cmp(&a.persisted_at)); + endpoints +} + +fn parse_registration(path: &Path) -> Option { + let text = std::fs::read_to_string(path).ok()?; + let registration: Registration = serde_json::from_str(text.trim_start_matches('\u{feff}')).ok()?; + let token = registration.token.filter(|token| !token.is_empty())?; + + let is_tcp = match registration.transport { + Some(serde_json::Value::Number(number)) => number.as_u64() == Some(0), + Some(serde_json::Value::String(value)) => value.eq_ignore_ascii_case("tcp"), + _ => false, + }; + + let transport = if is_tcp { + Transport::Tcp(registration.tcp_port.unwrap_or(7058)) + } else { + let name = registration + .named_pipe_name + .filter(|name| !name.is_empty()) + .unwrap_or_else(|| DEFAULT_PIPE_NAME.to_owned()); + + // On Unix the pipe is a socket file; UniGetUI resolves relative names under /tmp. + #[cfg(unix)] + let name = match registration.named_pipe_path.filter(|path| !path.is_empty()) { + Some(path) => path, + None if name.starts_with('/') => name, + None => format!("/tmp/{name}"), + }; + + Transport::NamedPipe(name) + }; + + Some(Endpoint { + file: path.to_path_buf(), + token, + transport, + persisted_at: registration.persisted_at_utc.unwrap_or_default(), + }) +} + +#[cfg(unix)] +fn registration_directories() -> Vec { + const RELATIVE: [&str; 5] = [".local", "share", "UniGetUI", "Configuration", "IpcApiEndpoints"]; + + let home_roots = if cfg!(target_os = "macos") { "/Users" } else { "/home" }; + let mut homes: Vec = std::fs::read_dir(home_roots) + .into_iter() + .flatten() + .filter_map(Result::ok) + .map(|entry| entry.path()) + .collect(); + homes.push(PathBuf::from("/root")); + + homes + .into_iter() + .map(|home| RELATIVE.iter().fold(home, |acc, part| acc.join(part))) + .collect() +} + +#[cfg(windows)] +fn registration_directories() -> Vec { + const RELATIVE: [&str; 5] = ["AppData", "Local", "UniGetUI", "Configuration", "IpcApiEndpoints"]; + + let mut dirs = Vec::new(); + + if let Some(local_app_data) = std::env::var_os("LOCALAPPDATA") { + dirs.push( + PathBuf::from(local_app_data) + .join("UniGetUI") + .join("Configuration") + .join("IpcApiEndpoints"), + ); + } + + let system_drive = std::env::var("SystemDrive").unwrap_or_else(|_| "C:".to_owned()); + let users_dir = PathBuf::from(format!("{system_drive}\\Users")); + if let Ok(entries) = std::fs::read_dir(users_dir) { + for entry in entries.filter_map(Result::ok) { + let dir = RELATIVE.iter().fold(entry.path(), |acc, part| acc.join(part)); + if !dirs.contains(&dir) { + dirs.push(dir); + } + } + } + + dirs +} + +#[cfg(windows)] +async fn named_pipe_request(pipe_name: &str, method: Method, path_and_query: &str) -> anyhow::Result { + use tokio::net::windows::named_pipe::ClientOptions; + + let pipe_path = format!(r"\\.\pipe\{pipe_name}"); + let pipe = ClientOptions::new() + .open(&pipe_path) + .with_context(|| format!("failed to open {pipe_path}"))?; + + stream_request(pipe, method, path_and_query).await +} + +#[cfg(unix)] +async fn named_pipe_request(socket_path: &str, method: Method, path_and_query: &str) -> anyhow::Result { + let socket = tokio::net::UnixStream::connect(socket_path) + .await + .with_context(|| format!("failed to connect to {socket_path}"))?; + + stream_request(socket, method, path_and_query).await +} + +async fn stream_request(mut pipe: S, method: Method, path_and_query: &str) -> anyhow::Result +where + S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin, +{ + use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _}; + + let request = format!( + "{} {path_and_query} HTTP/1.1\r\nHost: localhost\r\nAccept: application/json\r\nContent-Length: 0\r\nConnection: close\r\n\r\n", + method.as_str() + ); + pipe.write_all(request.as_bytes()) + .await + .context("failed to write request")?; + pipe.flush().await?; + + let mut response = Vec::new(); + // The server may reset the pipe after the response, so keep what was read. + if let Err(error) = pipe.read_to_end(&mut response).await + && response.is_empty() + { + return Err(error).context("failed to read response"); + } + + parse_http_response(&response) +} + +fn parse_http_response(response: &[u8]) -> anyhow::Result { + let header_end = response + .windows(4) + .position(|window| window == b"\r\n\r\n") + .context("malformed HTTP response")?; + let head = String::from_utf8_lossy(&response[..header_end]); + let body = &response[header_end + 4..]; + + let mut lines = head.lines(); + let status_line = lines.next().unwrap_or_default(); + let status = status_line.split_whitespace().nth(1).unwrap_or_default(); + let chunked = lines.any(|line| { + let lower = line.to_ascii_lowercase(); + lower.starts_with("transfer-encoding:") && lower.contains("chunked") + }); + + let body = if chunked { decode_chunked(body)? } else { body.to_vec() }; + let body = String::from_utf8(body).context("UniGetUI response is not UTF-8")?; + if !status.starts_with('2') { + bail!("UniGetUI returned {status_line}: {}", body.trim()); + } + Ok(body) +} + +fn decode_chunked(mut input: &[u8]) -> anyhow::Result> { + let mut output = Vec::new(); + loop { + let line_end = input + .windows(2) + .position(|window| window == b"\r\n") + .context("malformed chunk header")?; + let size_text = String::from_utf8_lossy(&input[..line_end]); + let size_text = size_text.split(';').next().unwrap_or_default().trim(); + let size = usize::from_str_radix(size_text, 16).context("invalid chunk size")?; + input = &input[line_end + 2..]; + if size == 0 { + return Ok(output); + } + let chunk = input.get(..size).context("truncated chunk")?; + output.extend_from_slice(chunk); + input = input.get(size + 2..).unwrap_or_default(); + } +} diff --git a/devolutions-agent/src/lib.rs b/devolutions-agent/src/lib.rs index 699d681cb2..5a115c917e 100644 --- a/devolutions-agent/src/lib.rs +++ b/devolutions-agent/src/lib.rs @@ -10,6 +10,7 @@ use std::sync::{Arc, LazyLock}; use sysevent::SystemEventSink; pub mod config; +pub mod device_management; pub mod domain_detect; pub mod enrollment; pub mod identity; diff --git a/devolutions-agent/src/main.rs b/devolutions-agent/src/main.rs index 9c40857965..44fec52c06 100644 --- a/devolutions-agent/src/main.rs +++ b/devolutions-agent/src/main.rs @@ -58,6 +58,9 @@ fn agent_service_main( _args: Vec, _standalone_mode: bool, ) -> u8 { + // Both ring and aws-lc-rs are in the dependency graph, so rustls cannot pick a process default by itself. + let _ = rustls::crypto::ring::default_provider().install_default(); + let Ok(conf_handle) = ConfHandle::init() else { // At this point, the logger is not yet initialized. return BAD_CONFIG_ERR_CODE; diff --git a/devolutions-agent/src/service.rs b/devolutions-agent/src/service.rs index 4f0b846974..98453a6469 100644 --- a/devolutions-agent/src/service.rs +++ b/devolutions-agent/src/service.rs @@ -3,6 +3,7 @@ use std::time::Duration; use anyhow::Context; use devolutions_agent::AgentServiceEvent; use devolutions_agent::config::ConfHandle; +use devolutions_agent::device_management::DeviceManagementTask; use devolutions_agent::identity::IdentityTask; use devolutions_agent::log::AgentLog; use devolutions_agent::psu_agent::PsuAgentTask; @@ -259,6 +260,10 @@ async fn spawn_tasks(conf_handle: ConfHandle) -> anyhow::Result { tasks.register(TunnelTask::new(conf_handle.clone())); } + if conf.device_management.is_some() { + tasks.register(DeviceManagementTask::new(conf_handle.clone())); + } + if conf.psu_agent.is_some() { tasks.register(PsuAgentTask::new(conf_handle)); } diff --git a/devolutions-agent/src/updater/mod.rs b/devolutions-agent/src/updater/mod.rs index 0e3f3d00ec..3f3420f6c0 100644 --- a/devolutions-agent/src/updater/mod.rs +++ b/devolutions-agent/src/updater/mod.rs @@ -43,7 +43,7 @@ use self::package::{install_package, uninstall_package, validate_package}; pub(crate) use self::product::Product; use self::product_actions::{ProductUpdateActions, build_product_actions}; use self::productinfo::DEVOLUTIONS_PRODUCTINFO_URL; -use self::security::set_file_dacl; +pub(crate) use self::security::set_file_dacl; use self::service_account::{GatewayServiceAccount, NETWORK_SERVICE_SID}; use crate::config::ConfHandle; use crate::config::dto::UpdaterSchedule; diff --git a/devolutions-agent/src/updater/security.rs b/devolutions-agent/src/updater/security.rs index 2a2f66b88f..13e6c4fb09 100644 --- a/devolutions-agent/src/updater/security.rs +++ b/devolutions-agent/src/updater/security.rs @@ -41,7 +41,10 @@ pub(crate) fn set_file_dacl(file_path: &Utf8Path, acl: &str) -> Result<(), Updat use windows::Win32::Security::Authorization::{ ConvertStringSecurityDescriptorToSecurityDescriptorW, SDDL_REVISION_1, SE_FILE_OBJECT, SetNamedSecurityInfoW, }; - use windows::Win32::Security::{ACL, DACL_SECURITY_INFORMATION, GetSecurityDescriptorDacl, PSECURITY_DESCRIPTOR}; + use windows::Win32::Security::{ + ACL, DACL_SECURITY_INFORMATION, GetSecurityDescriptorDacl, PROTECTED_DACL_SECURITY_INFORMATION, + PSECURITY_DESCRIPTOR, + }; struct OwnedPSecurityDescriptor(PSECURITY_DESCRIPTOR); @@ -104,7 +107,7 @@ pub(crate) fn set_file_dacl(file_path: &Utf8Path, acl: &str) -> Result<(), Updat SetNamedSecurityInfoW( wide_file_path.as_pcwstr(), SE_FILE_OBJECT, - DACL_SECURITY_INFORMATION, + DACL_SECURITY_INFORMATION | PROTECTED_DACL_SECURITY_INFORMATION, None, None, Some(dacl), diff --git a/package/AgentWindowsManaged/Actions/CustomActions.cs b/package/AgentWindowsManaged/Actions/CustomActions.cs index 0c23053cd6..1bbe9f03e9 100644 --- a/package/AgentWindowsManaged/Actions/CustomActions.cs +++ b/package/AgentWindowsManaged/Actions/CustomActions.cs @@ -1,4 +1,4 @@ -using DevolutionsAgent.Helpers; +using DevolutionsAgent.Helpers; using DevolutionsAgent.Properties; using DevolutionsAgent.Resources; using Microsoft.Deployment.WindowsInstaller; @@ -1147,7 +1147,9 @@ public static ActionResult ConfigureFeatures(Session session) } } - return ActionResult.Success; + // Device management is part of the PEDM feature. + bool pedmEnabled = enabledFeatures.Contains(Features.PEDM_FEATURE.Id.Substring(Features.FEATURE_ID_PREFIX.Length)); + return ToggleAgentFeature(session, "DeviceManagement", pedmEnabled); } [CustomAction] diff --git a/package/install/Start-DevInstallServer.ps1 b/package/install/Start-DevInstallServer.ps1 new file mode 100644 index 0000000000..d75c717eb0 --- /dev/null +++ b/package/install/Start-DevInstallServer.ps1 @@ -0,0 +1,92 @@ +<# +.SYNOPSIS + Serves a locally built Devolutions Agent and the install scripts to test machines. Development only. + +.DESCRIPTION + Copies the agent builds, the optional test root CA and the install scripts into a staging folder, sets the + development URL in the served scripts, then starts an HTTP server. On a test machine, run the command that + Remote Desktop Manager copies, with the script URL changed to this server: + + Windows: $env:DEVOLUTIONS_ENROLLMENT_TOKEN='dvaet1...'; irm http://:/install-agent.ps1 | iex + Linux: curl -fsSL http://:/install-agent.sh | sudo DEVOLUTIONS_ENROLLMENT_TOKEN='dvaet1...' sh + + The served scripts install the local builds instead of the published MSI or .deb package. The published + scripts are not changed and keep the latest version from productinfo.json. + +.EXAMPLE + .\Start-DevInstallServer.ps1 -HostAddress 100.95.53.36 -LinuxAgent 'D:\Shared\linux\devolutions-agent' -CaPem .\dvls-ca.pem +#> +param( + # The address that the test machines use to reach this computer. Default: the first IPv4 address that is not loopback. + [string] $HostAddress, + + [int] $Port = 5002, + + [string] $WindowsAgent = (Join-Path $PSScriptRoot '..\..\target\release\devolutions-agent.exe'), + + # A Linux x64 build of devolutions-agent, for example from cargo zigbuild. Optional. + [string] $LinuxAgent, + + # The root CA of a test Devolutions Server certificate. The agent trusts it in addition to the system store. Optional. + [string] $CaPem, + + # A UniGetUI installer for reinstall-unigetui.ps1, for example from UniGetUI scripts\build.ps1. Optional. + [string] $UniGetUIInstaller, + + [string] $StagingPath = (Join-Path ([IO.Path]::GetTempPath()) 'devolutions-agent-dev') +) + +$ErrorActionPreference = 'Stop' + +if (-not $HostAddress) { + $HostAddress = Get-NetIPAddress -AddressFamily IPv4 | + Where-Object { $_.IPAddress -notlike '127.*' -and $_.IPAddress -notlike '169.254.*' } | + Select-Object -First 1 -ExpandProperty IPAddress +} + +$devUrl = "http://${HostAddress}:$Port" + +if (Test-Path $StagingPath) { + Remove-Item -Path $StagingPath -Recurse -Force +} +New-Item -ItemType Directory -Path $StagingPath | Out-Null + +if (Test-Path $WindowsAgent) { + Copy-Item -Path $WindowsAgent -Destination (Join-Path $StagingPath 'DevolutionsAgent.exe') +} +else { + Write-Warning "No Windows agent at $WindowsAgent. Windows machines cannot install." +} + +if ($LinuxAgent) { + Copy-Item -Path $LinuxAgent -Destination (Join-Path $StagingPath 'devolutions-agent') +} + +if ($CaPem) { + Copy-Item -Path $CaPem -Destination (Join-Path $StagingPath 'dvls-ca.pem') +} + +$ps1 = [IO.File]::ReadAllText((Join-Path $PSScriptRoot 'install-agent.ps1')) +$ps1 = [regex]::Replace($ps1, "(?m)^(\s*)\`$DevUrl = ''", "`${1}`$`$DevUrl = '$devUrl'") +[IO.File]::WriteAllText((Join-Path $StagingPath 'install-agent.ps1'), $ps1) + +$sh = [IO.File]::ReadAllText((Join-Path $PSScriptRoot 'install-agent.sh')).Replace("`r`n", "`n") +$sh = [regex]::Replace($sh, "(?m)^DEV_URL=''", "DEV_URL='$devUrl'") +[IO.File]::WriteAllText((Join-Path $StagingPath 'install-agent.sh'), $sh) + +Copy-Item -Path (Join-Path $PSScriptRoot 'uninstall-agent.ps1') -Destination $StagingPath +$uninstallSh = [IO.File]::ReadAllText((Join-Path $PSScriptRoot 'uninstall-agent.sh')).Replace("`r`n", "`n") +[IO.File]::WriteAllText((Join-Path $StagingPath 'uninstall-agent.sh'), $uninstallSh) + +if ($UniGetUIInstaller) { + Copy-Item -Path $UniGetUIInstaller -Destination (Join-Path $StagingPath 'UniGetUI.Installer.x64.exe') + $reinstall = [IO.File]::ReadAllText((Join-Path $PSScriptRoot 'reinstall-unigetui.ps1')) + $reinstall = [regex]::Replace($reinstall, "(?m)^(\s*)\`$DevUrl = ''", "`${1}`$`$DevUrl = '$devUrl'") + [IO.File]::WriteAllText((Join-Path $StagingPath 'reinstall-unigetui.ps1'), $reinstall) +} + +Write-Host "Serving $StagingPath at $devUrl" -ForegroundColor Cyan +Get-ChildItem -Path $StagingPath | ForEach-Object { Write-Host " $devUrl/$($_.Name)" } +Write-Host 'Press Ctrl+C to stop.' + +python -m http.server $Port --bind 0.0.0.0 --directory $StagingPath diff --git a/package/install/install-agent.ps1 b/package/install/install-agent.ps1 new file mode 100644 index 0000000000..d13160965d --- /dev/null +++ b/package/install/install-agent.ps1 @@ -0,0 +1,241 @@ +# Installs Devolutions Agent, turns on Device Management and enrolls the device with Devolutions Server. +# +# Usage (PowerShell as administrator): +# $env:DEVOLUTIONS_ENROLLMENT_TOKEN='dvaet1...'; irm https://raw.githubusercontent.com/Devolutions/devolutions-gateway/master/package/install/install-agent.ps1 | iex +# +# Optional environment variables: +# DEVOLUTIONS_AGENT_INSTALLER_URL Installs this MSI instead of the latest published one. +# DEVOLUTIONS_AGENT_INSTALLER_SHA256 Expected SHA-256 of the MSI set by DEVOLUTIONS_AGENT_INSTALLER_URL. +# DEVOLUTIONS_AGENT_DEV_URL Development only. Base URL of a server that gives a locally built agent +# (DevolutionsAgent.exe) and, optionally, the test root CA of the server (dvls-ca.pem). +# See Start-DevInstallServer.ps1. No MSI is installed in this mode. +# +# The script has no param block so that it also runs through `irm | iex`. It never writes the token. + +& { + $ErrorActionPreference = 'Stop' + $ProgressPreference = 'SilentlyContinue' + [Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12 + + $ProductInfoUrl = 'https://devolutions.net/productinfo.json' + $ServiceName = 'DevolutionsAgent' + + # Start-DevInstallServer.ps1 sets this value in the copy of the script that it serves. + $DevUrl = '' + if ($env:DEVOLUTIONS_AGENT_DEV_URL) { + $DevUrl = $env:DEVOLUTIONS_AGENT_DEV_URL + } + $DevUrl = $DevUrl.TrimEnd('/') + + function Stop-Install([string] $Message) { + Remove-Item Env:\DEVOLUTIONS_ENROLLMENT_TOKEN -ErrorAction SilentlyContinue + throw "Devolutions Agent installation failed: $Message" + } + + $principal = [Security.Principal.WindowsPrincipal]::new([Security.Principal.WindowsIdentity]::GetCurrent()) + if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { + Stop-Install 'run this command in PowerShell as administrator.' + } + + $token = $env:DEVOLUTIONS_ENROLLMENT_TOKEN + if ([string]::IsNullOrWhiteSpace($token) -or -not $token.Trim().StartsWith('dvaet1.')) { + Stop-Install 'set DEVOLUTIONS_ENROLLMENT_TOKEN to the enrollment token from Remote Desktop Manager.' + } + + $configDir = Join-Path $env:ProgramData 'Devolutions\Agent' + $configPath = Join-Path $configDir 'agent.json' + $devCaFile = $null + + if ($DevUrl) { + Write-Warning "Development mode: installing the Devolutions Agent build from $DevUrl." + $installDir = Join-Path $env:ProgramFiles 'Devolutions\Agent' + $agentExe = Join-Path $installDir 'DevolutionsAgent.exe' + $downloadPath = Join-Path ([IO.Path]::GetTempPath()) ("DevolutionsAgent-{0}.exe" -f [guid]::NewGuid().ToString('N')) + try { + Write-Host "Downloading $DevUrl/DevolutionsAgent.exe..." + Invoke-WebRequest -Uri "$DevUrl/DevolutionsAgent.exe" -OutFile $downloadPath -UseBasicParsing + + Write-Host 'Installing Devolutions Agent...' + $service = Get-Service -Name $ServiceName -ErrorAction SilentlyContinue + if ($service -and $service.Status -ne 'Stopped') { + Stop-Service -Name $ServiceName -Force + $service.WaitForStatus('Stopped', '00:00:30') + } + Get-Process -Name 'DevolutionsAgent' -ErrorAction SilentlyContinue | Stop-Process -Force + + New-Item -ItemType Directory -Path $installDir -Force | Out-Null + Get-ChildItem -Path "$agentExe.old*" -ErrorAction SilentlyContinue | Remove-Item -Force -ErrorAction SilentlyContinue + # Windows can lock the executable after the process stops. A locked file cannot be overwritten, but it can be renamed. + if (Test-Path $agentExe) { + Move-Item -Path $agentExe -Destination "$agentExe.old$([DateTime]::Now.Ticks)" -Force + } + Copy-Item -Path $downloadPath -Destination $agentExe -Force + + if (-not $service) { + New-Service -Name $ServiceName -BinaryPathName "`"$agentExe`"" -DisplayName 'Devolutions Agent' -StartupType Automatic | Out-Null + } + } + finally { + Remove-Item -Path $downloadPath -Force -ErrorAction SilentlyContinue + } + + New-Item -ItemType Directory -Path $configDir -Force | Out-Null + try { + Invoke-WebRequest -Uri "$DevUrl/dvls-ca.pem" -OutFile (Join-Path $configDir 'dvls-ca.pem') -UseBasicParsing + $devCaFile = 'dvls-ca.pem' + Write-Host 'The agent trusts the test root CA from the development server.' + } + catch { + Write-Host 'The development server gives no test root CA. The agent uses the system trust store.' + } + } + else { + $installerUrl = $env:DEVOLUTIONS_AGENT_INSTALLER_URL + $expectedHash = $env:DEVOLUTIONS_AGENT_INSTALLER_SHA256 + if ([string]::IsNullOrWhiteSpace($installerUrl)) { + $architecture = if ($env:PROCESSOR_ARCHITEW6432) { $env:PROCESSOR_ARCHITEW6432 } else { $env:PROCESSOR_ARCHITECTURE } + $arch = switch ($architecture) { + 'AMD64' { 'x64' } + 'ARM64' { 'arm64' } + default { Stop-Install "the $architecture architecture is not supported." } + } + + Write-Host 'Reading the latest Devolutions Agent version...' + $productInfo = Invoke-RestMethod -Uri $ProductInfoUrl -UseBasicParsing + $file = $productInfo.Agent.Current.Files | Where-Object { $_.Type -eq 'msi' -and $_.Arch -eq $arch } | Select-Object -First 1 + if (-not $file) { + Stop-Install "no Devolutions Agent installer is available for Windows $arch." + } + + $installerUrl = $file.Url + $expectedHash = $file.Hash + } + + $msiPath = Join-Path ([IO.Path]::GetTempPath()) ("DevolutionsAgent-{0}.msi" -f [guid]::NewGuid().ToString('N')) + try { + Write-Host "Downloading $installerUrl..." + Invoke-WebRequest -Uri $installerUrl -OutFile $msiPath -UseBasicParsing + + if (-not [string]::IsNullOrWhiteSpace($expectedHash)) { + $actualHash = (Get-FileHash -Path $msiPath -Algorithm SHA256).Hash + if ($actualHash -ne $expectedHash.Trim().ToUpperInvariant()) { + Stop-Install 'the SHA-256 hash of the downloaded installer is not correct.' + } + } + else { + Write-Warning 'The installer hash is not checked because DEVOLUTIONS_AGENT_INSTALLER_SHA256 is not set.' + } + + Write-Host 'Installing Devolutions Agent...' + $logPath = Join-Path ([IO.Path]::GetTempPath()) 'DevolutionsAgent-install.log' + $process = Start-Process -FilePath 'msiexec.exe' -ArgumentList @('/i', "`"$msiPath`"", '/qn', '/norestart', '/l*v', "`"$logPath`"") -Wait -PassThru + if ($process.ExitCode -notin @(0, 3010)) { + Stop-Install "msiexec stopped with exit code $($process.ExitCode). See $logPath." + } + } + finally { + Remove-Item -Path $msiPath -Force -ErrorAction SilentlyContinue + } + + $installDir = (Get-ItemProperty -Path 'HKLM:\SOFTWARE\Devolutions\Agent' -Name 'InstallDir' -ErrorAction SilentlyContinue).InstallDir + if (-not $installDir) { + $installDir = Join-Path $env:ProgramFiles 'Devolutions\Agent' + } + + $agentExe = Join-Path $installDir 'DevolutionsAgent.exe' + } + + if (-not (Test-Path $agentExe)) { + Stop-Install "$agentExe is missing after the installation." + } + + if (-not (Test-Path $configPath)) { + & $agentExe config init | Out-Null + } + + Write-Host 'Turning on Device Management...' + $config = $null + if (Test-Path $configPath) { + $config = Get-Content -Path $configPath -Raw | ConvertFrom-Json + } + + if (-not $config) { + $config = [pscustomobject]@{} + } + + $config | Add-Member -NotePropertyName 'DeviceManagement' -NotePropertyValue ([pscustomobject]@{ Enabled = $true }) -Force + if ($DevUrl) { + # Development builds of UniGetUI are not signed, so the broker does not validate the caller signature. + $config | Add-Member -NotePropertyName 'PackageBroker' -NotePropertyValue ([pscustomobject]@{ Enabled = $true }) -Force + $debug = [pscustomobject]@{ skip_broker_signature_validation = $true } + if ($devCaFile) { + $debug | Add-Member -NotePropertyName 'identity' -NotePropertyValue ([pscustomobject]@{ extra_trusted_root = $devCaFile }) + } + $config | Add-Member -NotePropertyName '__debug__' -NotePropertyValue $debug -Force + } + + New-Item -ItemType Directory -Path $configDir -Force | Out-Null + [IO.File]::WriteAllText($configPath, ($config | ConvertTo-Json -Depth 32), [Text.UTF8Encoding]::new($false)) + + if ($DevUrl) { + $policyPath = Join-Path $env:ProgramData 'Devolutions\PackageBroker\package-broker-policy.json' + $policyDir = Split-Path $policyPath + New-Item -ItemType Directory -Path $policyDir -Force | Out-Null + # The broker refuses a policy folder that inherits the ProgramData ACL, because users can create files there. + icacls $policyDir /inheritance:r /grant:r '*S-1-5-18:(OI)(CI)F' '*S-1-5-32-544:(OI)(CI)F' '*S-1-5-32-545:(OI)(CI)RX' | Out-Null + icacls $policyDir /setowner '*S-1-5-32-544' /T | Out-Null + if (-not (Test-Path $policyPath)) { + [IO.File]::WriteAllText($policyPath, '{"PolicyFormatVersion":"1.0.0","Metadata":{"Id":"dev.allow-all","Publisher":"Development","Revision":1,"PublishedAt":"2026-10-21T00:00:00Z","Description":"Allow all package operations."},"Enforcement":{"DefaultDecision":"Allow"},"Rules":[]}') + } + } + + # The service creates the identity drop box when it starts, and enrollment needs it. + $service = Get-Service -Name $ServiceName -ErrorAction SilentlyContinue + if (-not $service) { + Stop-Install "the $ServiceName service is missing." + } + + if ($service.Status -ne 'Running') { + Start-Service -Name $ServiceName + $service.WaitForStatus('Running', '00:00:30') + } + + Write-Host 'Enrolling the device...' + $deadline = [DateTime]::UtcNow.AddSeconds(60) + do { + # Windows PowerShell turns native stderr into a terminating error when the preference is Stop. + $ErrorActionPreference = 'Continue' + $enrollOutput = $token.Trim() | & $agentExe identity enroll - 2>&1 + $enrollExitCode = $LASTEXITCODE + $ErrorActionPreference = 'Stop' + $notReady = $enrollExitCode -ne 0 -and ($enrollOutput | Out-String) -match 'has not prepared the enrollment directory' + if ($notReady) { + Start-Sleep -Seconds 2 + } + } while ($notReady -and [DateTime]::UtcNow -lt $deadline) + + if ($enrollExitCode -ne 0) { + $enrollOutput | ForEach-Object { Write-Host $_ } + } + $token = $null + Remove-Item Env:\DEVOLUTIONS_ENROLLMENT_TOKEN -ErrorAction SilentlyContinue + if ($enrollExitCode -ne 0) { + Stop-Install "the enrollment command stopped with exit code $enrollExitCode." + } + + Write-Host 'Restarting the Devolutions Agent service...' + $service = Get-Service -Name $ServiceName -ErrorAction SilentlyContinue + if (-not $service) { + Stop-Install "the $ServiceName service is missing." + } + + Set-Service -Name $ServiceName -StartupType Automatic + if ($service.Status -eq 'Running') { + Restart-Service -Name $ServiceName -Force + } + else { + Start-Service -Name $ServiceName + } + + Write-Host 'Devolutions Agent is installed. The device enrolls with Devolutions Server in a few seconds.' -ForegroundColor Green +} diff --git a/package/install/install-agent.sh b/package/install/install-agent.sh new file mode 100644 index 0000000000..efbea0fdec --- /dev/null +++ b/package/install/install-agent.sh @@ -0,0 +1,206 @@ +#!/bin/sh +# Installs Devolutions Agent, turns on Device Management and enrolls the device with Devolutions Server. +# +# Usage: +# curl -fsSL https://raw.githubusercontent.com/Devolutions/devolutions-gateway/master/package/install/install-agent.sh | sudo DEVOLUTIONS_ENROLLMENT_TOKEN='dvaet1...' sh +# +# Optional environment variables: +# DEVOLUTIONS_AGENT_INSTALLER_URL Installs this .deb package instead of the latest published one. +# DEVOLUTIONS_AGENT_INSTALLER_SHA256 Expected SHA-256 of the package set by DEVOLUTIONS_AGENT_INSTALLER_URL. +# DEVOLUTIONS_AGENT_DEV_URL Development only. Base URL of a server that gives a locally built agent +# (devolutions-agent) and, optionally, the test root CA of the server (dvls-ca.pem). +# See Start-DevInstallServer.ps1. No package is installed in this mode. +# +# Only Debian-based x64 distributions are supported. The script never writes the token. + +set -eu + +PRODUCT_INFO_URL='https://devolutions.net/productinfo.json' +AGENT_BIN='/usr/bin/devolutions-agent' +CONFIG_DIR='/etc/devolutions-agent' +CONFIG_PATH="$CONFIG_DIR/agent.json" +SERVICE_NAME='devolutions-agent' + +# Start-DevInstallServer.ps1 sets this value in the copy of the script that it serves. +DEV_URL='' +DEV_URL="${DEVOLUTIONS_AGENT_DEV_URL:-$DEV_URL}" +DEV_URL="${DEV_URL%/}" +DEV_CA_FILE='' + +fail() { + unset DEVOLUTIONS_ENROLLMENT_TOKEN + printf 'Devolutions Agent installation failed: %s\n' "$1" >&2 + exit 1 +} + +info() { + printf '%s\n' "$1" +} + +download() { + if command -v curl >/dev/null 2>&1; then + curl -fsSL -o "$2" "$1" + elif command -v wget >/dev/null 2>&1; then + wget -q -O "$2" "$1" + else + fail 'curl or wget is required.' + fi +} + +[ "$(id -u)" -eq 0 ] || fail 'run this script as root, for example with sudo.' + +TOKEN="${DEVOLUTIONS_ENROLLMENT_TOKEN:-}" +case "$TOKEN" in + dvaet1.*) ;; + *) fail 'set DEVOLUTIONS_ENROLLMENT_TOKEN to the enrollment token from Remote Desktop Manager.' ;; +esac + +[ "$(uname -s)" = 'Linux' ] || fail 'only Linux is supported.' + +case "$(uname -m)" in + x86_64 | amd64) ;; + aarch64 | arm64) fail 'arm64 is not supported yet. Use an x64 device.' ;; + *) fail "the $(uname -m) architecture is not supported." ;; +esac + +if [ -z "$DEV_URL" ] && ! command -v dpkg >/dev/null 2>&1; then + if command -v rpm >/dev/null 2>&1; then + fail 'RPM-based distributions are not supported yet. Use a Debian-based distribution such as Debian or Ubuntu.' + fi + fail 'dpkg is missing. Only Debian-based distributions are supported.' +fi + +INSTALLER_URL="${DEVOLUTIONS_AGENT_INSTALLER_URL:-}" +EXPECTED_HASH="${DEVOLUTIONS_AGENT_INSTALLER_SHA256:-}" +WORK_DIR="$(mktemp -d)" +trap 'rm -rf "$WORK_DIR"' EXIT INT TERM + +if [ -n "$DEV_URL" ]; then + info "Warning: development mode. Installing the Devolutions Agent build from $DEV_URL." >&2 + command -v python3 >/dev/null 2>&1 || fail 'python3 is required in development mode.' + info "Downloading $DEV_URL/devolutions-agent..." + download "$DEV_URL/devolutions-agent" "$WORK_DIR/devolutions-agent" + + info 'Installing Devolutions Agent...' + systemctl stop "$SERVICE_NAME" 2>/dev/null || true + install -m 755 "$WORK_DIR/devolutions-agent" "$AGENT_BIN" + if [ ! -f "/etc/systemd/system/$SERVICE_NAME.service" ] && [ ! -f "/lib/systemd/system/$SERVICE_NAME.service" ]; then + cat > "/etc/systemd/system/$SERVICE_NAME.service" <<'EOF' +[Unit] +Description=Devolutions Agent +After=network-online.target +Wants=network-online.target + +[Service] +ExecStart=/usr/bin/devolutions-agent run +KillSignal=SIGINT +Restart=on-failure +RestartSec=5 + +[Install] +WantedBy=multi-user.target +EOF + systemctl daemon-reload + fi + + mkdir -p "$CONFIG_DIR" + if download "$DEV_URL/dvls-ca.pem" "$WORK_DIR/dvls-ca.pem" 2>/dev/null; then + install -m 644 "$WORK_DIR/dvls-ca.pem" "$CONFIG_DIR/dvls-ca.pem" + DEV_CA_FILE='dvls-ca.pem' + info 'The agent trusts the test root CA from the development server.' + else + info 'The development server gives no test root CA. The agent uses the system trust store.' + fi +else +if [ -z "$INSTALLER_URL" ]; then + info 'Reading the latest Devolutions Agent version...' + download "$PRODUCT_INFO_URL" "$WORK_DIR/productinfo.json" + + # Prints the URL and the hash of the first x64 .deb in Agent.Current, which comes before Agent.Beta. + FILE_INFO="$(tr '{},' '\n\n\n' < "$WORK_DIR/productinfo.json" | awk ' + /"Agent"[[:space:]]*:/ { agent = 1 } + agent && /"Url"[[:space:]]*:/ && /devolutions-agent-[^"]*-x64\.deb"/ { url = $0 } + agent && url != "" && /"Hash"[[:space:]]*:/ { hash = $0; exit } + END { print url; print hash } + ' | sed -n 's/.*:[[:space:]]*"\(.*\)".*/\1/p')" + INSTALLER_URL="$(printf '%s\n' "$FILE_INFO" | sed -n '1p')" + EXPECTED_HASH="$(printf '%s\n' "$FILE_INFO" | sed -n '2p')" + [ -n "$INSTALLER_URL" ] || fail 'no Devolutions Agent package is available for Linux x64.' +fi + +PACKAGE_PATH="$WORK_DIR/devolutions-agent.deb" +info "Downloading $INSTALLER_URL..." +download "$INSTALLER_URL" "$PACKAGE_PATH" + +if [ -n "$EXPECTED_HASH" ]; then + command -v sha256sum >/dev/null 2>&1 || fail 'sha256sum is required to check the package.' + ACTUAL_HASH="$(sha256sum "$PACKAGE_PATH" | cut -d ' ' -f 1 | tr 'A-F' 'a-f')" + EXPECTED_HASH="$(printf '%s' "$EXPECTED_HASH" | tr 'A-F' 'a-f')" + [ "$ACTUAL_HASH" = "$EXPECTED_HASH" ] || fail 'the SHA-256 hash of the downloaded package is not correct.' +else + info 'Warning: the package hash is not checked because DEVOLUTIONS_AGENT_INSTALLER_SHA256 is not set.' >&2 +fi + +info 'Installing Devolutions Agent...' +if command -v apt-get >/dev/null 2>&1; then + DEBIAN_FRONTEND=noninteractive apt-get install -y "$PACKAGE_PATH" >/dev/null || fail 'apt-get could not install the package.' +else + dpkg -i "$PACKAGE_PATH" >/dev/null || fail 'dpkg could not install the package.' +fi +fi + +[ -x "$AGENT_BIN" ] || fail "$AGENT_BIN is missing after the installation." + +mkdir -p "$CONFIG_DIR" +[ -f "$CONFIG_PATH" ] || "$AGENT_BIN" config init >/dev/null + +info 'Turning on Device Management...' +if command -v python3 >/dev/null 2>&1; then + python3 - "$CONFIG_PATH" "$DEV_URL" "$DEV_CA_FILE" <<'EOF' || fail "could not update $CONFIG_PATH." +import json, sys +path = sys.argv[1] +try: + with open(path, encoding="utf-8-sig") as file: + config = json.load(file) +except FileNotFoundError: + config = {} +config["DeviceManagement"] = {"Enabled": True} +if sys.argv[2]: + # Development builds of UniGetUI are not signed, so the broker does not validate the caller signature. + config["PackageBroker"] = {"Enabled": True} + debug = {"skip_broker_signature_validation": True} + if sys.argv[3]: + debug["identity"] = {"extra_trusted_root": sys.argv[3]} + config["__debug__"] = debug +with open(path, "w", encoding="utf-8") as file: + json.dump(config, file, indent=2) + file.write("\n") +EOF +elif [ ! -s "$CONFIG_PATH" ]; then + printf '{\n "DeviceManagement": { "Enabled": true }\n}\n' > "$CONFIG_PATH" +elif ! grep -q '"DeviceManagement"' "$CONFIG_PATH"; then + sed -i '0,/{/s//{\n "DeviceManagement": { "Enabled": true },/' "$CONFIG_PATH" || fail "could not update $CONFIG_PATH." +else + fail "python3 is required to update the DeviceManagement section in $CONFIG_PATH." +fi + +info 'Enrolling the device...' +if ! printf '%s\n' "$TOKEN" | "$AGENT_BIN" identity enroll - >/dev/null; then + fail 'the enrollment command failed.' +fi +TOKEN='' +unset TOKEN DEVOLUTIONS_ENROLLMENT_TOKEN + +info 'Restarting the Devolutions Agent service...' +if [ -d /run/systemd/system ]; then + if ! systemctl cat "$SERVICE_NAME" >/dev/null 2>&1; then + "$AGENT_BIN" create >/dev/null + systemctl daemon-reload + fi + systemctl enable "$SERVICE_NAME" >/dev/null 2>&1 || true + systemctl restart "$SERVICE_NAME" || fail "could not start the $SERVICE_NAME service." +else + fail 'systemd is required to run the Devolutions Agent service.' +fi + +info 'Devolutions Agent is installed. The device enrolls with Devolutions Server in a few seconds.' diff --git a/package/install/reinstall-unigetui.ps1 b/package/install/reinstall-unigetui.ps1 new file mode 100644 index 0000000000..4d4b409f4a --- /dev/null +++ b/package/install/reinstall-unigetui.ps1 @@ -0,0 +1,61 @@ +# Reinstalls UniGetUI on this Windows machine from the development install server. Development only. +# Usage: irm http://:/reinstall-unigetui.ps1 | iex +# A machine-wide install needs an elevated PowerShell window. A per-user install keeps its scope. + +& { + $ErrorActionPreference = 'Stop' + # Start-DevInstallServer.ps1 sets this value in the served copy. + $DevUrl = '' + + if (-not $DevUrl) { + Write-Host 'Run the copy of this script served by Start-DevInstallServer.ps1.' -ForegroundColor Red + return + } + + $uninstallKeys = @( + 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\{889610CC-4337-4BDB-AC3B-4F21806C0BDE}_is1', + 'HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\{889610CC-4337-4BDB-AC3B-4F21806C0BDE}_is1' + ) + $current = Get-ItemProperty $uninstallKeys -ErrorAction SilentlyContinue | Select-Object -First 1 + $machineWide = $current -and $current.PSPath -like '*HKEY_LOCAL_MACHINE*' + + $principal = [Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent() + if ($machineWide -and -not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { + Write-Host 'UniGetUI is installed for all users. Run this script from an elevated PowerShell window.' -ForegroundColor Red + return + } + + if ($current) { + Write-Host "Installed: UniGetUI $($current.DisplayVersion) ($(if ($machineWide) { 'all users' } else { 'current user' }))" + } else { + Write-Host 'UniGetUI is not installed. Installing it for the current user.' + } + + $installer = Join-Path ([IO.Path]::GetTempPath()) 'UniGetUI.Installer.x64.exe' + Write-Host "Downloading $DevUrl/UniGetUI.Installer.x64.exe..." + $ProgressPreference = 'SilentlyContinue' + Invoke-WebRequest -Uri "$DevUrl/UniGetUI.Installer.x64.exe" -OutFile $installer -UseBasicParsing + + Write-Host 'Stopping UniGetUI...' + Get-Process UniGetUI, UniGetUI.Avalonia, UniGetUI.PolicyElevator -ErrorAction SilentlyContinue | Stop-Process -Force + Start-Sleep -Seconds 2 + + Write-Host 'Installing UniGetUI...' + $scope = if ($machineWide) { '/ALLUSERS' } else { '/CURRENTUSER' } + $arguments = '/VERYSILENT', '/SUPPRESSMSGBOXES', '/NORESTART', '/SP-', $scope + $exitCode = (Start-Process $installer -ArgumentList $arguments -Wait -PassThru).ExitCode + Remove-Item $installer -Force -ErrorAction SilentlyContinue + if ($exitCode -ne 0) { + Write-Host "The installer failed with exit code $exitCode." -ForegroundColor Red + return + } + + $installed = Get-ItemProperty $uninstallKeys -ErrorAction SilentlyContinue | Select-Object -First 1 + # The installer starts UniGetUI in the background. The agent needs it running to reach its IPC API. + for ($i = 0; $i -lt 10 -and -not (Get-Process UniGetUI -ErrorAction SilentlyContinue); $i++) { Start-Sleep -Seconds 1 } + if (-not (Get-Process UniGetUI -ErrorAction SilentlyContinue) -and $installed.InstallLocation) { + Start-Process (Join-Path $installed.InstallLocation 'UniGetUI.exe') -ArgumentList '--daemon' + } + + Write-Host "UniGetUI $($installed.DisplayVersion) is installed. Build date: $((Get-Item (Join-Path $installed.InstallLocation 'UniGetUI.exe')).LastWriteTime)." -ForegroundColor Green +} diff --git a/package/install/uninstall-agent.ps1 b/package/install/uninstall-agent.ps1 new file mode 100644 index 0000000000..95d513a4bc --- /dev/null +++ b/package/install/uninstall-agent.ps1 @@ -0,0 +1,59 @@ +# Removes the Devolutions Agent and its data from this Windows machine. +# Usage (elevated PowerShell): irm https:///uninstall-agent.ps1 | iex + +& { + $ErrorActionPreference = 'Stop' + $serviceName = 'DevolutionsAgent' + + $principal = [Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent() + if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { + Write-Host 'Run this script from an elevated PowerShell window.' -ForegroundColor Red + return + } + + $uninstallKeys = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*', 'HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*' + $products = Get-ItemProperty $uninstallKeys -ErrorAction SilentlyContinue | Where-Object DisplayName -like 'Devolutions Agent*' + foreach ($product in $products) { + Write-Host "Removing $($product.DisplayName) $($product.DisplayVersion)..." + $exitCode = (Start-Process msiexec.exe -ArgumentList "/x $($product.PSChildName) /qn /norestart" -Wait -PassThru).ExitCode + if ($exitCode -notin 0, 1605, 3010) { + Write-Host "msiexec failed with exit code $exitCode." -ForegroundColor Yellow + } + } + + $service = Get-Service $serviceName -ErrorAction SilentlyContinue + if ($service) { + Write-Host 'Removing the service...' + if ($service.Status -ne 'Stopped') { + Stop-Service $serviceName -Force -ErrorAction SilentlyContinue + $service.WaitForStatus('Stopped', '00:00:30') + } + sc.exe delete $serviceName | Out-Null + } + + Get-Process DevolutionsAgent -ErrorAction SilentlyContinue | Stop-Process -Force + Start-Sleep -Seconds 2 + + $failed = $false + foreach ($path in 'C:\Program Files\Devolutions\Agent', 'C:\ProgramData\Devolutions\Agent', 'C:\ProgramData\Devolutions\PackageBroker') { + if (-not (Test-Path $path)) { continue } + Write-Host "Removing $path..." + # The agent protects its identity folder with a SYSTEM-only DACL, so delete it as SYSTEM. + $taskName = 'DevolutionsAgentCleanup' + $action = New-ScheduledTaskAction -Execute cmd.exe -Argument "/c rmdir /s /q `"$path`"" + Register-ScheduledTask -TaskName $taskName -Action $action -User SYSTEM -RunLevel Highest -Force | Out-Null + Start-ScheduledTask -TaskName $taskName + for ($i = 0; $i -lt 15 -and (Test-Path $path); $i++) { Start-Sleep -Seconds 1 } + Unregister-ScheduledTask -TaskName $taskName -Confirm:$false + if (Test-Path $path) { + Write-Host "Could not remove $path." -ForegroundColor Yellow + $failed = $true + } + } + + if ($failed -or (Get-Service $serviceName -ErrorAction SilentlyContinue)) { + Write-Host 'The Devolutions Agent was not fully removed.' -ForegroundColor Yellow + } else { + Write-Host 'The Devolutions Agent is removed.' -ForegroundColor Green + } +} diff --git a/package/install/uninstall-agent.sh b/package/install/uninstall-agent.sh new file mode 100644 index 0000000000..3e59e00065 --- /dev/null +++ b/package/install/uninstall-agent.sh @@ -0,0 +1,28 @@ +#!/bin/sh +# Removes the Devolutions Agent and its data from this Linux machine. +# Usage: curl -fsSL https:///uninstall-agent.sh | sudo sh + +set -e + +if [ "$(id -u)" -ne 0 ]; then + echo "Run this script as root (sudo)." >&2 + exit 1 +fi + +if command -v dpkg >/dev/null 2>&1 && dpkg -s devolutions-agent >/dev/null 2>&1; then + echo "Removing the devolutions-agent package..." + apt-get purge -y devolutions-agent >/dev/null +elif command -v rpm >/dev/null 2>&1 && rpm -q devolutions-agent >/dev/null 2>&1; then + echo "Removing the devolutions-agent package..." + rpm -e devolutions-agent +fi + +echo "Removing the service..." +systemctl disable --now devolutions-agent >/dev/null 2>&1 || true +rm -f /etc/systemd/system/devolutions-agent.service /usr/bin/devolutions-agent +systemctl daemon-reload + +echo "Removing the agent data..." +rm -rf /etc/devolutions-agent /var/lib/devolutions-agent + +echo "The Devolutions Agent is removed."