From 59e93100f8353e0280f5f1f12e4d2d5bb45ae371 Mon Sep 17 00:00:00 2001 From: Vladyslav Nikonov Date: Tue, 20 Aug 2024 09:10:31 +0300 Subject: [PATCH] Devolutions host bootstrap --- Cargo.lock | 20 ++ Cargo.toml | 2 +- crates/win-api-wrappers/Cargo.toml | 6 +- crates/win-api-wrappers/src/lib.rs | 4 + crates/win-api-wrappers/src/process.rs | 288 +++++++++++++++- .../src/security/privilege.rs | 63 +++- crates/win-api-wrappers/src/session.rs | 27 ++ crates/win-api-wrappers/src/token.rs | 20 ++ devolutions-agent/Cargo.toml | 2 + devolutions-agent/src/config.rs | 21 ++ devolutions-agent/src/lib.rs | 6 + devolutions-agent/src/main.rs | 38 +- devolutions-agent/src/service.rs | 59 +++- devolutions-agent/src/session_manager/mod.rs | 325 ++++++++++++++++++ devolutions-host/Cargo.toml | 38 ++ devolutions-host/build.rs | 84 +++++ devolutions-host/src/config.rs | 261 ++++++++++++++ devolutions-host/src/dvc.rs | 140 ++++++++ devolutions-host/src/lib.rs | 12 + devolutions-host/src/log.rs | 21 ++ devolutions-host/src/main.rs | 45 +++ jetsocat/src/pipe.rs | 3 +- jetsocat/src/utils.rs | 3 +- 23 files changed, 1451 insertions(+), 37 deletions(-) create mode 100644 crates/win-api-wrappers/src/session.rs create mode 100644 devolutions-agent/src/session_manager/mod.rs create mode 100644 devolutions-host/Cargo.toml create mode 100644 devolutions-host/build.rs create mode 100644 devolutions-host/src/config.rs create mode 100644 devolutions-host/src/dvc.rs create mode 100644 devolutions-host/src/lib.rs create mode 100644 devolutions-host/src/log.rs create mode 100644 devolutions-host/src/main.rs diff --git a/Cargo.lock b/Cargo.lock index 00aea2092..7d63b3e1d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1014,6 +1014,7 @@ dependencies = [ "tokio-rustls 0.26.0", "tracing", "uuid", + "win-api-wrappers", "windows 0.58.0", "winreg 0.52.0", ] @@ -1117,6 +1118,24 @@ dependencies = [ "tokio 1.38.1", ] +[[package]] +name = "devolutions-host" +version = "2024.3.1" +dependencies = [ + "anyhow", + "camino", + "cfg-if", + "ctrlc", + "devolutions-log", + "embed-resource", + "parking_lot", + "serde", + "serde_json", + "tap", + "tracing", + "windows 0.58.0", +] + [[package]] name = "devolutions-log" version = "0.0.0" @@ -5805,6 +5824,7 @@ dependencies = [ "anyhow", "base16ct", "thiserror", + "tracing", "windows 0.58.0", ] diff --git a/Cargo.toml b/Cargo.toml index 5c378cebc..91f37a355 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ resolver = "2" members = [ "crates/*", "devolutions-agent", - "devolutions-gateway", + "devolutions-gateway", "devolutions-host", "jetsocat", "tools/generate-openapi", ] diff --git a/crates/win-api-wrappers/Cargo.toml b/crates/win-api-wrappers/Cargo.toml index 03601d947..f00d90951 100644 --- a/crates/win-api-wrappers/Cargo.toml +++ b/crates/win-api-wrappers/Cargo.toml @@ -11,19 +11,20 @@ publish = false base16ct = { version = "0.2.0", features = ["alloc"] } anyhow = "1.0.86" thiserror = "1.0.63" +tracing = "0.1" [dependencies.windows] version = "0.58.0" features = [ "Win32_Foundation", "Win32_NetworkManagement_NetManagement", - "Win32_Security", "Win32_Security_Authentication_Identity", "Win32_Security_Authorization", - "Win32_Security_Cryptography", "Win32_Security_Cryptography_Catalog", "Win32_Security_Cryptography_Sip", + "Win32_Security_Cryptography", "Win32_Security_WinTrust", + "Win32_Security", "Win32_Storage_FileSystem", "Win32_System_Com", "Win32_System_Diagnostics_Debug", @@ -38,6 +39,7 @@ features = [ "Win32_System_Pipes", "Win32_System_ProcessStatus", "Win32_System_Registry", + "Win32_System_RemoteDesktop", "Win32_System_Rpc", "Win32_System_StationsAndDesktops", "Win32_System_SystemServices", diff --git a/crates/win-api-wrappers/src/lib.rs b/crates/win-api-wrappers/src/lib.rs index c200e342e..a0e44bbe5 100644 --- a/crates/win-api-wrappers/src/lib.rs +++ b/crates/win-api-wrappers/src/lib.rs @@ -1,3 +1,6 @@ +#[macro_use] +extern crate tracing; + #[cfg(target_os = "windows")] #[path = ""] mod lib_win { @@ -8,6 +11,7 @@ mod lib_win { pub mod identity; pub mod process; pub mod security; + pub mod session; pub mod thread; pub mod token; pub mod utils; diff --git a/crates/win-api-wrappers/src/process.rs b/crates/win-api-wrappers/src/process.rs index de4f6bf3b..9f90ad396 100644 --- a/crates/win-api-wrappers/src/process.rs +++ b/crates/win-api-wrappers/src/process.rs @@ -6,7 +6,7 @@ use std::os::windows::ffi::OsStringExt; use std::path::{Path, PathBuf}; use std::{ptr, slice}; -use anyhow::{bail, Result}; +use anyhow::{anyhow, bail, Result}; use crate::handle::{Handle, HandleWrapper}; use crate::security::acl::{RawSecurityAttributes, SecurityAttributes}; @@ -17,9 +17,13 @@ use crate::utils::{serialize_environment, Allocation, AnsiString, CommandLine, W use crate::Error; use windows::core::PCWSTR; use windows::Win32::Foundation::{ - FreeLibrary, ERROR_INCORRECT_SIZE, E_HANDLE, HANDLE, HMODULE, MAX_PATH, WAIT_EVENT, WAIT_FAILED, + FreeLibrary, ERROR_INCORRECT_SIZE, ERROR_NO_MORE_FILES, E_HANDLE, HANDLE, HMODULE, MAX_PATH, WAIT_EVENT, + WAIT_FAILED, +}; +use windows::Win32::Security::{ + SE_ASSIGNPRIMARYTOKEN_NAME, SE_INCREASE_QUOTA_NAME, SE_TCB_NAME, TOKEN_ACCESS_MASK, TOKEN_ADJUST_PRIVILEGES, + TOKEN_QUERY, }; -use windows::Win32::Security::TOKEN_ACCESS_MASK; use windows::Win32::System::Com::{COINIT_APARTMENTTHREADED, COINIT_DISABLE_OLE1DDE}; use windows::Win32::System::Diagnostics::Debug::{ReadProcessMemory, WriteProcessMemory}; use windows::Win32::System::LibraryLoader::{ @@ -27,15 +31,21 @@ use windows::Win32::System::LibraryLoader::{ }; use windows::Win32::System::Threading::{ CreateProcessAsUserW, CreateRemoteThread, GetCurrentProcess, GetExitCodeProcess, OpenProcess, OpenProcessToken, - QueryFullProcessImageNameW, WaitForSingleObject, CREATE_UNICODE_ENVIRONMENT, EXTENDED_STARTUPINFO_PRESENT, - INFINITE, LPPROC_THREAD_ATTRIBUTE_LIST, LPTHREAD_START_ROUTINE, PEB, PROCESS_ACCESS_RIGHTS, - PROCESS_BASIC_INFORMATION, PROCESS_CREATION_FLAGS, PROCESS_INFORMATION, PROCESS_NAME_WIN32, STARTUPINFOEXW, - STARTUPINFOW, STARTUPINFOW_FLAGS, + QueryFullProcessImageNameW, TerminateProcess, WaitForSingleObject, CREATE_UNICODE_ENVIRONMENT, + EXTENDED_STARTUPINFO_PRESENT, INFINITE, LPPROC_THREAD_ATTRIBUTE_LIST, LPTHREAD_START_ROUTINE, PEB, + PROCESS_ACCESS_RIGHTS, PROCESS_BASIC_INFORMATION, PROCESS_CREATION_FLAGS, PROCESS_INFORMATION, PROCESS_NAME_WIN32, + PROCESS_TERMINATE, STARTUPINFOEXW, STARTUPINFOW, STARTUPINFOW_FLAGS, }; use windows::Win32::UI::Shell::{ShellExecuteExW, SEE_MASK_NOCLOSEPROCESS, SHELLEXECUTEINFOW}; use windows::Win32::UI::WindowsAndMessaging::SHOW_WINDOW_CMD; +use super::security::privilege::ScopedPrivileges; use super::utils::{size_of_u32, ComContext}; +use windows::Win32::System::Diagnostics::ToolHelp::{ + CreateToolhelp32Snapshot, Process32FirstW, Process32NextW, PROCESSENTRY32W, TH32CS_SNAPPROCESS, +}; +use windows::Win32::System::Environment::{CreateEnvironmentBlock, DestroyEnvironmentBlock}; +use windows::Win32::System::RemoteDesktop::ProcessIdToSessionId; #[derive(Debug)] pub struct Process { @@ -539,6 +549,129 @@ pub struct ProcessInformation { pub thread_id: u32, } +pub struct ProcessEntry32Iterator { + snapshot_handle: Handle, + process_entry: PROCESSENTRY32W, + first: bool, +} + +impl ProcessEntry32Iterator { + pub fn new() -> Result { + // SAFETY: `CreateToolhelp32Snapshot` call is always safe and `Owned` wrapper ensures + // that the handle is properly closed when the iterator is dropped. + let snapshot_handle = unsafe { Handle::new(CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0)?, true) }; + + // SAFETY: It is safe to zero out the structure as it is a simple POD type. + let mut process_entry: PROCESSENTRY32W = unsafe { mem::zeroed() }; + process_entry.dwSize = mem::size_of::().try_into().unwrap(); + + Ok(ProcessEntry32Iterator { + snapshot_handle, + process_entry, + first: true, + }) + } +} + +impl Iterator for ProcessEntry32Iterator { + type Item = ProcessEntry; + + fn next(&mut self) -> Option { + let result = if self.first { + // SAFETY: `windows` library ensures that `snapshot` handle is correct on creation, + // therefore it is safe to call Process32First + unsafe { Process32FirstW(self.snapshot_handle.raw(), &mut self.process_entry as *mut _) } + } else { + // SAFETY: `Process32Next` is safe to call because the `snapshot_handle` is valid while it + // is owned by the iterator. + unsafe { Process32NextW(self.snapshot_handle.raw(), &mut self.process_entry as *mut _) } + }; + + match result { + Err(err) if err.code() == ERROR_NO_MORE_FILES.to_hresult() => None, + Err(err) => { + error!(%err, "Failed to iterate over processes"); + None + } + Ok(()) => { + self.first = false; + Some(ProcessEntry(self.process_entry)) + } + } + } +} + +pub struct ProcessEntry(PROCESSENTRY32W); + +impl ProcessEntry { + pub fn process_id(&self) -> u32 { + self.0.th32ProcessID + } + + pub fn executable_name(&self) -> Result { + // NOTE: If for some reason szExeFile all 260 bytes filled and there is no null terminator, + // then the executable name will be truncated. + let exe_name_length = self + .0 + .szExeFile + .iter() + .position(|&c| c == 0) + .ok_or(anyhow!("Executable name null terminator not found"))?; + + let name = String::from_utf16(&self.0.szExeFile[..exe_name_length]) + .map_err(|_| anyhow!("Invalid executable name UTF16 encoding"))?; + + Ok(name) + } +} + +#[cfg(test)] +mod test { + use super::*; + + /// Semi-manual test to validate process enumeration. + #[test] + #[ignore] + fn iterate_processes() { + for process in ProcessEntry32Iterator::new().unwrap() { + let name = process.executable_name().unwrap(); + eprintln!("Process: {} ({})", name, process.process_id()); + } + } +} + +enum ProcessEnvironment { + OsDefined(*const c_void), + Custom(Vec), +} + +impl ProcessEnvironment { + fn as_mut_ptr(&self) -> Option<*const c_void> { + match self { + ProcessEnvironment::OsDefined(ptr) => Some(*ptr), + ProcessEnvironment::Custom(vec) => Some(vec.as_ptr() as *const _), + } + } +} + +impl Drop for ProcessEnvironment { + fn drop(&mut self) { + match self { + ProcessEnvironment::OsDefined(block) => { + // SAFETY: `block` is checked to be valid before free. + unsafe { + if !block.is_null() { + if let Err(err) = DestroyEnvironmentBlock(*block) { + warn!(%err, "Failed to destroy environment block"); + } + } + }; + } + _ => {} + } + } +} + // Goal is to wrap `CreateProcessAsUserW`, which has a lot of arguments. #[allow(clippy::too_many_arguments)] pub fn create_process_as_user( @@ -556,7 +689,17 @@ pub fn create_process_as_user( let application_name = application_name.map(WideString::from).unwrap_or_default(); let current_directory = current_directory.map(WideString::from).unwrap_or_default(); - let environment = environment.map(serialize_environment).transpose()?; + let environment = if let Some(env) = environment { + ProcessEnvironment::Custom(serialize_environment(env)?) + } else { + let mut environment: *mut c_void = ptr::null_mut(); + + if let Some(token) = token { + unsafe { CreateEnvironmentBlock(&mut environment, token.handle().raw(), false) }?; + } + + ProcessEnvironment::OsDefined(environment as *const _) + }; let mut command_line = command_line .map(CommandLine::to_command_line) @@ -583,7 +726,7 @@ pub fn create_process_as_user( thread_attributes.as_ref().map(|x| x.as_raw() as *const _), inherit_handles, creation_flags, - environment.as_ref().map(|x| x.as_ptr().cast()), + environment.as_mut_ptr(), current_directory.as_pcwstr(), &startup_info.as_raw()?.StartupInfo, &mut raw_process_information, @@ -597,3 +740,130 @@ pub fn create_process_as_user( thread_id: raw_process_information.dwThreadId, }) } + +/// Starts new process in the specified session. Note that this function requires the current +/// process to have `SYSTEM`-level permissions. Use with caution. +pub fn create_process_in_session( + session_id: u32, + application_name: Option<&Path>, + command_line: Option<&CommandLine>, + process_attributes: Option<&SecurityAttributes>, + thread_attributes: Option<&SecurityAttributes>, + inherit_handles: bool, + creation_flags: PROCESS_CREATION_FLAGS, + environment: Option<&HashMap>, + current_directory: Option<&Path>, + startup_info: &mut StartupInfo, +) -> Result { + let mut current_process_token = Process::current_process().token(TOKEN_ADJUST_PRIVILEGES | TOKEN_QUERY)?; + + // (needs investigation) Setting all of these at once fails and crashes the process. + // In `wayk-agent` project they are set one by one. + let mut _priv_tcb = ScopedPrivileges::enter(&mut current_process_token, &[SE_TCB_NAME])?; + let mut _priv_primary = ScopedPrivileges::enter(_priv_tcb.token_mut(), &[SE_ASSIGNPRIMARYTOKEN_NAME])?; + let _priv_quota = ScopedPrivileges::enter(_priv_primary.token_mut(), &[SE_INCREASE_QUOTA_NAME])?; + + let mut session_token = Token::for_session(session_id)?; + + session_token.set_session_id(session_id)?; + session_token.set_ui_access(1)?; + + create_process_as_user( + Some(&session_token), + application_name, + command_line, + process_attributes, + thread_attributes, + inherit_handles, + creation_flags, + environment, + current_directory, + startup_info, + ) +} + +pub fn is_process_running(process_name: &str) -> Result { + is_process_running_impl(process_name, None) +} + +pub fn is_process_running_in_session(process_name: &str, session_id: u32) -> Result { + is_process_running_impl(process_name, Some(session_id)) +} + +fn is_process_running_impl(process_name: &str, session_id: Option) -> Result { + for process in ProcessEntry32Iterator::new()? { + if let Some(session_id) = session_id { + let actual_session = match process_id_to_session(process.process_id()) { + Ok(session) => session, + Err(_) => { + continue; + } + }; + + if session_id != actual_session { + continue; + } + } + + if str::eq_ignore_ascii_case(process.executable_name()?.as_str(), process_name) { + return Ok(true); + } + } + + Ok(false) +} + +pub fn terminate_process_by_name(process_name: &str) -> Result { + terminate_process_by_name_impl(process_name, None) +} + +pub fn terminate_process_by_name_in_session(process_name: &str, session_id: u32) -> Result { + terminate_process_by_name_impl(process_name, Some(session_id)) +} + +fn terminate_process_by_name_impl(process_name: &str, session_id: Option) -> Result { + for process in ProcessEntry32Iterator::new()? { + if let Some(session_id) = session_id { + let actual_session = match process_id_to_session(process.process_id()) { + Ok(session) => session, + Err(_) => { + continue; + } + }; + + if session_id != actual_session { + continue; + } + } + + if str::eq_ignore_ascii_case(process.executable_name()?.as_str(), process_name) { + let process = unsafe { OpenProcess(PROCESS_TERMINATE, false, process.process_id()) }; + + match process { + Ok(process) => { + unsafe { + if let Err(err) = TerminateProcess(process, 1) { + warn!(process_name, session_id, %err, "TerminateProcess failed"); + return Ok(false); + } + } + + return Ok(true); + } + Err(err) => { + warn!(process_name, session_id, %err, "OpenProcess failed"); + continue; + } + } + } + } + + Ok(false) +} + +fn process_id_to_session(pid: u32) -> Result { + let mut session_id = 0; + // SAFETY: `session_id` is always pointing to a valid memory location. + unsafe { ProcessIdToSessionId(pid, &mut session_id as *mut _) }?; + Ok(session_id) +} diff --git a/crates/win-api-wrappers/src/security/privilege.rs b/crates/win-api-wrappers/src/security/privilege.rs index 6c6b7c2d0..1a97163b4 100644 --- a/crates/win-api-wrappers/src/security/privilege.rs +++ b/crates/win-api-wrappers/src/security/privilege.rs @@ -4,7 +4,7 @@ use std::sync::OnceLock; use anyhow::Result; use crate::process::Process; -use crate::token::Token; +use crate::token::{Token, TokenPrivilegesAdjustment}; use crate::utils::{slice_from_ptr, Snapshot, WideString}; use windows::core::PCWSTR; use windows::Win32::Foundation::LUID; @@ -104,6 +104,67 @@ pub fn find_token_with_privilege(privilege: LUID) -> Result> { })) } +/// ScopedPrivilege enables a Windows privilege for the lifetime of the object and +/// disables it when going out of scope. +/// +/// Token is borrowed to ensure that the token is alive throughout the lifetime of the scope. +pub struct ScopedPrivileges<'a> { + token: &'a mut Token, + token_privileges: Vec, + description: String, +} + +impl<'a> ScopedPrivileges<'a> { + pub fn enter(token: &'a mut Token, privileges: &[PCWSTR]) -> Result> { + let mut token_privileges = Vec::with_capacity(privileges.len()); + + for privilege in privileges.iter().copied() { + let luid = lookup_privilege_value(None, privilege)?; + token_privileges.push(luid); + } + + let description = privileges + .iter() + .map(|p| { + // SAFETY: `p` is a valid pointer to a NUL-terminated string. + String::from_utf16_lossy(unsafe { p.as_wide() }) + }) + .reduce(|mut acc, value| { + acc.push_str(", "); + acc.push_str(&value); + acc + }) + .unwrap_or_default(); + + token.adjust_privileges(&TokenPrivilegesAdjustment::Enable(token_privileges.clone()))?; + + Ok(ScopedPrivileges { + token, + token_privileges, + description, + }) + } + + pub fn token(&self) -> &Token { + &self.token + } + + pub fn token_mut(&mut self) -> &mut Token { + &mut self.token + } +} + +impl Drop for ScopedPrivileges<'_> { + fn drop(&mut self) { + if let Err(err) = self + .token + .adjust_privileges(&TokenPrivilegesAdjustment::Disable(self.token_privileges.clone())) + { + error!(%err, "Failed to disable ScopedPrivileges({})", self.description); + } + } +} + #[rustfmt::skip] pub fn default_admin_privileges() -> &'static TokenPrivileges { static PRIVS: OnceLock = OnceLock::new(); diff --git a/crates/win-api-wrappers/src/session.rs b/crates/win-api-wrappers/src/session.rs new file mode 100644 index 000000000..31a96e4cb --- /dev/null +++ b/crates/win-api-wrappers/src/session.rs @@ -0,0 +1,27 @@ +use super::handle::Handle; +use super::process::Process; +use super::security::privilege::ScopedPrivileges; +use anyhow::Result; +use windows::Win32::Foundation::{ERROR_NO_TOKEN, HANDLE}; +use windows::Win32::Security::{SE_TCB_NAME, TOKEN_ADJUST_PRIVILEGES, TOKEN_QUERY}; +use windows::Win32::System::RemoteDesktop::WTSQueryUserToken; + +/// Returns true if a user is logged in the provided session. +pub fn session_has_logged_in_user(session_id: u32) -> Result { + let mut current_process_token = Process::current_process().token(TOKEN_ADJUST_PRIVILEGES | TOKEN_QUERY)?; + + let mut _priv_tcb = ScopedPrivileges::enter(&mut current_process_token, &[SE_TCB_NAME])?; + + let mut handle = HANDLE::default(); + + // SAFETY: `WTSQueryUserToken` is safe to call with a valid session id and handle memory ptr. + match unsafe { WTSQueryUserToken(session_id, &mut handle as *mut _) } { + Err(err) if err.code() == ERROR_NO_TOKEN.to_hresult() => Ok(false), + Err(err) => Err(err.into()), + Ok(()) => { + // Close handle immediately. + let _handle: Handle = Handle::new(handle, true); + Ok(true) + } + } +} diff --git a/crates/win-api-wrappers/src/token.rs b/crates/win-api-wrappers/src/token.rs index e7cceca25..22080748b 100644 --- a/crates/win-api-wrappers/src/token.rs +++ b/crates/win-api-wrappers/src/token.rs @@ -45,6 +45,7 @@ use windows::Win32::Security::{ use windows::Win32::System::SystemServices::SE_GROUP_LOGON_ID; use super::utils::size_of_u32; +use windows::Win32::System::RemoteDesktop::WTSQueryUserToken; #[derive(Debug)] pub struct Token { @@ -66,6 +67,17 @@ impl Token { } } + pub fn for_session(session_id: u32) -> Result { + let mut user_token = HANDLE::default(); + + // SAFETY: query user token is always safe if dst pointer is valid. + unsafe { WTSQueryUserToken(session_id, &mut user_token as *mut _)? }; + + Ok(Self { + handle: user_token.into(), + }) + } + // Wrapper around `NtCreateToken`, which has a lot of arguments. #[allow(clippy::too_many_arguments)] pub fn create_token( @@ -328,6 +340,14 @@ impl Token { self.set_information_raw(windows::Win32::Security::TokenSessionId, &session_id) } + pub fn ui_access(&self) -> Result { + self.information_raw::(windows::Win32::Security::TokenUIAccess) + } + + pub fn set_ui_access(&mut self, ui_access: u32) -> Result<()> { + self.set_information_raw(windows::Win32::Security::TokenUIAccess, &ui_access) + } + pub fn mandatory_policy(&self) -> Result { Ok(self .information_raw::(windows::Win32::Security::TokenMandatoryPolicy)? diff --git a/devolutions-agent/Cargo.toml b/devolutions-agent/Cargo.toml index 5b5a8952a..e75c72845 100644 --- a/devolutions-agent/Cargo.toml +++ b/devolutions-agent/Cargo.toml @@ -63,6 +63,7 @@ thiserror = "1" uuid = { version = "1.10", features = ["v4"] } winreg = "0.52" devolutions-pedm = { path = "../crates/devolutions-pedm" } +win-api-wrappers = { path = "../crates/win-api-wrappers" } [target.'cfg(windows)'.dependencies.windows] version = "0.58" @@ -74,6 +75,7 @@ features = [ "Win32_Security_Cryptography", "Win32_Security_Authorization", "Win32_System_ApplicationInstallationAndServicing", + "Win32_System_RemoteDesktop", ] [target.'cfg(windows)'.build-dependencies] diff --git a/devolutions-agent/src/config.rs b/devolutions-agent/src/config.rs index 9dc009b83..7dd740b9d 100644 --- a/devolutions-agent/src/config.rs +++ b/devolutions-agent/src/config.rs @@ -18,6 +18,7 @@ pub struct Conf { pub updater: dto::UpdaterConf, pub remote_desktop: RemoteDesktopConf, pub pedm: dto::PedmConf, + pub session_host: dto::SessionHostConf, pub debug: dto::DebugConf, } @@ -44,6 +45,7 @@ impl Conf { updater: conf_file.updater.clone().unwrap_or_default(), remote_desktop, pedm: conf_file.pedm.clone().unwrap_or_default(), + session_host: conf_file.session_host.clone().unwrap_or_default(), debug: conf_file.debug.clone().unwrap_or_default(), }) } @@ -253,6 +255,19 @@ pub mod dto { } } + #[derive(PartialEq, Eq, Debug, Clone, Serialize, Deserialize)] + #[serde(rename_all = "PascalCase")] + pub struct SessionHostConf { + /// Enable Session host module (disabled by default) + pub enabled: bool, + } + + impl Default for SessionHostConf { + fn default() -> Self { + Self { enabled: false } + } + } + /// Source of truth for Agent configuration /// /// This struct represents the JSON file used for configuration as close as possible @@ -275,10 +290,15 @@ pub mod dto { #[serde(skip_serializing_if = "Option::is_none")] pub remote_desktop: Option, + /// Devolutions PEDM configuration #[serde(default, skip_serializing_if = "Option::is_none")] pub pedm: Option, + /// Devolutions Session Host configuration + #[serde(default, skip_serializing_if = "Option::is_none")] + pub session_host: Option, + /// (Unstable) Unsafe debug options for developers #[serde(rename = "__debug__", skip_serializing_if = "Option::is_none")] pub debug: Option, @@ -299,6 +319,7 @@ pub mod dto { remote_desktop: None, pedm: None, debug: None, + session_host: Some(SessionHostConf { enabled: true }), rest: serde_json::Map::new(), } } diff --git a/devolutions-agent/src/lib.rs b/devolutions-agent/src/lib.rs index f6c82ec87..4a2d32d22 100644 --- a/devolutions-agent/src/lib.rs +++ b/devolutions-agent/src/lib.rs @@ -9,5 +9,11 @@ mod log; mod remote_desktop; pub mod service; +#[cfg(windows)] +mod session_manager; + #[cfg(windows)] mod updater; + +pub enum CustomAgentServiceEvent {} +pub type AgentServiceEvent = ceviche::ServiceEvent; diff --git a/devolutions-agent/src/main.rs b/devolutions-agent/src/main.rs index 070946f85..1622ac50d 100644 --- a/devolutions-agent/src/main.rs +++ b/devolutions-agent/src/main.rs @@ -15,19 +15,18 @@ use std::env; use std::sync::mpsc; use ceviche::controller::*; -use ceviche::{Service, ServiceEvent}; +use ceviche::Service; use devolutions_agent::config::ConfHandle; use devolutions_agent::service::{AgentService, DESCRIPTION, DISPLAY_NAME, SERVICE_NAME}; +use devolutions_agent::AgentServiceEvent; const BAD_CONFIG_ERR_CODE: u32 = 1; const START_FAILED_ERR_CODE: u32 = 2; -enum AgentServiceEvent {} - fn agent_service_main( - rx: mpsc::Receiver>, - _tx: mpsc::Sender>, + rx: mpsc::Receiver, + _tx: mpsc::Sender, _args: Vec, _standalone_mode: bool, ) -> u32 { @@ -53,13 +52,34 @@ fn agent_service_main( } } + let mut service_event_tx = service.service_event_tx(); + loop { if let Ok(control_code) = rx.recv() { info!(%control_code, "Received control code"); - if let ServiceEvent::Stop = control_code { - service.stop(); - break; + match control_code { + AgentServiceEvent::Stop => { + break; + } + AgentServiceEvent::SessionConnect(_) + | AgentServiceEvent::SessionDisconnect(_) + | AgentServiceEvent::SessionRemoteConnect(_) + | AgentServiceEvent::SessionRemoteDisconnect(_) + | AgentServiceEvent::SessionLogon(_) + | AgentServiceEvent::SessionLogoff(_) => { + if let Some(tx) = service_event_tx.as_mut() { + match tx.send(control_code) { + Ok(()) => {} + Err(err) => { + error!(%err, "Failed to send event to session manager"); + service_event_tx = None; + } + } + } + } + + _ => {} } } } @@ -101,7 +121,7 @@ fn main() { let _tx = tx.clone(); ctrlc::set_handler(move || { - let _ = tx.send(ServiceEvent::Stop); + let _ = tx.send(AgentServiceEvent::Stop); }) .expect("failed to register Ctrl-C handler"); diff --git a/devolutions-agent/src/service.rs b/devolutions-agent/src/service.rs index 58ec5b312..a68f24f11 100644 --- a/devolutions-agent/src/service.rs +++ b/devolutions-agent/src/service.rs @@ -1,10 +1,14 @@ use tokio::runtime::{self, Runtime}; +use tokio::sync::mpsc; use crate::config::ConfHandle; use crate::log::AgentLog; use crate::remote_desktop::RemoteDesktopTask; #[cfg(windows)] +use crate::session_manager::SessionManager; +#[cfg(windows)] use crate::updater::UpdaterTask; +use crate::AgentServiceEvent; use anyhow::Context; use devolutions_gateway_task::{ChildTask, ShutdownHandle, ShutdownSignal}; use devolutions_log::{self, LogDeleterTask, LoggerGuard}; @@ -16,6 +20,13 @@ pub const SERVICE_NAME: &str = "devolutions-agent"; pub const DISPLAY_NAME: &str = "Devolutions Agent"; pub const DESCRIPTION: &str = "Devolutions Agent service"; +struct TasksCtx { + /// Spawned service tasks + tasks: Tasks, + /// Sender side of the service event channel (Used for session manager module) + service_event_tx: Option>, +} + #[allow(clippy::large_enum_variant)] // `Running` variant is bigger than `Stopped` but we don't care enum AgentState { Stopped, @@ -29,6 +40,7 @@ pub struct AgentService { conf_handle: ConfHandle, state: AgentState, _logger_guard: LoggerGuard, + service_event_tx: Option>, } impl AgentService { @@ -57,6 +69,7 @@ impl AgentService { Ok(AgentService { conf_handle, state: AgentState::Stopped, + service_event_tx: None, _logger_guard: logger_guard, }) } @@ -70,7 +83,10 @@ impl AgentService { let config = self.conf_handle.clone(); // create_futures needs to be run in the runtime in order to bind the sockets. - let tasks = runtime.block_on(spawn_tasks(config))?; + let TasksCtx { + tasks, + service_event_tx, + } = runtime.block_on(spawn_tasks(config))?; trace!("Tasks created"); @@ -94,6 +110,7 @@ impl AgentService { } }); + self.service_event_tx = service_event_tx; self.state = AgentState::Running { shutdown_handle: tasks.shutdown_handle, runtime, @@ -147,6 +164,10 @@ impl AgentService { } } } + + pub fn service_event_tx(&self) -> Option> { + self.service_event_tx.clone() + } } struct Tasks { @@ -175,7 +196,7 @@ impl Tasks { } } -async fn spawn_tasks(conf_handle: ConfHandle) -> anyhow::Result { +async fn spawn_tasks(conf_handle: ConfHandle) -> anyhow::Result { let conf = conf_handle.get_conf(); let mut tasks = Tasks::new(); @@ -183,18 +204,34 @@ async fn spawn_tasks(conf_handle: ConfHandle) -> anyhow::Result { tasks.register(LogDeleterTask::::new(conf.log_file.clone())); #[cfg(windows)] - if conf.updater.enabled { - tasks.register(UpdaterTask::new(conf_handle.clone())); - } + let service_event_tx = { + if conf.updater.enabled { + tasks.register(UpdaterTask::new(conf_handle.clone())); + } + + if conf.pedm.enabled { + tasks.register(PedmTask::new()) + } + + if conf.session_host.enabled { + let session_manager = SessionManager::default(); + let tx = session_manager.service_event_tx(); + tasks.register(session_manager); + Some(tx) + } else { + None + } + }; + + #[cfg(not(windows))] + let service_event_tx = None; if conf.debug.enable_unstable && conf.remote_desktop.enabled { tasks.register(RemoteDesktopTask::new(conf_handle)); } - #[cfg(windows)] - if conf.pedm.enabled { - tasks.register(PedmTask::new()) - } - - Ok(tasks) + Ok(TasksCtx { + tasks, + service_event_tx, + }) } diff --git a/devolutions-agent/src/session_manager/mod.rs b/devolutions-agent/src/session_manager/mod.rs new file mode 100644 index 000000000..88d82577f --- /dev/null +++ b/devolutions-agent/src/session_manager/mod.rs @@ -0,0 +1,325 @@ +//! Module for starting and managing the Devolutions Host process in user sessions. + +use tokio::sync::{mpsc, RwLock}; + +use crate::AgentServiceEvent; +use async_trait::async_trait; +use ceviche::controller::Session; +use devolutions_gateway_task::{ShutdownSignal, Task}; +use std::collections::BTreeMap; +use std::fmt::Debug; + +use camino::Utf8PathBuf; +use win_api_wrappers::process::{ + create_process_in_session, is_process_running_in_session, terminate_process_by_name_in_session, StartupInfo, +}; +use win_api_wrappers::session::session_has_logged_in_user; +use win_api_wrappers::utils::{CommandLine, WideString}; +use windows::Win32::System::Threading::{ + CREATE_NEW_CONSOLE, CREATE_UNICODE_ENVIRONMENT, NORMAL_PRIORITY_CLASS, STARTF_USESHOWWINDOW, +}; +use windows::Win32::UI::WindowsAndMessaging::SW_SHOW; + +const HOST_BINARY: &str = "DevolutionsHost.exe"; + +#[derive(Debug, Clone, Copy)] +pub enum SessionKind { + /// Console session. For example, when you connect to a user session on the local computer + /// by switching users on the computer. + Console, + /// Remote session. For example, when a user connects to a user session by using the Remote + /// Desktop Connection program from a remote computer. + Remote, +} + +struct GatewaySession { + session: Session, + kind: SessionKind, + is_host_ready: bool, +} + +// NOTE: `ceviche::controller::Session` do not implement `Debug` for session. +impl Debug for GatewaySession { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("GatewaySession") + .field("session", &self.session.id) + .field("kind", &self.kind) + .field("is_host_ready", &self.is_host_ready) + .finish() + } +} + +impl GatewaySession { + fn new(session: Session, kind: SessionKind) -> Self { + Self { + session, + kind, + is_host_ready: false, + } + } + + fn kind(&self) -> SessionKind { + self.kind + } + + fn os_session(&self) -> &Session { + &self.session + } + + fn is_host_ready(&self) -> bool { + self.is_host_ready + } + + fn set_host_ready(&mut self, is_ready: bool) { + self.is_host_ready = is_ready; + } +} + +#[derive(Default, Debug)] +struct SessionManagerCtx { + sessions: BTreeMap, +} + +impl SessionManagerCtx { + fn register_session(&mut self, session: &Session, kind: SessionKind) { + self.sessions + .insert(session.to_string(), GatewaySession::new(Session::new(session.id), kind)); + } + + fn unregister_session(&mut self, session: &Session) { + self.sessions.remove(&session.to_string()); + } + + fn get_session(&self, session: &Session) -> Option<&GatewaySession> { + self.sessions.get(&session.to_string()) + } + + fn get_session_mut(&mut self, session: &Session) -> Option<&mut GatewaySession> { + self.sessions.get_mut(&session.to_string()) + } +} + +pub struct SessionManager { + ctx: RwLock, + service_event_tx: mpsc::UnboundedSender, + service_event_rx: mpsc::UnboundedReceiver, +} + +impl Default for SessionManager { + fn default() -> Self { + let (service_event_tx, service_event_rx) = mpsc::unbounded_channel(); + + Self { + ctx: RwLock::new(SessionManagerCtx::default()), + service_event_tx, + service_event_rx, + } + } +} + +impl SessionManager { + pub(crate) fn service_event_tx(&self) -> mpsc::UnboundedSender { + self.service_event_tx.clone() + } +} + +#[async_trait] +impl Task for SessionManager { + type Output = anyhow::Result<()>; + + const NAME: &'static str = "SessionManager"; + + async fn run(self, mut shutdown_signal: ShutdownSignal) -> anyhow::Result<()> { + let Self { + mut service_event_rx, + ctx, + .. + } = self; + + info!("Starting session manager..."); + + loop { + tokio::select! { + event = service_event_rx.recv() => { + info!("Received service event"); + + let event = if let Some(event) = event { + event + } else { + error!("Service event channel closed"); + // Channel closed, all senders were dropped + break; + }; + + match event { + AgentServiceEvent::SessionConnect(id) => { + info!(%id, "Session connected"); + let mut ctx = ctx.write().await; + ctx.register_session(&id, SessionKind::Console); + // We only start the host process for remote sessions (initiated + // via RDP), as Host process with DVC handler is only needed for remote + // sessions. + } + AgentServiceEvent::SessionDisconnect(id) => { + info!(%id, "Session disconnected"); + try_terminate_host_process(&id); + ctx.write().await.unregister_session(&id); + } + AgentServiceEvent::SessionRemoteConnect(id) => { + info!(%id, "Remote session connected"); + // Terminate old host process if it is already running + try_terminate_host_process(&id); + + { + let mut ctx = ctx.write().await; + ctx.register_session(&id, SessionKind::Remote); + try_start_host_process(&mut ctx, &id)?; + } + } + AgentServiceEvent::SessionRemoteDisconnect(id) => { + info!(%id, "Remote session disconnected"); + // Terminate host process when remote session is disconnected + // (NOTE: depending on the system settings, session could + // still be running in the background after RDP disconnect) + try_terminate_host_process(&id); + ctx.write().await.unregister_session(&id); + } + AgentServiceEvent::SessionLogon(id) => { + info!(%id, "Session logged on"); + + // Terminate old host process if it is already running + try_terminate_host_process(&id); + + + // NOTE: In some cases, SessionRemoteConnect is fired before + // an actual user is logged in, therefore we need to try start the host + // app on logon, if not yet started + let mut ctx = ctx.write().await; + try_start_host_process(&mut ctx, &id)?; + } + AgentServiceEvent::SessionLogoff(id) => { + info!(%id, "Session logged off"); + ctx.write().await.get_session_mut(&id).map(|session| { + // When a user logs off, host process will be stopped by the system; + // Console sessions could be reused for different users, therefore + // we should not remove the session from the list, but mark it as + // not yet ready (host will be started as soon as new user logs in). + session.set_host_ready(false); + }); + } + _ => { + continue; + } + } + } + _ = shutdown_signal.wait() => { + info!("Shutting down session manager"); + break; + } + } + } + + Ok(()) + } +} + +/// Starts Devolutions Host process in the target session. +fn try_start_host_process(ctx: &mut SessionManagerCtx, session: &Session) -> anyhow::Result<()> { + match ctx.get_session_mut(&session) { + Some(gw_session) => { + if is_host_running_in_session(&session)? { + gw_session.set_host_ready(true); + return Ok(()); + } + + info!(%session, "Starting host process in session"); + + match start_host_process(&session) { + Ok(()) => { + info!(%session, "Host process started in session"); + gw_session.set_host_ready(true); + } + Err(err) => { + error!(%err, %session, "Failed to start host process for session"); + } + } + } + None => { + warn!(%session, "Session is not yet registered"); + } + }; + + Ok(()) +} + +/// Terminates Devolutions Host process in the target session. +fn try_terminate_host_process(session: &Session) { + match terminate_process_by_name_in_session(HOST_BINARY, session.id) { + Ok(false) => { + trace!(%session, "Host process is not running in the session"); + } + Ok(true) => { + info!(%session, "Host process terminated in session"); + } + Err(err) => { + error!(%err, %session, "Failed to terminate host process in session"); + } + } +} + +fn is_host_running_in_session(session: &Session) -> anyhow::Result { + let is_running = is_process_running_in_session(HOST_BINARY, session.id)?; + Ok(is_running) +} + +fn start_host_process(session: &Session) -> anyhow::Result<()> { + if !session_has_logged_in_user(session.id)? { + anyhow::bail!("Session {} does not have a logged in user", session); + } + + let host_app_path = host_app_path(); + let command_line = CommandLine::new(vec!["--session".to_owned(), session.to_string()]); + + info!("Starting `{host_app_path}` in session `{session}`"); + + let mut startup_info = StartupInfo::default(); + + // Run with GUI access + startup_info.show_window = SW_SHOW.0 as u16; + startup_info.flags = STARTF_USESHOWWINDOW; + startup_info.desktop = WideString::from("WinSta0\\Default"); + + let start_result = create_process_in_session( + session.id, + Some(host_app_path.as_std_path()), + Some(&command_line), + None, + None, + false, + CREATE_NEW_CONSOLE | NORMAL_PRIORITY_CLASS | CREATE_UNICODE_ENVIRONMENT, + None, + None, + &mut startup_info, + ); + + match start_result { + Ok(_) => { + info!("{HOST_BINARY} started in session {session}"); + Ok(()) + } + Err(err) => { + error!(%err, "Failed to start {HOST_BINARY} in session {session}"); + Err(err) + } + } +} + +fn host_app_path() -> Utf8PathBuf { + let mut current_dir = Utf8PathBuf::from_path_buf(std::env::current_exe().expect("BUG: can't get current exe path")) + .expect("BUG: OS should always return valid UTF-8 executable path"); + + current_dir.pop(); + current_dir.push(HOST_BINARY); + + current_dir +} diff --git a/devolutions-host/Cargo.toml b/devolutions-host/Cargo.toml new file mode 100644 index 000000000..3101d282b --- /dev/null +++ b/devolutions-host/Cargo.toml @@ -0,0 +1,38 @@ +[package] +name = "devolutions-host" +version = "2024.3.1" +edition = "2021" +license = "MIT/Apache-2.0" +authors = ["Devolutions Inc. "] +description = "Per-session host application for Devolutions Agent" +build = "build.rs" +publish = false + +[dependencies] +anyhow = "1.0" +camino = { version = "1.1", features = ["serde1"] } +cfg-if = "1" +ctrlc = "3.4" +devolutions-log = { path = "../crates/devolutions-log" } +parking_lot = "0.12" +serde = "1" +serde_json = "1" +tap = "1.0" +tracing = "0.1" + +[lints] +workspace = true + +[target.'cfg(windows)'.build-dependencies] +embed-resource = "2.4" + +[target.'cfg(windows)'.dependencies.windows] +version = "0.58" +features = [ + "Win32_Foundation", + "Win32_Storage_FileSystem", + "Win32_System_RemoteDesktop", + "Win32_System_IO", + "Win32_System_Threading", + "Win32_Security", +] \ No newline at end of file diff --git a/devolutions-host/build.rs b/devolutions-host/build.rs new file mode 100644 index 000000000..839115979 --- /dev/null +++ b/devolutions-host/build.rs @@ -0,0 +1,84 @@ +fn main() { + #[cfg(target_os = "windows")] + win::embed_version_rc(); +} + +#[cfg(target_os = "windows")] +mod win { + use std::{env, fs}; + + pub(super) fn embed_version_rc() { + let out_dir = env::var("OUT_DIR").unwrap(); + let version_rc_file = format!("{}/version.rc", out_dir); + let version_rc_data = generate_version_rc(); + fs::write(&version_rc_file, version_rc_data).unwrap(); + + embed_resource::compile(&version_rc_file, embed_resource::NONE); + } + + fn generate_version_rc() -> String { + let output_name = "DevolutionsHost"; + let filename = format!("{}.exe", output_name); + let company_name = "Devolutions Inc."; + let legal_copyright = format!("Copyright 2020-2024 {}", company_name); + + let version_number = env::var("CARGO_PKG_VERSION").unwrap() + ".0"; + let version_commas = version_number.replace('.', ","); + let file_description = output_name; + let file_version = version_number.clone(); + let internal_name = filename.clone(); + let original_filename = filename; + let product_name = output_name; + let product_version = version_number; + let vs_file_version = version_commas.clone(); + let vs_product_version = version_commas; + + let version_rc = format!( + r#"#include +VS_VERSION_INFO VERSIONINFO + FILEVERSION {vs_file_version} + PRODUCTVERSION {vs_product_version} + FILEFLAGSMASK 0x3fL +#ifdef _DEBUG + FILEFLAGS 0x1L +#else + FILEFLAGS 0x0L +#endif + FILEOS 0x40004L + FILETYPE 0x1L + FILESUBTYPE 0x0L +BEGIN + BLOCK "StringFileInfo" + BEGIN + BLOCK "040904b0" + BEGIN + VALUE "CompanyName", "{company_name}" + VALUE "FileDescription", "{file_description}" + VALUE "FileVersion", "{file_version}" + VALUE "InternalName", "{internal_name}" + VALUE "LegalCopyright", "{legal_copyright}" + VALUE "OriginalFilename", "{original_filename}" + VALUE "ProductName", "{product_name}" + VALUE "ProductVersion", "{product_version}" + END + END + BLOCK "VarFileInfo" + BEGIN + VALUE "Translation", 0x409, 1200 + END +END"#, + vs_file_version = vs_file_version, + vs_product_version = vs_product_version, + company_name = company_name, + file_description = file_description, + file_version = file_version, + internal_name = internal_name, + legal_copyright = legal_copyright, + original_filename = original_filename, + product_name = product_name, + product_version = product_version + ); + + version_rc + } +} diff --git a/devolutions-host/src/config.rs b/devolutions-host/src/config.rs new file mode 100644 index 000000000..ce0c42f50 --- /dev/null +++ b/devolutions-host/src/config.rs @@ -0,0 +1,261 @@ +use anyhow::{bail, Context}; +use camino::{Utf8Path, Utf8PathBuf}; +use cfg_if::cfg_if; +use serde::{Deserialize, Serialize}; +use tap::prelude::*; + +use std::fs::File; +use std::io::BufReader; +use std::net::SocketAddr; +use std::sync::Arc; + +cfg_if! { + if #[cfg(target_os = "windows")] { + const COMPANY_DIR: &str = "Devolutions"; + const PROGRAM_DIR: &str = "Host"; + const APPLICATION_DIR: &str = "Devolutions\\Host"; + } else if #[cfg(target_os = "macos")] { + const COMPANY_DIR: &str = "Devolutions"; + const PROGRAM_DIR: &str = "Host"; + const APPLICATION_DIR: &str = "Devolutions Host"; + } else { + const COMPANY_DIR: &str = "devolutions"; + const PROGRAM_DIR: &str = "Host"; + const APPLICATION_DIR: &str = "devolutions-host"; + } +} + +#[derive(Debug, Clone)] +pub struct Conf { + pub log_file: Utf8PathBuf, + pub verbosity_profile: dto::VerbosityProfile, + pub debug: dto::DebugConf, +} + +impl Conf { + pub fn from_conf_file(conf_file: &dto::ConfFile) -> anyhow::Result { + let data_dir = get_data_dir(); + + let log_file = conf_file + .log_file + .clone() + .unwrap_or_else(|| Utf8PathBuf::from("host")) + .pipe_ref(|path| normalize_data_path(path, &data_dir)); + + Ok(Conf { + log_file, + verbosity_profile: conf_file.verbosity_profile.unwrap_or_default(), + debug: conf_file.debug.clone().unwrap_or_default(), + }) + } +} + +/// Configuration Handle, source of truth for current configuration state +#[derive(Clone)] +pub struct ConfHandle { + inner: Arc, +} + +struct ConfHandleInner { + conf: parking_lot::RwLock>, + conf_file: parking_lot::RwLock>, +} + +impl ConfHandle { + /// Initializes configuration for this instance. + /// + /// It's best to call this only once to avoid inconsistencies. + pub fn init() -> anyhow::Result { + let conf_file = load_conf_file_or_generate_new()?; + let conf = Conf::from_conf_file(&conf_file).context("invalid configuration file")?; + + Ok(Self { + inner: Arc::new(ConfHandleInner { + conf: parking_lot::RwLock::new(Arc::new(conf)), + conf_file: parking_lot::RwLock::new(Arc::new(conf_file)), + }), + }) + } + + /// Returns current configuration state (do not hold it forever as it may become outdated) + pub fn get_conf(&self) -> Arc { + self.inner.conf.read().clone() + } + + /// Returns current configuration file state (do not hold it forever as it may become outdated) + pub fn get_conf_file(&self) -> Arc { + self.inner.conf_file.read().clone() + } +} + +fn save_config(conf: &dto::ConfFile) -> anyhow::Result<()> { + let conf_file_path = get_conf_file_path(); + let json = serde_json::to_string_pretty(conf).context("failed JSON serialization of configuration")?; + std::fs::write(&conf_file_path, json).with_context(|| format!("failed to write file at {conf_file_path}"))?; + Ok(()) +} + +fn get_conf_file_path() -> Utf8PathBuf { + get_data_dir().join("agent.json") +} + +fn normalize_data_path(path: &Utf8Path, data_dir: &Utf8Path) -> Utf8PathBuf { + if path.is_absolute() { + path.to_owned() + } else { + data_dir.join(path) + } +} + +fn load_conf_file(conf_path: &Utf8Path) -> anyhow::Result> { + match File::open(conf_path) { + Ok(file) => BufReader::new(file) + .pipe(serde_json::from_reader) + .map(Some) + .with_context(|| format!("invalid config file at {conf_path}")), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(e) => Err(anyhow::anyhow!(e).context(format!("couldn't open config file at {conf_path}"))), + } +} + +#[allow(clippy::print_stdout)] // Logger is likely not yet initialized at this point, so it’s fine to write to stdout. +pub fn load_conf_file_or_generate_new() -> anyhow::Result { + let conf_file_path = get_conf_file_path(); + + let conf_file = match load_conf_file(&conf_file_path).context("failed to load configuration")? { + Some(conf_file) => conf_file, + None => { + let defaults = dto::ConfFile::generate_new(); + println!("Write default configuration to disk…"); + save_config(&defaults).context("failed to save configuration")?; + defaults + } + }; + + Ok(conf_file) +} + +pub mod dto { + use super::*; + + /// Source of truth for Agent configuration + /// + /// This struct represents the JSON file used for configuration as close as possible + /// and is not trying to be too smart. + /// + /// Unstable options are subject to change + #[derive(PartialEq, Debug, Clone, Serialize, Deserialize)] + #[serde(rename_all = "PascalCase")] + pub struct ConfFile { + /// Verbosity profile + #[serde(skip_serializing_if = "Option::is_none")] + pub verbosity_profile: Option, + + /// (Unstable) Folder and prefix for log files + #[serde(skip_serializing_if = "Option::is_none")] + pub log_file: Option, + + /// (Unstable) Unsafe debug options for developers + #[serde(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. + #[serde(flatten)] + pub rest: serde_json::Map, + } + + impl ConfFile { + pub fn generate_new() -> Self { + Self { + verbosity_profile: None, + log_file: None, + debug: None, + rest: serde_json::Map::new(), + } + } + } + + /// Verbosity profile (pre-defined tracing directives) + #[derive(PartialEq, Eq, Debug, Clone, Copy, Serialize, Deserialize, Default)] + pub enum VerbosityProfile { + /// The default profile, mostly info records + #[default] + Default, + /// Recommended profile for developers + Debug, + /// Show all traces + All, + /// Only show warnings and errors + Quiet, + } + + impl VerbosityProfile { + pub fn to_log_filter(self) -> &'static str { + match self { + VerbosityProfile::Default => "info", + VerbosityProfile::Debug => "info,devolutions_agent=debug", + VerbosityProfile::All => "trace", + VerbosityProfile::Quiet => "warn", + } + } + } + + /// Unsafe debug options that should only ever be used at development stage + /// + /// These options might change or get removed without further notice. + /// + /// Note to developers: all options should be safe by default, never add an option + /// that needs to be overridden manually in order to be safe. + #[derive(PartialEq, Eq, Debug, Clone, Serialize, Deserialize)] + pub struct DebugConf { + /// Directives string in the same form as the RUST_LOG environment variable + #[serde(skip_serializing_if = "Option::is_none")] + pub log_directives: Option, + + /// Enable unstable features which may break at any point + #[serde(default)] + pub enable_unstable: 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, + enable_unstable: false, + } + } + } + + impl DebugConf { + pub fn is_default(&self) -> bool { + Self::default().eq(self) + } + } +} + +pub fn get_data_dir() -> Utf8PathBuf { + if let Ok(config_path_env) = std::env::var("DHOST_DATA_PATH") { + Utf8PathBuf::from(config_path_env) + } else { + let mut config_path = Utf8PathBuf::new(); + + if cfg!(target_os = "windows") { + let program_data_env = std::env::var("APPDATA").expect("APPDATA env variable should be set on Windows"); + 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 + } +} diff --git a/devolutions-host/src/dvc.rs b/devolutions-host/src/dvc.rs new file mode 100644 index 000000000..d2da9dbaa --- /dev/null +++ b/devolutions-host/src/dvc.rs @@ -0,0 +1,140 @@ +//! WIP: This file is copied from MSRDPEX project + +use crate::config::ConfHandle; +use windows::core::PCSTR; +use windows::Win32::Foundation::{ + DuplicateHandle, GetLastError, DUPLICATE_SAME_ACCESS, ERROR_IO_PENDING, HANDLE, WIN32_ERROR, +}; +use windows::Win32::Storage::FileSystem::{ReadFile, WriteFile}; +use windows::Win32::System::RemoteDesktop::{ + WTSFreeMemory, WTSVirtualChannelClose, WTSVirtualChannelOpenEx, WTSVirtualChannelQuery, WTSVirtualFileHandle, + CHANNEL_FLAG_LAST, CHANNEL_PDU_HEADER, WTS_CHANNEL_OPTION_DYNAMIC, WTS_CURRENT_SESSION, WTS_VIRTUAL_CLASS, +}; +use windows::Win32::System::Threading::{CreateEventW, GetCurrentProcess, WaitForSingleObject, INFINITE}; +use windows::Win32::System::IO::{GetOverlappedResult, OVERLAPPED}; + +const CHANNEL_PDU_LENGTH: usize = 1024; + +pub fn loop_dvc(config: ConfHandle) { + info!("Starting DVC loop"); + + let channel_name = "DvcSample"; + match open_virtual_channel(channel_name) { + Ok(h_file) => { + info!("Virtual channel opened"); + + if let Err(err) = handle_virtual_channel(h_file) { + error!(%err, "DVC handling falied"); + } + } + Err(err) => { + error!(%err, "Failed to open virtual channel"); + // NOTE: Not exiting the program here, as it is not the main functionality + } + } + + info!("DVC loop finished"); +} + +fn open_virtual_channel(channel_name: &str) -> windows::core::Result { + unsafe { + let channel_name_wide = PCSTR::from_raw(channel_name.as_ptr()); + let h_wts_handle = WTSVirtualChannelOpenEx(WTS_CURRENT_SESSION, channel_name_wide, WTS_CHANNEL_OPTION_DYNAMIC) + .map_err(|e| std::io::Error::from_raw_os_error(e.code().0))?; + + let mut vc_file_handle_ptr: *mut HANDLE = std::ptr::null_mut(); + let mut len: u32 = 0; + let wts_virtual_class: WTS_VIRTUAL_CLASS = WTSVirtualFileHandle; + WTSVirtualChannelQuery( + h_wts_handle, + wts_virtual_class, + &mut vc_file_handle_ptr as *mut _ as *mut _, + &mut len, + ) + .map_err(|e| std::io::Error::from_raw_os_error(e.code().0))?; + + let mut new_handle: HANDLE = HANDLE::default(); + let _duplicate_result = DuplicateHandle( + GetCurrentProcess(), + *vc_file_handle_ptr, + GetCurrentProcess(), + &mut new_handle, + 0, + false, + DUPLICATE_SAME_ACCESS, + ); + + WTSFreeMemory(vc_file_handle_ptr as *mut core::ffi::c_void); + let _ = WTSVirtualChannelClose(h_wts_handle); + + Ok(new_handle) + } +} + +fn write_virtual_channel_message(h_file: HANDLE, cb_size: u32, buffer: *const u8) -> windows::core::Result<()> { + unsafe { + let buffer_slice = std::slice::from_raw_parts(buffer, cb_size as usize); + let mut dw_written: u32 = 0; + WriteFile(h_file, Some(buffer_slice), Some(&mut dw_written), None) + } +} + +fn handle_virtual_channel(h_file: HANDLE) -> windows::core::Result<()> { + unsafe { + let mut read_buffer = [0u8; CHANNEL_PDU_LENGTH]; + let mut overlapped = OVERLAPPED::default(); + let mut dw_read: u32 = 0; + + let cmd = "whoami\0"; + let cb_size = cmd.len() as u32; + write_virtual_channel_message(h_file, cb_size, cmd.as_ptr())?; + + let h_event = CreateEventW(None, false, false, None)?; + overlapped.hEvent = h_event; + + loop { + // Notice the wrapping of parameters in Some() + let result = ReadFile( + h_file, + Some(&mut read_buffer), + Some(&mut dw_read), + Some(&mut overlapped), + ); + + if let Err(e) = result { + if GetLastError() == WIN32_ERROR(ERROR_IO_PENDING.0) { + let _dw_status = WaitForSingleObject(h_event, INFINITE); + if !GetOverlappedResult(h_file, &mut overlapped, &mut dw_read, false).is_ok() { + return Err(windows::core::Error::from_win32()); + } + } else { + return Err(e); + } + } + + println!("read {} bytes", dw_read); + + let packet_size = dw_read as usize - std::mem::size_of::(); + let p_data = read_buffer + .as_ptr() + .offset(std::mem::size_of::() as isize) as *const u8; + + println!( + ">> {}", + std::str::from_utf8(std::slice::from_raw_parts(p_data, packet_size)).unwrap_or("Invalid UTF-8") + ); + + if dw_read == 0 + || ((*(p_data.offset(-(std::mem::size_of::() as isize)) + as *const CHANNEL_PDU_HEADER)) + .flags + & CHANNEL_FLAG_LAST) + != 0 + { + break; + } + } + + Ok(()) + } +} diff --git a/devolutions-host/src/lib.rs b/devolutions-host/src/lib.rs new file mode 100644 index 000000000..773f192cb --- /dev/null +++ b/devolutions-host/src/lib.rs @@ -0,0 +1,12 @@ +#[macro_use] +extern crate tracing; + +#[cfg(windows)] +mod dvc; + +mod config; +mod log; + +pub use config::{get_data_dir, ConfHandle}; +pub use dvc::loop_dvc; +pub use log::init_log; diff --git a/devolutions-host/src/log.rs b/devolutions-host/src/log.rs new file mode 100644 index 000000000..e66bc55a7 --- /dev/null +++ b/devolutions-host/src/log.rs @@ -0,0 +1,21 @@ +use crate::config::ConfHandle; +use devolutions_log::{LoggerGuard, StaticLogConfig}; + +pub(crate) struct HostLog; + +impl StaticLogConfig for HostLog { + const MAX_BYTES_PER_LOG_FILE: u64 = 3_000_000; // 3 MB; + const MAX_LOG_FILES: usize = 10; + const LOG_FILE_PREFIX: &'static str = "host"; +} + +pub fn init_log(config: ConfHandle) -> LoggerGuard { + let conf = config.get_conf(); + + devolutions_log::init::( + &conf.log_file, + conf.verbosity_profile.to_log_filter(), + conf.debug.log_directives.as_deref(), + ) + .expect("BUG: Failed to initialize log") +} diff --git a/devolutions-host/src/main.rs b/devolutions-host/src/main.rs new file mode 100644 index 000000000..36ca76a1f --- /dev/null +++ b/devolutions-host/src/main.rs @@ -0,0 +1,45 @@ +// Start the program without a console window. +// It has no effect on platforms other than Windows. +#![windows_subsystem = "windows"] + +#[macro_use] +extern crate tracing; + +use devolutions_host::{get_data_dir, init_log, loop_dvc, ConfHandle}; + +use anyhow::Context; + +use std::sync::mpsc; + +fn main() -> anyhow::Result<()> { + // Ensure per-user data dir exists + + std::fs::create_dir_all(get_data_dir()).context("Failed to create data directory")?; + + let config = ConfHandle::init().context("Failed to initialize configuration")?; + + let _logger_guard = init_log(config.clone()); + + info!("Starting Devolutions Host"); + + // TMP: Copy-paste from MSRDPEX project for testing purposes + #[cfg(windows)] + if config.get_conf().debug.enable_unstable { + loop_dvc(config.clone()); + } + + let (shutdown_tx, shutdown_rx) = mpsc::channel(); + + ctrlc::set_handler(move || { + info!("Ctrl-C received, exiting"); + shutdown_tx.send(()).unwrap(); + }) + .expect("BUG: Failed to set Ctrl-C handler"); + + info!("Waiting for shutdown signal"); + shutdown_rx.recv().unwrap(); + + info!("Exiting Devolutions Host"); + + Ok(()) +} diff --git a/jetsocat/src/pipe.rs b/jetsocat/src/pipe.rs index 06a8ef948..e540b7cea 100644 --- a/jetsocat/src/pipe.rs +++ b/jetsocat/src/pipe.rs @@ -287,8 +287,7 @@ pub async fn open_pipe(mode: PipeMode, proxy_cfg: Option) -> Result #[instrument(skip_all)] pub async fn pipe(mut a: Pipe, mut b: Pipe) -> Result<()> { - use tokio::io::copy_bidirectional_with_sizes; - use tokio::io::AsyncWriteExt as _; + use tokio::io::{copy_bidirectional_with_sizes, AsyncWriteExt as _}; const BUF_SIZE: usize = 16 * 1024; diff --git a/jetsocat/src/utils.rs b/jetsocat/src/utils.rs index 546cb804c..e710b95bd 100644 --- a/jetsocat/src/utils.rs +++ b/jetsocat/src/utils.rs @@ -155,8 +155,7 @@ where + Send + 'static, { - use futures_util::SinkExt as _; - use futures_util::StreamExt as _; + use futures_util::{SinkExt as _, StreamExt as _}; let compat = stream .map(|item| {