diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 6d64b112..2d0a753f 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -445,6 +445,25 @@ jobs: xmake f --CROSSDESK_VERSION=${{ env.VERSION_NUM }} --USE_CUDA=true -y xmake b -vy crossdesk + - name: Test installed Windows autostart and unattended host + run: | + xmake b -vy windows_autostart_test + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + xmake run windows_autostart_test + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + xmake b -vy windows_unattended_policy_test + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + xmake b -vy windows_unattended_security_test + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + xmake run windows_unattended_policy_test + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + xmake run windows_unattended_security_test + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + xmake b -vy windows_service_lifetime_test + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + xmake run windows_service_lifetime_test + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + - name: Package shell: pwsh run: | @@ -501,6 +520,13 @@ jobs: xmake f --CROSSDESK_VERSION=${{ env.VERSION_NUM }} --USE_CUDA=true --CROSSDESK_PORTABLE=true -y xmake b -vy crossdesk + - name: Test portable Windows autostart + run: | + xmake b -vy windows_autostart_test + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + xmake run windows_autostart_test + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + - name: Package Portable shell: pwsh run: | diff --git a/README.md b/README.md index 5d8911c2..c5f27506 100644 --- a/README.md +++ b/README.md @@ -191,7 +191,32 @@ sudo apt install "./crossdesk-linux-amd64-.deb" **CrossDesk Service**(服务名 `CrossDeskService`)提供锁屏、登录、凭据输入及安全桌面的状态上报、`Ctrl+Alt+Del` 发送和键鼠输入转发。 -安装版会注册按需启动的服务,客户端启动时尝试启动它;本机没有 CrossDesk 客户端进程后服务会自动退出。便携版可在提示窗口或“设置 → 锁屏控制服务”中安装服务,需要管理员权限。该服务依赖客户端运行。 +安装版默认注册按需启动的服务,客户端启动时尝试启动它;未启用无人值守时,本机没有 CrossDesk 客户端进程后服务会自动退出。便携版可在提示窗口或“设置 → 锁屏控制服务”中安装服务,需要管理员权限。 + +Windows 的“开机自启”在当前用户登录后启动客户端。安装版使用以最高权限运行的登录计划任务,后续登录无需再次确认 UAC;便携版和 Debug 构建使用当前用户的普通启动项。升级安装版或手动启动新版时,会自动迁移指向该程序的旧启动项。关闭自启会删除相应注册。登录前远控需要另外启用下面的无人值守模式。 + +### 开机后尚未登录时远控 + +安装版支持独立的无人值守被控端。在完整安装目录中打开**管理员 PowerShell**,一次性启用: + +```powershell +.\CrossDesk.exe --unattended-enable +# 等待联网注册后,查看独立设备 ID、密码和 online 状态 +.\CrossDesk.exe --unattended-status +``` + +使用返回的 `device_id` 和 `password` 从另一台设备连接。首次启用时复制当前客户端的服务器和编码设置,单独生成无人值守凭据;它与普通客户端的设备 ID 不同,重启、注销和重新启用时保留。后续执行启用命令会复用机器配置,不会覆盖它。自建服务器的根证书需要安装到本地计算机的信任存储中。 + +启用后,`CrossDeskService` 改为系统启动时自动运行,无需 Windows 自动登录,也不需要保存 Windows 登录密码。服务以 SYSTEM 身份在当前控制台会话中启动无界面的被控进程,复用登录界面抓屏、键鼠及 `Ctrl+Alt+Del` 支持。用户登录、锁屏和关闭普通客户端不会停止它;注销、切换用户或进程崩溃后服务会重新拉起,期间连接可能中断,需要重新连接同一设备 ID。启动时网络尚未就绪会继续重试。 + +无人值守模式提供画面、键鼠、多屏切换和安全桌面控制;不提供声音、文件传输、剪贴板或隐私遮罩。便携版和 Debug 构建不能启用此模式。设置和凭据位于 `%ProgramData%\CrossDeskUnattended`,只允许 SYSTEM 和管理员访问;日志位于其 `logs` 子目录。 + +```powershell +# 停止无人值守被控端,并恢复服务按需启动 +.\CrossDesk.exe --unattended-disable +``` + +关闭普通“开机自启”不会关闭无人值守模式。禁用或卸载服务会取消无人值守启动,但保留受保护的机器配置;若需彻底清除凭据,在服务停止后由管理员删除该目录。
手动部署与服务管理命令 diff --git a/README_EN.md b/README_EN.md index 5a1a8f41..8f1d46ff 100644 --- a/README_EN.md +++ b/README_EN.md @@ -183,7 +183,32 @@ Gestures include one-finger click, two-finger right-click, long-press drag, and **CrossDesk Service** (`CrossDeskService`) reports protected desktop states and forwards `Ctrl+Alt+Del`, keyboard, and mouse input on lock screens, sign-in screens, credential prompts, and secure desktops. -The installer registers an on-demand service. CrossDesk tries to start it on launch, and the service exits when no local CrossDesk client is running. Portable builds offer service installation in the prompt or **Settings → Lock Screen Service**, with administrator privileges. The service depends on a running client. +The installer registers an on-demand service by default. CrossDesk tries to start it on launch. Unless unattended mode is enabled, the service exits when no local CrossDesk client is running. Portable builds offer service installation in the prompt or **Settings → Lock Screen Service**, with administrator privileges. + +On Windows, **Auto Start** launches the client after the current user signs in. Installed release builds use a logon scheduled task with highest privileges, so subsequent logons need no UAC confirmation; portable and Debug builds use a normal per-user startup entry. Upgrading the installed build or launching the new version manually migrates a legacy startup entry pointing to that executable. Turning Auto Start off removes its registration. Remote access before sign-in requires the separate unattended mode below. + +### Remote access before Windows sign-in + +The installed release supports a separate unattended host. Open an **administrator PowerShell** in the complete installation directory and enable it once: + +```powershell +.\CrossDesk.exe --unattended-enable +# After network registration, show its separate device ID, password and online state +.\CrossDesk.exe --unattended-status +``` + +Connect from another device using the returned `device_id` and `password`. First-time setup copies the current client's server and codec preferences and creates separate credentials. This ID differs from the GUI client's ID and persists across reboots, logoffs and re-enabling. Subsequent enable commands retain the machine configuration. Self-hosted server root certificates must be trusted in the Local Computer certificate store. + +Once enabled, `CrossDeskService` starts automatically at system boot. Windows automatic login and saved Windows login passwords are unnecessary. The service launches a windowless SYSTEM host in the active console session and reuses secure-desktop capture, keyboard/mouse input and `Ctrl+Alt+Del`. Signing in, locking Windows or closing the ordinary client does not stop it. Logoff, user switching or a host crash causes the service to restart the host; the connection may drop, and you can reconnect using the same ID. Network connections retry if networking is unavailable during boot. + +Unattended mode supports screen viewing, keyboard/mouse input, monitor switching and secure desktops. Audio, file transfer, clipboard and privacy screens are unavailable. Portable and Debug builds cannot enable it. Configuration and credentials reside in `%ProgramData%\CrossDeskUnattended`, accessible only to SYSTEM and administrators; logs are in its `logs` subdirectory. + +```powershell +# Stop the unattended host and restore on-demand service startup +.\CrossDesk.exe --unattended-disable +``` + +Turning off ordinary Auto Start does not disable unattended mode. Disabling or uninstalling the service disarms unattended startup but retains the protected machine profile. To erase its credentials, stop the service and delete that directory as an administrator.
Manual deployment and service commands diff --git a/apps/desktop/scripts/windows/nsis_script.nsi b/apps/desktop/scripts/windows/nsis_script.nsi index bfd94ee0..37fe919c 100644 --- a/apps/desktop/scripts/windows/nsis_script.nsi +++ b/apps/desktop/scripts/windows/nsis_script.nsi @@ -101,6 +101,15 @@ installApp: Call RegisterInstalledService + ; Replace an existing per-user Run entry only after the new binary is in + ; place. A fresh installation must not opt the user into auto-start. + ClearErrors + ExecWait '"$INSTDIR\CrossDesk.exe" --autostart-migrate' $0 + ${If} ${Errors} + ${OrIf} $0 != 0 + MessageBox MB_ICONEXCLAMATION|MB_OK "Could not migrate CrossDesk auto-start. Open CrossDesk and enable Auto Start again." + ${EndIf} + ; Write uninstall information WriteUninstaller "$INSTDIR\uninstall.exe" @@ -147,6 +156,12 @@ cancelUninstall: Abort uninstallApp: + ClearErrors + ExecWait '"$INSTDIR\CrossDesk.exe" --autostart-uninstall' $0 + ${If} ${Errors} + ${OrIf} $0 != 0 + MessageBox MB_ICONEXCLAMATION|MB_OK "Could not remove CrossDesk auto-start. Remove any remaining CrossDesk Autostart tasks in Task Scheduler." + ${EndIf} Call un.UnregisterInstalledService Call un.RemoveUsbmmiddDriver @@ -218,7 +233,18 @@ Function RegisterInstalledService Abort ${EndIf} - DetailPrint "CrossDesk service registered for on-demand start" + ; Preserve an existing unattended opt-in and resume it after upgrades. + ; A fresh install still registers the ordinary on-demand lock-screen service. + SetRegView 64 + ReadRegDWORD $1 HKLM "Software\CrossDesk\Unattended" "Enabled" + SetRegView 32 + ${If} $1 = 1 + ExecWait '"$INSTDIR\CrossDesk.exe" --service-start' $0 + ${If} $0 != 0 + MessageBox MB_ICONEXCLAMATION|MB_OK "Could not restart the unattended host. Run CrossDesk.exe --service-start from an administrator terminal." + ${EndIf} + ${EndIf} + DetailPrint "CrossDesk service registered" Return @@ -228,6 +254,10 @@ missing_service_binary: FunctionEnd Function un.UnregisterInstalledService + ; Also disarm unattended startup when removal must fall back to sc.exe. + SetRegView 64 + DeleteRegKey HKLM "Software\CrossDesk\Unattended" + SetRegView 32 IfFileExists "$INSTDIR\CrossDesk.exe" 0 unregister_with_sc DetailPrint "Stopping CrossDesk service" diff --git a/apps/desktop/src/app/main.cpp b/apps/desktop/src/app/main.cpp index 327a6dea..3937c141 100644 --- a/apps/desktop/src/app/main.cpp +++ b/apps/desktop/src/app/main.cpp @@ -16,8 +16,11 @@ #ifdef _WIN32 #include +#include "platform/windows/autostart_task.h" #include "platform/windows/gui/slint_backend.h" #include "service_host.h" +#include "service_lifetime.h" +#include "unattended_config.h" #endif #include "config_center.h" @@ -70,6 +73,12 @@ void PrintServiceCliUsage() { << " --service-ping Ping the service over named pipe IPC\n" << " --service-status Query service runtime status\n" << " --service-help Show this help\n"; + std::cout + << " --unattended-enable Enable a separate machine host before login\n" + << " --unattended-disable Stop the machine host and disable boot " + "startup\n" + << " --unattended-status Show machine host status and connection " + "credentials\n"; } std::wstring GetCurrentExecutablePathW() { @@ -97,6 +106,9 @@ bool IsServiceCliCommand(const char* arg) { } return std::strcmp(arg, "--service-install") == 0 || + std::strcmp(arg, "--unattended-enable") == 0 || + std::strcmp(arg, "--unattended-disable") == 0 || + std::strcmp(arg, "--unattended-status") == 0 || std::strcmp(arg, "--service-uninstall") == 0 || std::strcmp(arg, "--service-start") == 0 || std::strcmp(arg, "--service-stop") == 0 || @@ -127,6 +139,97 @@ int HandleServiceCliCommand(const std::string& command) { return 0; } + if (command.rfind("--unattended-", 0) == 0) { + if (!crossdesk::IsAdministratorProcess()) { + std::cerr << "Run this command from an administrator terminal.\n"; + return 1; + } + if (command == "--unattended-status") { + std::cout << crossdesk::ReadUnattendedStatus() << '\n'; + return 0; + } + const auto service = GetSiblingServiceExecutablePath(); + if (service.empty() || !std::filesystem::exists(service)) { + std::cerr << "Install CrossDesk with its Windows service first.\n"; + return 1; + } + if (command == "--unattended-disable") { + // Disarm boot startup even if stopping the current process fails. + const bool saved = crossdesk::SetUnattendedEnabled(false); + const bool configured = + saved && crossdesk::InstallCrossDeskService(service.wstring()); + const bool stopped = crossdesk::StopCrossDeskService(20000); + std::cout << (configured && stopped + ? "Unattended host disabled.\n" + : "Failed to disable unattended host.\n"); + return configured && stopped ? 0 : 1; + } +#if defined(CROSSDESK_DEBUG) || CROSSDESK_PORTABLE + std::cerr << "Unattended mode requires the installed release build.\n"; + return 1; +#else + const bool was_enabled = crossdesk::IsUnattendedEnabled(); + if (!crossdesk::PrepareUnattendedDirectory(true)) { + std::cerr + << "Cannot create a private machine profile. Existing permissive " + "directories and links are rejected.\n"; + return 1; + } + if (crossdesk::IsCrossDeskServiceInstalled() && + !crossdesk::StopCrossDeskService(20000)) { + std::cerr << "Could not stop the service to configure unattended mode.\n"; + return 1; + } + crossdesk::PathManager paths("CrossDesk"); + crossdesk::ConfigCenter source( + (paths.GetCachePath() / "config.ini").string()); + const auto machine_config = crossdesk::UnattendedDataPath() / "config.ini"; + const auto staged_config = + crossdesk::UnattendedDataPath() / "config.pending.ini"; + // Import only server/media preferences. Never share the GUI identity or + // consume its file-transfer paths in the SYSTEM process. + const auto host = source.IsSelfHosted() ? source.GetSignalServerHost() + : source.GetDefaultServerHost(); + const int port = source.IsSelfHosted() + ? source.GetSignalServerPort() + : source.GetDefaultSignalServerPort(); + bool configured = std::filesystem::exists(machine_config); + if (!configured) { + crossdesk::ConfigCenter destination(staged_config.string()); + configured = + destination.SetServerHost(host) == 0 && + destination.SetServerPort(port) == 0 && + destination.SetSelfHosted(source.IsSelfHosted()) == 0 && + destination.SetTurnMode(source.GetTurnMode()) == 0 && + destination.SetVideoEncodeFormat(source.GetVideoEncodeFormat()) == + 0 && + destination.SetHardwareVideoCodec(source.IsHardwareVideoCodec()) == + 0 && + destination.SetScreenCaptureMethod( + crossdesk::ScreenCaptureMethod::Auto) == 0 && + destination.SetPrivacyScreen(false) == 0 && + destination.SetSaveRemotePreviews(false) == 0; + } + if (configured && !std::filesystem::exists(machine_config)) { + configured = MoveFileExW(staged_config.c_str(), machine_config.c_str(), + MOVEFILE_WRITE_THROUGH) != FALSE; + } + if (!configured || !crossdesk::SetUnattendedEnabled(true) || + !crossdesk::InstallCrossDeskService(service.wstring()) || + !crossdesk::StartCrossDeskService()) { + crossdesk::SetUnattendedEnabled(was_enabled); + crossdesk::InstallCrossDeskService(service.wstring()); + if (was_enabled) crossdesk::StartCrossDeskService(); + std::cerr << "Failed to enable unattended host.\n"; + return 1; + } + std::cout << "Unattended host enabled for boot startup.\n" + "After it connects, use --unattended-status to obtain its " + "separate device ID and password.\n"; + return 0; +#endif + } + if (command == "--service-install") { std::filesystem::path service_path = GetSiblingServiceExecutablePath(); if (service_path.empty()) { @@ -218,6 +321,10 @@ static int RunApplication( } #ifdef _WIN32 + if (!crossdesk::platform::MigrateLegacyAutostart("CrossDesk")) { + crossdesk::get_logger()->warn( + "Could not migrate the legacy Windows autostart entry"); + } TryStartManagedWindowsService(); #endif @@ -288,6 +395,41 @@ int main(int argc, char* argv[]) { } #endif #ifdef _WIN32 + if (argc > 1 && std::strcmp(argv[1], "--unattended-host") == 0) { + // Internal service entry point: never elevate an ordinary invocation into + // a SYSTEM host, and never open the GUI or the per-user daemon here. + if (argc != 4 || !crossdesk::IsLocalSystemProcess() || + !crossdesk::IsUnattendedEnabled()) + return 1; + const std::string name(argv[2]); + if (name.rfind("Global\\CrossDeskUnattendedStop-", 0) != 0) return 1; + const std::wstring wide_name(name.begin(), name.end()); + HANDLE stop = OpenEventW(SYNCHRONIZE, FALSE, wide_name.c_str()); + if (!stop) return 1; + const std::string lifetime_name(argv[3]); + crossdesk::ServiceLifetimeWatcher lifetime; + if (!lifetime.Start( + std::wstring(lifetime_name.begin(), lifetime_name.end()))) { + CloseHandle(stop); + return 1; + } + crossdesk::Render render; + const int result = render.RunUnattended(stop); + CloseHandle(stop); + return result; + } + // Installer maintenance must not start the GUI or the process supervisor. + if (argc == 2 && (std::strcmp(argv[1], "--autostart-migrate") == 0 || + std::strcmp(argv[1], "--autostart-uninstall") == 0)) { + crossdesk::PathManager paths("CrossDesk"); + crossdesk::InitLogger(paths.GetLogPath().string(), + "crossdesk-autostart-cli"); + const bool success = + std::strcmp(argv[1], "--autostart-migrate") == 0 + ? crossdesk::platform::MigrateLegacyAutostart("CrossDesk") + : crossdesk::platform::RemoveInstalledAutostart("CrossDesk"); + return success ? 0 : 1; + } if (argc == 2 && std::strcmp(argv[1], crossdesk::kSlintRendererProbeArgument) == 0) { return crossdesk::RunSlintRendererProbe(); diff --git a/apps/desktop/src/config_center/config_center.cpp b/apps/desktop/src/config_center/config_center.cpp index af9cf330..5ce94917 100644 --- a/apps/desktop/src/config_center/config_center.cpp +++ b/apps/desktop/src/config_center/config_center.cpp @@ -312,7 +312,16 @@ int ConfigCenter::SetSelfHosted(bool enabled) { int ConfigCenter::SetAutostart(bool enabled) { const bool previous = IsAutostartEnabled("CrossDesk"); - if (enabled != previous && !(enabled ? EnableAutostart("CrossDesk") : DisableAutostart("CrossDesk"))) { + // Refresh the executable path and remove legacy registrations even when the + // boolean state is unchanged. In particular, a Run entry no longer counts + // as enabled for an elevated Windows build, but still needs deleting. +#ifdef _WIN32 + const bool update_registration = true; +#else + const bool update_registration = enabled != previous; +#endif + if (update_registration && + !(enabled ? EnableAutostart("CrossDesk") : DisableAutostart("CrossDesk"))) { LOG_ERROR("SetAutostart failed"); return -1; } diff --git a/apps/desktop/src/gui/application/gui_application.h b/apps/desktop/src/gui/application/gui_application.h index c368179b..cb70cd53 100644 --- a/apps/desktop/src/gui/application/gui_application.h +++ b/apps/desktop/src/gui/application/gui_application.h @@ -29,6 +29,9 @@ public: ~GuiApplication(); int Run(); +#ifdef _WIN32 + int RunUnattended(void* stop_event); +#endif private: struct SlintUi; diff --git a/apps/desktop/src/gui/features/devices/session_device_manager.cpp b/apps/desktop/src/gui/features/devices/session_device_manager.cpp index 4209edd0..7a08644c 100644 --- a/apps/desktop/src/gui/features/devices/session_device_manager.cpp +++ b/apps/desktop/src/gui/features/devices/session_device_manager.cpp @@ -115,7 +115,7 @@ void SessionDeviceManager::RecordCaptureCadence( } void SessionDeviceManager::Initialize() { - InitializeAudioOutput(); + if (!owner_.unattended_host_) InitializeAudioOutput(); screen_capturer_factory_ = new ScreenCapturerFactory(); device_controller_factory_ = new DeviceControllerFactory(); keyboard_capturer_ = @@ -329,6 +329,7 @@ void SessionDeviceManager::ReleaseRemoteMouseButtons() { #endif void SessionDeviceManager::StartSpeakerCapturer() { + if (owner_.unattended_host_) return; speaker_capture_.SetEnabled(true); } diff --git a/apps/desktop/src/gui/features/settings/settings_manager.cpp b/apps/desktop/src/gui/features/settings/settings_manager.cpp index 612d4fb8..7804ca07 100644 --- a/apps/desktop/src/gui/features/settings/settings_manager.cpp +++ b/apps/desktop/src/gui/features/settings/settings_manager.cpp @@ -229,6 +229,13 @@ bool SettingsManager::ReadV2Locked() { return true; } +std::string SettingsManager::ActiveIdentity() const { + std::lock_guard lock(cache_mutex_); + return owner_.config_center_->IsSelfHosted() + ? cache_v2_.self_hosted_id + : cache_v2_.client_id_with_password; +} + int SettingsManager::Load() { std::unique_lock lock(cache_mutex_); diff --git a/apps/desktop/src/gui/features/settings/settings_manager.h b/apps/desktop/src/gui/features/settings/settings_manager.h index 2a6a4ab9..5e64194d 100644 --- a/apps/desktop/src/gui/features/settings/settings_manager.h +++ b/apps/desktop/src/gui/features/settings/settings_manager.h @@ -20,6 +20,7 @@ public: int Save(); int Load(); + std::string ActiveIdentity() const; int LoadRecentConnectionAliases(); int SaveRecentConnectionAliases() const; diff --git a/apps/desktop/src/gui/render.cpp b/apps/desktop/src/gui/render.cpp index 62b48484..6ecab9d0 100644 --- a/apps/desktop/src/gui/render.cpp +++ b/apps/desktop/src/gui/render.cpp @@ -11,5 +11,10 @@ Render::Render() : application_(std::make_unique()) {} Render::~Render() = default; int Render::Run() { return application_->Run(); } +#ifdef _WIN32 +int Render::RunUnattended(void* stop_event) { + return application_->RunUnattended(stop_event); +} +#endif } // namespace crossdesk diff --git a/apps/desktop/src/gui/render.h b/apps/desktop/src/gui/render.h index 461f53e1..fddc6bb0 100644 --- a/apps/desktop/src/gui/render.h +++ b/apps/desktop/src/gui/render.h @@ -24,6 +24,9 @@ public: Render &operator=(const Render &) = delete; int Run(); +#ifdef _WIN32 + int RunUnattended(void* stop_event); +#endif private: std::unique_ptr application_; diff --git a/apps/desktop/src/gui/runtime/gui_runtime.h b/apps/desktop/src/gui/runtime/gui_runtime.h index fb727cfa..2ff875b1 100644 --- a/apps/desktop/src/gui/runtime/gui_runtime.h +++ b/apps/desktop/src/gui/runtime/gui_runtime.h @@ -100,6 +100,8 @@ class GuiRuntime : protected gui_detail::GuiState { #endif PrivacyController privacy_; + // Set before initializing callbacks; immutable for the process lifetime. + bool unattended_host_ = false; AnnouncementInbox announcements_; PrivacySessionPolicy privacy_sessions_; std::mutex privacy_commands_mutex_; diff --git a/apps/desktop/src/gui/runtime/peer_data_callbacks.cpp b/apps/desktop/src/gui/runtime/peer_data_callbacks.cpp index c031917c..4286ae15 100644 --- a/apps/desktop/src/gui/runtime/peer_data_callbacks.cpp +++ b/apps/desktop/src/gui/runtime/peer_data_callbacks.cpp @@ -91,6 +91,13 @@ void PeerEventHandler::OnReceiveDataBuffer( } std::string source_id = std::string(src_id, src_id_size); + // The service-owned host runs as SYSTEM. Do not expose filesystem or + // clipboard operations in that security context. + if (runtime->unattended_host_ && + (source_id == runtime->file_label_ || + source_id == runtime->file_feedback_label_ || + source_id == runtime->clipboard_label_)) + return; if (source_id == runtime->file_label_) { std::string remote_user_id = std::string(user_id, user_id_size); @@ -209,6 +216,7 @@ void PeerEventHandler::OnReceiveDataBuffer( return; } if (remote_action.type == ControlType::privacy_command) { + if (runtime->unattended_host_) return; if (source_id == runtime->control_data_label_) runtime->QueuePrivacyCommand(remote_id, remote_action.pc); return; diff --git a/apps/desktop/src/gui/runtime/peer_event_handler.cpp b/apps/desktop/src/gui/runtime/peer_event_handler.cpp index fd91aba2..f27915dd 100644 --- a/apps/desktop/src/gui/runtime/peer_event_handler.cpp +++ b/apps/desktop/src/gui/runtime/peer_event_handler.cpp @@ -278,8 +278,8 @@ void PeerEventHandler::OnConnectionStatus(ConnectionStatus status, { RemoteAction remote_action = MakeHostInformation( GetHostName(), runtime->devices_.host_display_list(), - /*supports_privacy_screen=*/true, CROSSDESK_VERSION, - kClientPlatform); + /*supports_privacy_screen=*/!runtime->unattended_host_, + CROSSDESK_VERSION, kClientPlatform); remote_action.i.supports_video_settings = true; for (std::size_t i = 0; i < remote_action.i.display_num; i++) { LOG_INFO("Local display [{}:{}]", i + 1, @@ -413,8 +413,8 @@ void PeerEventHandler::OnConnectionStatus(ConnectionStatus status, { RemoteAction remote_action = MakeHostInformation( GetHostName(), runtime->devices_.host_display_list(), - /*supports_privacy_screen=*/true, CROSSDESK_VERSION, - kClientPlatform); + /*supports_privacy_screen=*/!runtime->unattended_host_, + CROSSDESK_VERSION, kClientPlatform); remote_action.i.supports_video_settings = true; for (std::size_t i = 0; i < remote_action.i.display_num; i++) { LOG_INFO("Local display [{}:{}]", i + 1, diff --git a/apps/desktop/src/gui/runtime/peer_media_callbacks.cpp b/apps/desktop/src/gui/runtime/peer_media_callbacks.cpp index 05a0cc7d..31244d5f 100644 --- a/apps/desktop/src/gui/runtime/peer_media_callbacks.cpp +++ b/apps/desktop/src/gui/runtime/peer_media_callbacks.cpp @@ -201,7 +201,7 @@ void PeerEventHandler::OnReceiveAudioBuffer( auto callback = handler->EnterCallback(); if (!callback) return; GuiRuntime* runtime = handler ? &handler->owner_ : nullptr; - if (!runtime) { + if (!runtime || runtime->unattended_host_) { return; } diff --git a/apps/desktop/src/platform/windows/autostart.cpp b/apps/desktop/src/platform/windows/autostart.cpp index 43eb6668..954bbc06 100644 --- a/apps/desktop/src/platform/windows/autostart.cpp +++ b/apps/desktop/src/platform/windows/autostart.cpp @@ -1,69 +1,322 @@ #include "platform/autostart_backend.h" #include +#include +#include +#include #include +#include #include +#include + +#include "autostart_task.h" +#include "rd_log.h" namespace crossdesk::platform { namespace { -constexpr const char* kRunKey = - "Software\\Microsoft\\Windows\\CurrentVersion\\Run"; +using Microsoft::WRL::ComPtr; +using Bstr = std::unique_ptr; + +constexpr wchar_t kRunKey[] = + L"Software\\Microsoft\\Windows\\CurrentVersion\\Run"; + +Bstr MakeBstr(const std::wstring& value) { + return Bstr(SysAllocString(value.c_str()), SysFreeString); +} + +std::wstring FromUtf8(const std::string& value) { + if (value.empty()) return {}; + const int size = MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, + value.c_str(), -1, nullptr, 0); + if (size <= 0) return {}; + std::wstring result(size, L'\0'); + if (!MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, value.c_str(), -1, + result.data(), size)) + return {}; + result.pop_back(); + return result; +} + +std::wstring CurrentUserSid() { + HANDLE token = nullptr; + if (!OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &token)) return {}; + DWORD size = 0; + GetTokenInformation(token, TokenUser, nullptr, 0, &size); + std::vector data(size); + const bool read = + GetTokenInformation(token, TokenUser, data.data(), size, &size) != FALSE; + CloseHandle(token); + if (!read) return {}; + LPWSTR sid = nullptr; + if (!ConvertSidToStringSidW( + reinterpret_cast(data.data())->User.Sid, &sid)) + return {}; + const std::wstring result(sid); + LocalFree(sid); + return result; +} + +bool Check(HRESULT result, const char* operation) { + if (SUCCEEDED(result)) return true; + LOG_ERROR("Windows autostart {} failed: HRESULT=0x{:08x}", operation, + static_cast(result)); + return false; +} + +class ComApartment { + public: + ComApartment() : result_(CoInitializeEx(nullptr, COINIT_MULTITHREADED)) {} + ~ComApartment() { + if (SUCCEEDED(result_)) CoUninitialize(); + } + bool ready() const { + // Settings can run on a UI thread that already uses a different apartment. + return result_ == RPC_E_CHANGED_MODE || Check(result_, "initialize COM"); + } + + private: + HRESULT result_; +}; + +class TaskScheduler { + private: + // COM interfaces must be released before the apartment is uninitialized. + ComApartment apartment_; + ComPtr service_; + + public: + ComPtr folder; + + bool Open() { + if (!apartment_.ready()) return false; + if (!Check( + CoCreateInstance(CLSID_TaskScheduler, nullptr, CLSCTX_INPROC_SERVER, + IID_PPV_ARGS(service_.GetAddressOf())), + "create scheduler")) + return false; + const VARIANT empty{}; + if (!Check(service_->Connect(empty, empty, empty, empty), "connect")) + return false; + auto root = MakeBstr(L"\\"); + return root && Check(service_->GetFolder(root.get(), folder.GetAddressOf()), + "open task folder"); + } +}; + +LONG ReadRunEntry(const std::wstring& app_name, std::wstring* command) { + DWORD size = 0; + LONG result = RegGetValueW(HKEY_CURRENT_USER, kRunKey, app_name.c_str(), + RRF_RT_REG_SZ, nullptr, nullptr, &size); + if (result != ERROR_SUCCESS) return result; + std::vector buffer(size / sizeof(wchar_t) + 1, L'\0'); + result = RegGetValueW(HKEY_CURRENT_USER, kRunKey, app_name.c_str(), + RRF_RT_REG_SZ, nullptr, buffer.data(), &size); + if (result == ERROR_SUCCESS) *command = buffer.data(); + return result; +} + +bool RemoveRunEntry(const std::wstring& app_name) { + const LONG result = + RegDeleteKeyValueW(HKEY_CURRENT_USER, kRunKey, app_name.c_str()); + if (result == ERROR_SUCCESS || result == ERROR_FILE_NOT_FOUND || + result == ERROR_PATH_NOT_FOUND) + return true; + LOG_ERROR("Windows autostart remove Run entry failed: error={}", result); + return false; +} + +bool EnableRunEntry(const std::wstring& app_name, + const std::wstring& executable) { + HKEY key = nullptr; + const LONG opened = RegCreateKeyExW(HKEY_CURRENT_USER, kRunKey, 0, nullptr, 0, + KEY_SET_VALUE, nullptr, &key, nullptr); + if (opened != ERROR_SUCCESS) { + LOG_ERROR("Windows autostart open Run key failed: error={}", opened); + return false; + } + const std::wstring command = L"\"" + executable + L"\""; + const LONG result = RegSetValueExW( + key, app_name.c_str(), 0, REG_SZ, + reinterpret_cast(command.c_str()), + static_cast((command.size() + 1) * sizeof(wchar_t))); + RegCloseKey(key); + if (result != ERROR_SUCCESS) + LOG_ERROR("Windows autostart set Run entry failed: error={}", result); + return result == ERROR_SUCCESS; +} + +bool RegisterLogonTask(const std::wstring& app_name, + const std::filesystem::path& executable) { + const auto sid = CurrentUserSid(); + if (sid.empty()) return false; + TaskScheduler scheduler; + if (!scheduler.Open()) return false; + auto name = MakeBstr(AutostartTaskName(app_name, sid)); + auto xml = MakeBstr(BuildAutostartTaskXml( + sid, executable.wstring(), executable.parent_path().wstring())); + auto user = MakeBstr(sid); + if (!name || !xml || !user) return false; + VARIANT user_id{}; + user_id.vt = VT_BSTR; + user_id.bstrVal = user.get(); // Borrowed for this synchronous call. + const VARIANT empty{}; + ComPtr task; + return Check(scheduler.folder->RegisterTask( + name.get(), xml.get(), TASK_CREATE_OR_UPDATE, user_id, empty, + TASK_LOGON_INTERACTIVE_TOKEN, empty, task.GetAddressOf()), + "register elevated logon task"); +} + +bool RemoveLogonTask(const std::wstring& app_name) { + const auto sid = CurrentUserSid(); + if (sid.empty()) return false; + TaskScheduler scheduler; + if (!scheduler.Open()) return false; + auto name = MakeBstr(AutostartTaskName(app_name, sid)); + if (!name) return false; + const HRESULT result = scheduler.folder->DeleteTask(name.get(), 0); + return result == HRESULT_FROM_WIN32(ERROR_FILE_NOT_FOUND) || + result == HRESULT_FROM_WIN32(ERROR_PATH_NOT_FOUND) || + Check(result, "delete logon task"); +} } // namespace std::string GetAutostartExecutablePath() { - char path[32768] = {}; - const DWORD length = GetModuleFileNameA(nullptr, path, sizeof(path)); - return length > 0 && length < sizeof(path) ? std::string(path, length) - : std::string(); + std::vector path(32768); + const DWORD length = + GetModuleFileNameW(nullptr, path.data(), static_cast(path.size())); + if (length == 0 || length >= path.size()) return {}; + return std::filesystem::path(path.data(), path.data() + length).u8string(); } bool EnableAutostart(const std::string& app_name, const std::string& executable_path) { - if (!std::filesystem::exists(executable_path)) return false; - - HKEY key = nullptr; - if (RegOpenKeyExA(HKEY_CURRENT_USER, kRunKey, 0, KEY_WRITE, &key) != - ERROR_SUCCESS) { + const auto name = FromUtf8(app_name); + const auto path = FromUtf8(executable_path); + if (name.empty() || path.empty()) return false; + const std::filesystem::path executable(path); + std::error_code error; + if (!executable.is_absolute() || + !std::filesystem::is_regular_file(executable, error)) return false; - } - - std::string value = executable_path; - if (value.find(' ') != std::string::npos && - (value.front() != '"' || value.back() != '"')) { - value = "\"" + value + "\""; - } - const LONG result = RegSetValueExA( - key, app_name.c_str(), 0, REG_SZ, - reinterpret_cast(value.c_str()), - static_cast(value.size() + 1)); - RegCloseKey(key); - return result == ERROR_SUCCESS; + if (!kAutostartRequiresElevation) return EnableRunEntry(name, path); + // Do not remove the old entry unless registering its replacement succeeded. + if (!RegisterLogonTask(name, executable)) return false; + RemoveRunEntry(name); + return true; } bool DisableAutostart(const std::string& app_name) { - HKEY key = nullptr; - if (RegOpenKeyExA(HKEY_CURRENT_USER, kRunKey, 0, KEY_WRITE, &key) != - ERROR_SUCCESS) { - return false; - } - const LONG result = RegDeleteValueA(key, app_name.c_str()); - RegCloseKey(key); - return result == ERROR_SUCCESS || result == ERROR_FILE_NOT_FOUND; + const auto name = FromUtf8(app_name); + if (name.empty()) return false; + // Portable builds must not modify an installed build's elevated task. + if (kAutostartRequiresElevation && !RemoveLogonTask(name)) return false; + return RemoveRunEntry(name); } bool IsAutostartEnabled(const std::string& app_name) { - HKEY key = nullptr; - if (RegOpenKeyExA(HKEY_CURRENT_USER, kRunKey, 0, KEY_READ, &key) != - ERROR_SUCCESS) { - return false; + const auto app = FromUtf8(app_name); + if (app.empty()) return false; + if (!kAutostartRequiresElevation) { + std::wstring command; + return ReadRunEntry(app, &command) == ERROR_SUCCESS; } - const LONG result = - RegQueryValueExA(key, app_name.c_str(), nullptr, nullptr, nullptr, nullptr); - RegCloseKey(key); - return result == ERROR_SUCCESS; + const auto sid = CurrentUserSid(); + if (sid.empty()) return false; + TaskScheduler scheduler; + if (!scheduler.Open()) return false; + auto name = MakeBstr(AutostartTaskName(app, sid)); + if (!name) return false; + ComPtr task; + if (FAILED(scheduler.folder->GetTask(name.get(), task.GetAddressOf()))) + return false; + VARIANT_BOOL enabled = VARIANT_FALSE; + return SUCCEEDED(task->get_Enabled(&enabled)) && enabled == VARIANT_TRUE; +} + +bool MigrateLegacyAutostart(const std::string& app_name) { + if (!kAutostartRequiresElevation) return true; + std::wstring command; + const auto name = FromUtf8(app_name); + if (name.empty()) return false; + const LONG result = ReadRunEntry(name, &command); + if (result == ERROR_FILE_NOT_FOUND || result == ERROR_PATH_NOT_FOUND) + return true; + if (result != ERROR_SUCCESS) return false; + if (command.size() >= 2 && command.front() == L'"' && command.back() == L'"') + command = command.substr(1, command.size() - 2); + const auto executable = GetAutostartExecutablePath(); + if (executable.empty()) return false; + if (_wcsicmp(command.c_str(), FromUtf8(executable).c_str()) != 0) return true; + return EnableAutostart(app_name, executable); +} + +bool RemoveInstalledAutostart(const std::string& app_name) { + const auto name = FromUtf8(app_name); + const auto executable = FromUtf8(GetAutostartExecutablePath()); + if (name.empty() || executable.empty()) return false; + TaskScheduler scheduler; + if (!scheduler.Open()) return false; + ComPtr tasks; + if (!Check(scheduler.folder->GetTasks(TASK_ENUM_HIDDEN, tasks.GetAddressOf()), + "enumerate logon tasks")) + return false; + LONG count = 0; + if (!Check(tasks->get_Count(&count), "count logon tasks")) return false; + const auto prefix = name + L" Autostart "; + std::vector remove; + for (LONG index = 1; index <= count; ++index) { + VARIANT item{}; + item.vt = VT_I4; + item.lVal = index; + ComPtr task; + if (!Check(tasks->get_Item(item, task.GetAddressOf()), "read logon task")) + return false; + BSTR raw_name = nullptr; + if (!Check(task->get_Name(&raw_name), "read task name")) return false; + Bstr task_name(raw_name, SysFreeString); + const std::wstring task_text(task_name.get() ? task_name.get() : L""); + if (task_text.compare(0, prefix.size(), prefix) != 0) continue; + ComPtr definition; + ComPtr actions; + ComPtr action; + ComPtr exec; + LONG action_count = 0; + if (!Check(task->get_Definition(definition.GetAddressOf()), + "read task definition") || + !Check(definition->get_Actions(actions.GetAddressOf()), + "read task actions") || + !Check(actions->get_Count(&action_count), "count task actions")) + return false; + if (action_count != 1) continue; + if (!Check(actions->get_Item(1, action.GetAddressOf()), "read task action")) + return false; + if (FAILED(action.As(&exec))) continue; + BSTR raw_path = nullptr; + if (!Check(exec->get_Path(&raw_path), "read task executable")) return false; + Bstr path(raw_path, SysFreeString); + if (path && _wcsicmp(path.get(), executable.c_str()) == 0) + remove.push_back(task_text); + } + // Collect names first; deleting during enumeration can change its indices. + for (const auto& task : remove) { + auto task_name = MakeBstr(task); + if (!task_name || !Check(scheduler.folder->DeleteTask(task_name.get(), 0), + "remove installed logon task")) + return false; + } + std::wstring command; + const LONG result = ReadRunEntry(name, &command); + if (result == ERROR_FILE_NOT_FOUND || result == ERROR_PATH_NOT_FOUND) + return true; + if (result != ERROR_SUCCESS) return false; + if (command.size() >= 2 && command.front() == L'"' && command.back() == L'"') + command = command.substr(1, command.size() - 2); + return _wcsicmp(command.c_str(), executable.c_str()) != 0 || + RemoveRunEntry(name); } } // namespace crossdesk::platform diff --git a/apps/desktop/src/platform/windows/autostart_task.h b/apps/desktop/src/platform/windows/autostart_task.h new file mode 100644 index 00000000..366f7f68 --- /dev/null +++ b/apps/desktop/src/platform/windows/autostart_task.h @@ -0,0 +1,88 @@ +#ifndef CROSSDESK_WINDOWS_AUTOSTART_TASK_H_ +#define CROSSDESK_WINDOWS_AUTOSTART_TASK_H_ + +#include + +namespace crossdesk::platform { + +// Keep this in sync with crossdesk.rc and crossdesk_portable.rc. Elevating a +// portable build manually must not change its normal login startup policy. +#if !defined(CROSSDESK_DEBUG) && \ + (!defined(CROSSDESK_PORTABLE) || !CROSSDESK_PORTABLE) +inline constexpr bool kAutostartRequiresElevation = true; +#else +inline constexpr bool kAutostartRequiresElevation = false; +#endif + +inline std::wstring AutostartTaskName(const std::wstring& app_name, + const std::wstring& user_sid) { + return app_name + L" Autostart " + user_sid; +} + +inline std::wstring EscapeTaskXml(const std::wstring& value) { + std::wstring escaped; + for (const wchar_t character : value) { + switch (character) { + case L'&': + escaped += L"&"; + break; + case L'<': + escaped += L"<"; + break; + case L'>': + escaped += L">"; + break; + case L'"': + escaped += L"""; + break; + case L'\'': + escaped += L"'"; + break; + default: + escaped += character; + break; + } + } + return escaped; +} + +inline std::wstring BuildAutostartTaskXml(const std::wstring& user_sid, + const std::wstring& executable, + const std::wstring& directory) { + const auto sid = EscapeTaskXml(user_sid); + // InteractiveToken keeps the GUI in this user's logged-in desktop and does + // not store a password. A remote desktop client must also survive battery + // operation and the scheduler's default three-day execution time limit. + return L"" + L"true" + L"PT0S" + + sid + + L"" + L"" + + sid + + L"InteractiveToken" + L"HighestAvailable" + L"IgnoreNew" + L"false" + L"false" + L"false" + L"truePT0S" + L"" + + EscapeTaskXml(executable) + L"" + + EscapeTaskXml(directory) + + L""; +} + +// Called at normal startup and by the installer after an upgrade. Only migrate +// a legacy Run entry pointing to this executable, leaving other copies alone. +bool MigrateLegacyAutostart(const std::string& app_name); + +// Installer cleanup: remove tasks for this executable across users, preserving +// registrations belonging to other installation/portable directories. +bool RemoveInstalledAutostart(const std::string& app_name); + +} // namespace crossdesk::platform + +#endif diff --git a/apps/desktop/src/platform/windows/gui/application/unattended_application.cpp b/apps/desktop/src/platform/windows/gui/application/unattended_application.cpp new file mode 100644 index 00000000..fc669a24 --- /dev/null +++ b/apps/desktop/src/platform/windows/gui/application/unattended_application.cpp @@ -0,0 +1,77 @@ +#include "application/gui_application.h" + +#include +#include + +#include "rd_log.h" +#include "unattended_config.h" + +namespace crossdesk { + +int GuiApplication::RunUnattended(void* stop_event) { + if (!stop_event || !IsLocalSystemProcess() || !IsUnattendedEnabled() || + !PrepareUnattendedDirectory(false)) + return 1; + unattended_host_ = true; + DWORD session = 0; + if (!ProcessIdToSessionId(GetCurrentProcessId(), &session) || session == 0) + return 1; + + path_manager_ = std::make_unique("CrossDesk"); + const auto root = UnattendedDataPath(); + cache_path_ = root.string(); + exec_log_path_ = (root / L"logs").string(); + dll_log_path_ = exec_log_path_; + InitializeLogger(); + config_center_ = std::make_unique(cache_path_ + "/config.ini"); + if (config_center_->SetPrivacyScreen(false) != 0 || + config_center_->SetSaveRemotePreviews(false) != 0) + return 1; + InitializeSettings(); + + // The transport and device controllers do not need a Slint window, an SDL + // video driver, a tray icon, or a logged-in user's audio/clipboard session. + if (!SDL_Init(SDL_INIT_EVENTS)) return 1; + devices_.Initialize(); + ULONGLONG last_status = 0; + ULONGLONG next_peer_attempt = 0; + while (WaitForSingleObject(stop_event, 16) == WAIT_TIMEOUT) { + HandleSessionCleanup(); + HandlePasswordChangeResult(); + HandleCredentialRecovery(); + const auto now = GetTickCount64(); + if (!peer_ && now >= next_peer_attempt) { + next_peer_attempt = now + 3000; + if (CreateConnectionPeer() != 0) CloseConnectionPeer(); + } + HandleConnectionStatusChange(); + HandleWindowsServiceIntegration(); + devices_.UpdateInteractions(); + ShareLocalCursorState(); + if (now - last_status >= 1000) { + last_status = now; + if (!WriteUnattendedStatus(settings_.ActiveIdentity(), + signal_connected_.load(), session)) { + LOG_WARN("Could not publish unattended host status"); + } + } + // Pump native device messages without constructing an application window. + MSG message{}; + while (PeekMessageW(&message, nullptr, 0, 0, PM_REMOVE)) { + TranslateMessage(&message); + DispatchMessageW(&message); + } + } + + // Quiesce network callbacks before destroying the controllers they use. + CloseAllRemoteSessions(); + WaitForSessionCleanup(); + keyboard_.ReleaseAllRemotePressedKeys("unattended_host_exit"); + devices_.DestroyDevices(); + devices_.DestroyFactories(); + privacy_.Shutdown(); + WriteUnattendedStatus(settings_.ActiveIdentity(), false, session); + SDL_Quit(); + return 0; +} +} // namespace crossdesk diff --git a/apps/desktop/src/platform/windows/service/service_host.cpp b/apps/desktop/src/platform/windows/service/service_host.cpp index f907270e..46ba7955 100644 --- a/apps/desktop/src/platform/windows/service/service_host.cpp +++ b/apps/desktop/src/platform/windows/service/service_host.cpp @@ -20,6 +20,8 @@ #include "path_manager.h" #include "rd_log.h" #include "session_helper_shared.h" +#include "unattended_config.h" +#include "unattended_policy.h" namespace crossdesk { @@ -1007,7 +1009,7 @@ DWORD WINAPI CrossDeskServiceHost::ServiceControlHandler( return NO_ERROR; case SERVICE_CONTROL_STOP: case SERVICE_CONTROL_SHUTDOWN: - instance_->ReportServiceStatus(SERVICE_STOP_PENDING, NO_ERROR, 3000); + instance_->ReportServiceStatus(SERVICE_STOP_PENDING, NO_ERROR, 20000); instance_->RequestStop(); return NO_ERROR; case SERVICE_CONTROL_SESSIONCHANGE: { @@ -1054,6 +1056,28 @@ int CrossDeskServiceHost::InitializeRuntime() { return static_cast(error); } + GUID lifetime_id{}; + wchar_t lifetime_guid[40]{}; + if (FAILED(CoCreateGuid(&lifetime_id)) || + !StringFromGUID2(lifetime_id, lifetime_guid, 40)) + return ERROR_GEN_FAILURE; + lifetime_mutex_name_ = + L"Global\\CrossDeskServiceLifetime-" + std::wstring(lifetime_guid); + PSECURITY_DESCRIPTOR lifetime_descriptor = nullptr; + if (!ConvertStringSecurityDescriptorToSecurityDescriptorW( + L"D:P(A;;GA;;;SY)(A;;GA;;;BA)(A;;0x00100000;;;AU)", SDDL_REVISION_1, + &lifetime_descriptor, nullptr)) + return static_cast(GetLastError()); + SECURITY_ATTRIBUTES lifetime_attributes{sizeof(SECURITY_ATTRIBUTES), + lifetime_descriptor, FALSE}; + // Owned by this service-main thread until every child has been stopped. + lifetime_mutex_ = + CreateMutexW(&lifetime_attributes, TRUE, lifetime_mutex_name_.c_str()); + const DWORD lifetime_error = GetLastError(); + LocalFree(lifetime_descriptor); + if (!lifetime_mutex_ || lifetime_error == ERROR_ALREADY_EXISTS) + return ERROR_INVALID_HANDLE; + started_at_tick_ = GetTickCount64(); last_sas_tick_ = 0; active_session_id_ = WTSGetActiveConsoleSessionId(); @@ -1123,9 +1147,6 @@ int CrossDeskServiceHost::InitializeRuntime() { } void CrossDeskServiceHost::ShutdownRuntime() { - StopSecureInputHelper(); - StopSessionHelper(); - if (stop_event_ != nullptr) { SetEvent(stop_event_); } @@ -1133,10 +1154,18 @@ void CrossDeskServiceHost::ShutdownRuntime() { if (client_process_monitor_thread_.joinable()) { client_process_monitor_thread_.join(); } + StopUnattendedHost(); if (ipc_thread_.joinable()) { ipc_thread_.join(); } + StopSecureInputHelper(); + StopSessionHelper(); + if (lifetime_mutex_) { + ReleaseMutex(lifetime_mutex_); + CloseHandle(lifetime_mutex_); + lifetime_mutex_ = nullptr; + } if (stop_event_ != nullptr) { CloseHandle(stop_event_); @@ -1152,6 +1181,7 @@ void CrossDeskServiceHost::RequestStop() { void CrossDeskServiceHost::ClientProcessMonitorLoop() { const ULONGLONG monitor_started_at = GetTickCount64(); + uint64_t logoff_generation = unattended_logoff_generation_.load(); while (stop_event_ != nullptr) { DWORD wait_result = @@ -1163,6 +1193,34 @@ void CrossDeskServiceHost::ClientProcessMonitorLoop() { continue; } + const bool enabled = IsUnattendedEnabled(); + const uint64_t generation = unattended_logoff_generation_.load(); + if (generation != logoff_generation) { + logoff_generation = generation; + StopUnattendedHost(); + // Windows may destroy/recreate Default during logoff, even when the + // numeric console session ID does not change. Rebind on the next tick. + continue; + } + if (unattended_process_ && + WaitForSingleObject(unattended_process_, 0) != WAIT_TIMEOUT) { + StopUnattendedHost(); + } + const DWORD console_session = WTSGetActiveConsoleSessionId(); + const auto action = + UnattendedAction(enabled, console_session, + unattended_process_ != nullptr, unattended_session_); + if (action == UnattendedProcessAction::stop) StopUnattendedHost(); + if (action == UnattendedProcessAction::start) { + if (!LaunchUnattendedHost(console_session)) { + // Boot can precede creation of WinSta0 or network availability. + WaitForSingleObject(stop_event_, 4000); + } + } + // An enabled host is independent of the GUI, including while Windows is + // between console sessions or the host is retrying startup. + if (enabled) continue; + if (GetTickCount64() - monitor_started_at < kCrossDeskClientMonitorStartupGraceMs) { continue; @@ -1178,6 +1236,88 @@ void CrossDeskServiceHost::ClientProcessMonitorLoop() { } } +bool CrossDeskServiceHost::LaunchUnattendedHost(DWORD session_id) { + if (!IsLocalSystemProcess() || !PrepareUnattendedDirectory(false)) { + LOG_ERROR("Unattended host requires SYSTEM and a private machine profile"); + return false; + } + const auto executable = + std::filesystem::path(GetCurrentExecutablePathW()).parent_path() / + L"CrossDesk.exe"; + GUID nonce{}; + wchar_t guid[40]{}; + if (FAILED(CoCreateGuid(&nonce)) || !StringFromGUID2(nonce, guid, 40)) + return false; + const std::wstring event_name = + L"Global\\CrossDeskUnattendedStop-" + std::wstring(guid); + PSECURITY_DESCRIPTOR descriptor = nullptr; + if (!ConvertStringSecurityDescriptorToSecurityDescriptorW( + L"D:P(A;;GA;;;SY)", SDDL_REVISION_1, &descriptor, nullptr)) + return false; + SECURITY_ATTRIBUTES attributes{sizeof(SECURITY_ATTRIBUTES), descriptor, + FALSE}; + HANDLE event = CreateEventW(&attributes, TRUE, FALSE, event_name.c_str()); + const DWORD event_error = GetLastError(); + LocalFree(descriptor); + if (!event || event_error == ERROR_ALREADY_EXISTS) { + if (event) CloseHandle(event); + return false; + } + HANDLE token = nullptr; + DWORD error = 0; + ScopedEnvironmentBlock environment; + PROCESS_INFORMATION process{}; + STARTUPINFOW startup{}; + startup.cb = sizeof(startup); + wchar_t desktop[] = L"winsta0\\default"; + startup.lpDesktop = desktop; + std::wstring command = QuoteWindowsArgument(executable.wstring()) + + L" --unattended-host " + + QuoteWindowsArgument(event_name) + L" " + + QuoteWindowsArgument(lifetime_mutex_name_); + bool created = false; + if (CreateSessionSystemToken(session_id, &token, &error)) { + if (CreateEnvironmentBlock(&environment.environment, token, FALSE)) { + created = CreateProcessAsUserW( + token, executable.c_str(), command.data(), nullptr, nullptr, + FALSE, CREATE_UNICODE_ENVIRONMENT | CREATE_NO_WINDOW, + environment.environment, executable.parent_path().c_str(), + &startup, &process) != FALSE; + } + error = created ? ERROR_SUCCESS : GetLastError(); + CloseHandle(token); + } + if (process.hThread) CloseHandle(process.hThread); + if (!created) { + CloseHandle(event); + LOG_WARN("Unattended host launch failed: session={}, error={}", session_id, + error); + return false; + } + unattended_process_ = process.hProcess; + unattended_stop_event_ = event; + unattended_session_ = session_id; + LOG_INFO("Unattended host started: session={}, pid={}", session_id, + process.dwProcessId); + return true; +} + +void CrossDeskServiceHost::StopUnattendedHost() { + if (unattended_stop_event_) SetEvent(unattended_stop_event_); + if (unattended_process_) WaitForSingleObject(unattended_process_, 5000); + if (unattended_process_ && + WaitForSingleObject(unattended_process_, 0) == WAIT_TIMEOUT) + TerminateProcess(unattended_process_, ERROR_PROCESS_ABORTED); + if (unattended_process_) { + // Do not start another instance until the previous identity has exited. + WaitForSingleObject(unattended_process_, INFINITE); + CloseHandle(unattended_process_); + } + if (unattended_stop_event_) CloseHandle(unattended_stop_event_); + unattended_process_ = unattended_stop_event_ = nullptr; + unattended_session_ = 0xFFFFFFFF; +} + void CrossDeskServiceHost::ReportServiceStatus(DWORD current_state, DWORD win32_exit_code, DWORD wait_hint) { @@ -1226,7 +1366,7 @@ int CrossDeskServiceHost::RunServiceLoop(bool as_service) { if (console_mode_) { SetConsoleCtrlHandler(&CrossDeskServiceHost::ConsoleControlHandler, TRUE); - std::cout << "CrossDesk service skeleton running in console mode. Press " + std::cout << "CrossDesk service running in console mode. Press " "Ctrl+C to stop." << std::endl; } @@ -1234,7 +1374,7 @@ int CrossDeskServiceHost::RunServiceLoop(bool as_service) { WaitForSingleObject(stop_event_, INFINITE); if (as_service) { - ReportServiceStatus(SERVICE_STOP_PENDING, NO_ERROR, 3000); + ReportServiceStatus(SERVICE_STOP_PENDING, NO_ERROR, 20000); } ShutdownRuntime(); @@ -1448,6 +1588,9 @@ bool CrossDeskServiceHost::ShouldKeepSecureInputHelperLocked( } std::string CrossDeskServiceHost::ResolveInteractiveStageLocked() const { + // Session 0's input desktop cannot describe a console that has no user + // helper yet. Pre-login must always use the SYSTEM secure-desktop path. + if (prelogin_) return "credential-ui"; if (IsSasSecureDesktopGraceActiveLocked() && (session_helper_report_interactive_stage_.empty() || session_helper_report_interactive_stage_ == "user-desktop")) { @@ -1472,6 +1615,7 @@ std::string CrossDeskServiceHost::ResolveInteractiveStageLocked() const { std::string CrossDeskServiceHost::ResolveInteractiveDesktopLocked( const std::string& interactive_stage) const { + if (prelogin_) return "Winlogon"; if (session_helper_status_ok_ && session_helper_report_input_desktop_available_ && !session_helper_report_input_desktop_.empty() && @@ -1694,10 +1838,11 @@ bool CrossDeskServiceHost::LaunchSessionHelper(DWORD session_id) { return false; } - std::wstring command_line = QuoteWindowsArgument(helper_path) + - L" --session-helper --session-id " + - std::to_wstring(session_id) + L" --stop-event " + - QuoteWindowsArgument(stop_event_name); + std::wstring command_line = + QuoteWindowsArgument(helper_path) + L" --session-helper --session-id " + + std::to_wstring(session_id) + L" --stop-event " + + QuoteWindowsArgument(stop_event_name) + L" --service-lifetime " + + QuoteWindowsArgument(lifetime_mutex_name_); std::wstring mutable_command_line = command_line; STARTUPINFOW startup_info{}; @@ -1756,6 +1901,13 @@ bool CrossDeskServiceHost::LaunchSessionHelper(DWORD session_id) { } } + if (!created) { + CloseHandle(stop_event_handle); + LOG_ERROR("Could not launch a supervised session helper, session={}", + session_id); + return false; + } + CloseHandle(process_info.hThread); { std::lock_guard lock(state_mutex_); @@ -1803,10 +1955,11 @@ bool CrossDeskServiceHost::LaunchSecureInputHelper( return false; } - std::wstring command_line = QuoteWindowsArgument(helper_path) + - L" --secure-input-helper --session-id " + - std::to_wstring(session_id) + L" --stop-event " + - QuoteWindowsArgument(stop_event_name); + std::wstring command_line = + QuoteWindowsArgument(helper_path) + + L" --secure-input-helper --session-id " + std::to_wstring(session_id) + + L" --stop-event " + QuoteWindowsArgument(stop_event_name) + + L" --service-lifetime " + QuoteWindowsArgument(lifetime_mutex_name_); std::wstring mutable_command_line = command_line; STARTUPINFOW startup_info{}; @@ -1851,6 +2004,13 @@ bool CrossDeskServiceHost::LaunchSecureInputHelper( } } + if (!created) { + CloseHandle(stop_event_handle); + LOG_ERROR("Could not launch a supervised session helper, session={}", + session_id); + return false; + } + CloseHandle(process_info.hThread); { std::lock_guard lock(state_mutex_); @@ -1985,6 +2145,10 @@ void CrossDeskServiceHost::RefreshSessionHelperReportedState() { void CrossDeskServiceHost::RecordSessionEvent(DWORD event_type, DWORD session_id) { + if (event_type == WTS_SESSION_LOGOFF) { + std::lock_guard lock(state_mutex_); + if (session_id == active_session_id_) ++unattended_logoff_generation_; + } { std::lock_guard lock(state_mutex_); last_session_event_type_ = event_type; @@ -2444,9 +2608,11 @@ bool InstallCrossDeskService(const std::wstring& binary_path) { } std::wstring service_command = L"\"" + binary_path + L"\" --service"; + const DWORD start_type = + IsUnattendedEnabled() ? SERVICE_AUTO_START : SERVICE_DEMAND_START; SC_HANDLE service = CreateServiceW( manager, kCrossDeskServiceName, kCrossDeskServiceDisplayName, - SERVICE_ALL_ACCESS, SERVICE_WIN32_OWN_PROCESS, SERVICE_DEMAND_START, + SERVICE_ALL_ACCESS, SERVICE_WIN32_OWN_PROCESS, start_type, SERVICE_ERROR_NORMAL, service_command.c_str(), nullptr, nullptr, nullptr, nullptr, nullptr); @@ -2460,14 +2626,14 @@ bool InstallCrossDeskService(const std::wstring& binary_path) { service = OpenServiceW(manager, kCrossDeskServiceName, SERVICE_CHANGE_CONFIG | SERVICE_QUERY_STATUS | - READ_CONTROL | WRITE_DAC); + SERVICE_START | READ_CONTROL | WRITE_DAC); if (service == nullptr) { LOG_ERROR("OpenServiceW failed, error={}", GetLastError()); CloseServiceHandle(manager); return false; } - if (!ChangeServiceConfigW(service, SERVICE_NO_CHANGE, SERVICE_DEMAND_START, + if (!ChangeServiceConfigW(service, SERVICE_NO_CHANGE, start_type, SERVICE_NO_CHANGE, service_command.c_str(), nullptr, nullptr, nullptr, nullptr, nullptr, kCrossDeskServiceDisplayName)) { @@ -2484,6 +2650,21 @@ bool InstallCrossDeskService(const std::wstring& binary_path) { return false; } + SC_ACTION actions[] = {{SC_ACTION_RESTART, 5000}, + {SC_ACTION_RESTART, 15000}, + {SC_ACTION_RESTART, 60000}}; + SERVICE_FAILURE_ACTIONSW recovery{}; + recovery.dwResetPeriod = 86400; + recovery.cActions = static_cast(sizeof(actions) / sizeof(actions[0])); + recovery.lpsaActions = actions; + if (!ChangeServiceConfig2W(service, SERVICE_CONFIG_FAILURE_ACTIONS, + &recovery)) { + LOG_ERROR("Could not configure service recovery, error={}", GetLastError()); + CloseServiceHandle(service); + CloseServiceHandle(manager); + return false; + } + CloseServiceHandle(service); CloseServiceHandle(manager); return true; @@ -2609,6 +2790,7 @@ bool StopCrossDeskService(DWORD timeout_ms) { } bool UninstallCrossDeskService() { + if (!SetUnattendedEnabled(false)) return false; SC_HANDLE manager = OpenSCManagerW(nullptr, nullptr, SC_MANAGER_ALL_ACCESS); if (manager == nullptr) { LOG_ERROR("OpenSCManagerW failed, error={}", GetLastError()); @@ -2628,7 +2810,11 @@ bool UninstallCrossDeskService() { return false; } - StopCrossDeskService(); + if (!StopCrossDeskService()) { + CloseServiceHandle(service); + CloseServiceHandle(manager); + return false; + } bool success = DeleteService(service) != FALSE; if (!success) { diff --git a/apps/desktop/src/platform/windows/service/service_host.h b/apps/desktop/src/platform/windows/service/service_host.h index 9008b98a..7265b646 100644 --- a/apps/desktop/src/platform/windows/service/service_host.h +++ b/apps/desktop/src/platform/windows/service/service_host.h @@ -10,6 +10,7 @@ #include #include +#include #include #include #include @@ -35,6 +36,8 @@ class CrossDeskServiceHost { void ShutdownRuntime(); void RequestStop(); void ClientProcessMonitorLoop(); + bool LaunchUnattendedHost(DWORD session_id); + void StopUnattendedHost(); void ReportServiceStatus(DWORD current_state, DWORD win32_exit_code, DWORD wait_hint); void IpcServerLoop(); @@ -87,7 +90,14 @@ class CrossDeskServiceHost { HANDLE stop_event_ = nullptr; std::thread ipc_thread_; std::thread client_process_monitor_thread_; + // Owned exclusively by the monitor, then drained after it joins. + HANDLE unattended_process_ = nullptr; + HANDLE unattended_stop_event_ = nullptr; + DWORD unattended_session_ = 0xFFFFFFFF; + std::atomic unattended_logoff_generation_{0}; std::mutex state_mutex_; + HANDLE lifetime_mutex_ = nullptr; + std::wstring lifetime_mutex_name_; DWORD active_session_id_ = 0xFFFFFFFF; DWORD process_session_id_ = 0xFFFFFFFF; DWORD input_desktop_error_code_ = 0; @@ -154,7 +164,7 @@ bool IsCrossDeskServiceInstalled(); bool InstallCrossDeskService(const std::wstring& binary_path); bool UninstallCrossDeskService(); bool StartCrossDeskService(); -bool StopCrossDeskService(DWORD timeout_ms = 5000); +bool StopCrossDeskService(DWORD timeout_ms = 20000); std::string QueryCrossDeskService(const std::string& command, DWORD timeout_ms = 1000); std::string SendCrossDeskSecureDesktopKeyInput(int key_code, bool is_down, diff --git a/apps/desktop/src/platform/windows/service/service_lifetime.h b/apps/desktop/src/platform/windows/service/service_lifetime.h new file mode 100644 index 00000000..f56b5219 --- /dev/null +++ b/apps/desktop/src/platform/windows/service/service_lifetime.h @@ -0,0 +1,49 @@ +#pragma once + +#include + +#include +#include + +namespace crossdesk { + +// The service's main thread owns a Global mutex for its entire lifetime. +// Unlike a process handle or a job created in Session 0, this can be waited +// on by both SYSTEM and ordinary-user helpers in another Windows session. +class ServiceLifetimeWatcher { + public: + ServiceLifetimeWatcher() = default; + ServiceLifetimeWatcher(const ServiceLifetimeWatcher&) = delete; + ServiceLifetimeWatcher& operator=(const ServiceLifetimeWatcher&) = delete; + ~ServiceLifetimeWatcher() { + if (cancel_) SetEvent(cancel_); + if (thread_.joinable()) thread_.join(); + if (cancel_) CloseHandle(cancel_); + if (lifetime_) CloseHandle(lifetime_); + } + + bool Start(const std::wstring& name) { + if (lifetime_ || name.empty()) return false; + lifetime_ = OpenMutexW(SYNCHRONIZE, FALSE, name.c_str()); + if (!lifetime_) return false; + cancel_ = CreateEventW(nullptr, TRUE, FALSE, nullptr); + if (!cancel_) return false; + thread_ = std::thread([this] { + HANDLE handles[] = {lifetime_, cancel_}; + const DWORD result = WaitForMultipleObjects(2, handles, FALSE, INFINITE); + if (result != WAIT_OBJECT_0 + 1) { + // Includes abandoned ownership after a service crash. Do not wait for + // transport/capture cleanup that could otherwise leave an orphan + // online. + TerminateProcess(GetCurrentProcess(), ERROR_BROKEN_PIPE); + } + }); + return true; + } + + private: + HANDLE lifetime_ = nullptr; + HANDLE cancel_ = nullptr; + std::thread thread_; +}; +} // namespace crossdesk diff --git a/apps/desktop/src/platform/windows/service/session_helper_main.cpp b/apps/desktop/src/platform/windows/service/session_helper_main.cpp index c9c9751e..f0fdbdf8 100644 --- a/apps/desktop/src/platform/windows/service/session_helper_main.cpp +++ b/apps/desktop/src/platform/windows/service/session_helper_main.cpp @@ -26,6 +26,7 @@ #include "path_manager.h" #include "rd_log.h" #include "session_helper_shared.h" +#include "service_lifetime.h" #include "usbmmidd_policy.h" #include "usbmmidd_virtual_display.h" #include "windows_cursor_state.h" @@ -1967,6 +1968,7 @@ int main(int argc, char* argv[]) { bool run_secure_input_helper = false; DWORD expected_session_id = 0xFFFFFFFF; std::wstring stop_event_name; + std::wstring service_lifetime_name; for (int index = 1; index < argc; ++index) { std::string argument = argv[index]; if (argument == "--session-helper") { @@ -1985,6 +1987,10 @@ int main(int argc, char* argv[]) { stop_event_name = Utf8ToWide(argv[++index]); continue; } + if (argument == "--service-lifetime" && index + 1 < argc) { + service_lifetime_name = Utf8ToWide(argv[++index]); + continue; + } } if (!run_helper && !run_secure_input_helper) { @@ -1993,6 +1999,9 @@ int main(int argc, char* argv[]) { } DWORD current_session_id = 0xFFFFFFFF; + crossdesk::ServiceLifetimeWatcher lifetime; + if (!service_lifetime_name.empty() && !lifetime.Start(service_lifetime_name)) + return 1; ProcessIdToSessionId(GetCurrentProcessId(), ¤t_session_id); HANDLE stop_event = nullptr; if (!stop_event_name.empty()) { diff --git a/apps/desktop/src/platform/windows/service/unattended_config.cpp b/apps/desktop/src/platform/windows/service/unattended_config.cpp new file mode 100644 index 00000000..e1a9e5ae --- /dev/null +++ b/apps/desktop/src/platform/windows/service/unattended_config.cpp @@ -0,0 +1,184 @@ +#include "unattended_config.h" + +#include +#include +#include + +#include +#include +#include +#include + +namespace crossdesk { +namespace { +constexpr wchar_t kRegistryKey[] = L"SOFTWARE\\CrossDesk\\Unattended"; +constexpr wchar_t kDirectorySecurity[] = + L"O:BAG:BAD:P(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)"; + +bool TrustedSid(PSID sid) { + return sid && (IsWellKnownSid(sid, WinLocalSystemSid) || + IsWellKnownSid(sid, WinBuiltinAdministratorsSid)); +} + +bool PrivateDirectory(const std::filesystem::path& path) { + const DWORD attributes = GetFileAttributesW(path.c_str()); + if (attributes == INVALID_FILE_ATTRIBUTES || + !(attributes & FILE_ATTRIBUTE_DIRECTORY) || + (attributes & FILE_ATTRIBUTE_REPARSE_POINT)) + return false; + PSID owner = nullptr; + PACL dacl = nullptr; + PSECURITY_DESCRIPTOR descriptor = nullptr; + if (GetNamedSecurityInfoW( + const_cast(path.c_str()), SE_FILE_OBJECT, + OWNER_SECURITY_INFORMATION | DACL_SECURITY_INFORMATION, &owner, + nullptr, &dacl, nullptr, &descriptor) != ERROR_SUCCESS) + return false; + bool safe = TrustedSid(owner) && dacl && dacl->AceCount > 0; + for (DWORD index = 0; safe && index < dacl->AceCount; ++index) { + void* raw = nullptr; + if (!GetAce(dacl, index, &raw)) { + safe = false; + break; + } + auto* ace = static_cast(raw); + // Only SYSTEM and elevated administrators can read credentials or write + // configuration that the service will consume as SYSTEM. + safe = + ace->Header.AceType == ACCESS_ALLOWED_ACE_TYPE && + TrustedSid(&ace->SidStart) && + (ace->Header.AceFlags & (OBJECT_INHERIT_ACE | CONTAINER_INHERIT_ACE)) == + (OBJECT_INHERIT_ACE | CONTAINER_INHERIT_ACE); + } + LocalFree(descriptor); + return safe; +} +} // namespace + +std::filesystem::path UnattendedDataPath() { + PWSTR value = nullptr; + if (FAILED(SHGetKnownFolderPath(FOLDERID_ProgramData, KF_FLAG_DEFAULT, + nullptr, &value))) + return {}; + const auto path = std::filesystem::path(value) / L"CrossDeskUnattended"; + CoTaskMemFree(value); + return path; +} + +bool IsUnattendedEnabled() { + DWORD enabled = 0; + DWORD size = sizeof(enabled); + return RegGetValueW(HKEY_LOCAL_MACHINE, kRegistryKey, L"Enabled", + RRF_RT_REG_DWORD | RRF_SUBKEY_WOW6464KEY, nullptr, + &enabled, &size) == ERROR_SUCCESS && + enabled == 1; +} + +bool SetUnattendedEnabled(bool enabled) { + if (!IsAdministratorProcess()) return false; + HKEY key = nullptr; + if (RegCreateKeyExW(HKEY_LOCAL_MACHINE, kRegistryKey, 0, nullptr, 0, + KEY_SET_VALUE | KEY_WOW64_64KEY, nullptr, &key, + nullptr) != ERROR_SUCCESS) + return false; + const DWORD value = enabled ? 1 : 0; + const auto result = + RegSetValueExW(key, L"Enabled", 0, REG_DWORD, + reinterpret_cast(&value), sizeof(value)); + RegCloseKey(key); + return result == ERROR_SUCCESS; +} + +bool IsAdministratorProcess() { + BYTE sid[SECURITY_MAX_SID_SIZE]; + DWORD size = sizeof(sid); + BOOL member = FALSE; + return CreateWellKnownSid(WinBuiltinAdministratorsSid, nullptr, sid, &size) && + CheckTokenMembership(nullptr, sid, &member) && member; +} + +bool IsLocalSystemProcess() { + HANDLE token = nullptr; + if (!OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &token)) return false; + DWORD size = 0; + GetTokenInformation(token, TokenUser, nullptr, 0, &size); + std::vector buffer(size); + const bool system = + GetTokenInformation(token, TokenUser, buffer.data(), size, &size) && + IsWellKnownSid(reinterpret_cast(buffer.data())->User.Sid, + WinLocalSystemSid); + CloseHandle(token); + return system; +} + +bool PrepareUnattendedDirectory(bool create) { + return unattended_detail::PreparePrivateDirectory(UnattendedDataPath(), + create); +} + +bool unattended_detail::PreparePrivateDirectory( + const std::filesystem::path& root, bool create) { + if (root.empty()) return false; + if (create) { + PSECURITY_DESCRIPTOR descriptor = nullptr; + if (!ConvertStringSecurityDescriptorToSecurityDescriptorW( + kDirectorySecurity, SDDL_REVISION_1, &descriptor, nullptr)) + return false; + SECURITY_ATTRIBUTES attributes{sizeof(SECURITY_ATTRIBUTES), descriptor, + FALSE}; + const bool created = CreateDirectoryW(root.c_str(), &attributes) != FALSE; + const DWORD error = created ? ERROR_SUCCESS : GetLastError(); + LocalFree(descriptor); + if (!created && error != ERROR_ALREADY_EXISTS) return false; + } + return PrivateDirectory(root); +} + +bool WriteUnattendedStatus(const std::string& identity, bool online, + DWORD session_id) { + const auto root = UnattendedDataPath(); + if (root.empty()) return false; + const auto separator = identity.find('@'); + nlohmann::json status = { + {"online", online}, + {"process_id", GetCurrentProcessId()}, + {"session_id", session_id}, + {"updated_at", std::time(nullptr)}, + {"device_id", + separator == std::string::npos ? "" : identity.substr(0, separator)}, + {"password", + separator == std::string::npos ? "" : identity.substr(separator + 1)}}; + const auto temporary = root / L"status.tmp"; + std::ofstream output(temporary, std::ios::binary | std::ios::trunc); + output << status.dump(2); + output.close(); + return !output.fail() && + MoveFileExW(temporary.c_str(), (root / L"status.json").c_str(), + MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH); +} + +std::string ReadUnattendedStatus() { + nlohmann::json status = {{"enabled", IsUnattendedEnabled()}, + {"online", false}}; + if (!IsAdministratorProcess() || !PrepareUnattendedDirectory(false)) { + status["error"] = "administrator_required_or_private_directory_unavailable"; + return status.dump(2); + } + std::ifstream input(UnattendedDataPath() / L"status.json", std::ios::binary); + auto saved = nlohmann::json::parse(input, nullptr, false); + if (saved.is_object()) { + status.update(saved); + const auto updated = status.value("updated_at", std::time_t{0}); + const auto now = std::time(nullptr); + HANDLE process = + OpenProcess(SYNCHRONIZE | PROCESS_QUERY_LIMITED_INFORMATION, FALSE, + status.value("process_id", 0u)); + const bool running = + process && WaitForSingleObject(process, 0) == WAIT_TIMEOUT; + if (process) CloseHandle(process); + status["online"] = IsUnattendedEnabled() && running && updated <= now && + now - updated < 10 && status.value("online", false); + } + return status.dump(2); +} +} // namespace crossdesk diff --git a/apps/desktop/src/platform/windows/service/unattended_config.h b/apps/desktop/src/platform/windows/service/unattended_config.h new file mode 100644 index 00000000..c718bf34 --- /dev/null +++ b/apps/desktop/src/platform/windows/service/unattended_config.h @@ -0,0 +1,27 @@ +#pragma once + +#include + +#include +#include + +namespace crossdesk { + +// This directory is deliberately separate from every user's GUI profile. +std::filesystem::path UnattendedDataPath(); +bool IsUnattendedEnabled(); +bool SetUnattendedEnabled(bool enabled); +bool IsLocalSystemProcess(); +bool IsAdministratorProcess(); +// Reject reparse points, untrusted owners and permissive existing DACLs. +bool PrepareUnattendedDirectory(bool create); +namespace unattended_detail { +// Parameterized only for isolated native ACL tests; production always uses +// the fixed ProgramData path above. +bool PreparePrivateDirectory(const std::filesystem::path& root, bool create); +} // namespace unattended_detail +bool WriteUnattendedStatus(const std::string& identity, bool online, + DWORD session_id); +std::string ReadUnattendedStatus(); + +} // namespace crossdesk diff --git a/apps/desktop/src/platform/windows/service/unattended_policy.h b/apps/desktop/src/platform/windows/service/unattended_policy.h new file mode 100644 index 00000000..b81fda33 --- /dev/null +++ b/apps/desktop/src/platform/windows/service/unattended_policy.h @@ -0,0 +1,24 @@ +#pragma once + +#include + +namespace crossdesk { +enum class UnattendedProcessAction { keep, start, stop }; + +// No logged-in-user condition: a console at the sign-in screen is a valid +// target. Lock/unlock and GUI presence must not interrupt an existing host. +inline UnattendedProcessAction UnattendedAction(bool enabled, + uint32_t console_session, + bool running, + uint32_t process_session) { + constexpr uint32_t unavailable = 0xFFFFFFFFu; + const bool target = + enabled && console_session != unavailable && console_session != 0; + if (running) + return target && process_session == console_session + ? UnattendedProcessAction::keep + : UnattendedProcessAction::stop; + return target ? UnattendedProcessAction::start + : UnattendedProcessAction::keep; +} +} // namespace crossdesk diff --git a/apps/desktop/tests/build_define_scope_test.lua b/apps/desktop/tests/build_define_scope_test.lua index df8f70be..14d47a2b 100644 --- a/apps/desktop/tests/build_define_scope_test.lua +++ b/apps/desktop/tests/build_define_scope_test.lua @@ -58,6 +58,10 @@ function main(plat, portable) end if plat == "windows" then + for _, name in ipairs({"autostart", "windows_autostart_test"}) do + assert(contains(effective_defines(targets[name]), "CROSSDESK_PORTABLE=1") == + (portable == "true"), name .. " has inconsistent autostart privilege policy") + end local resources = { crossdesk = portable == "true" and "crossdesk_portable" or "crossdesk", crossdesk_service = "crossdesk_service", diff --git a/apps/desktop/tests/windows_autostart_task_test.py b/apps/desktop/tests/windows_autostart_task_test.py new file mode 100644 index 00000000..48d8d23a --- /dev/null +++ b/apps/desktop/tests/windows_autostart_task_test.py @@ -0,0 +1,84 @@ +"""Validate generated Task Scheduler XML on macOS/Linux without changing startup. + +Run: python3 apps/desktop/tests/windows_autostart_task_test.py +Native registration/migration coverage lives in windows_autostart_test.cpp. +""" +import pathlib +import shutil +import subprocess +import tempfile +import unittest +import xml.etree.ElementTree as ET + +ROOT = pathlib.Path(__file__).resolve().parents[3] +NAMESPACE = {"t": "http://schemas.microsoft.com/windows/2004/02/mit/task"} + + +class AutostartTaskTest(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.temporary = tempfile.TemporaryDirectory(prefix="crossdesk-autostart-test-") + cls.addClassCleanup(cls.temporary.cleanup) + directory = pathlib.Path(cls.temporary.name) + source = directory / "fixture.cpp" + source.write_text(r''' +#include +#include "platform/windows/autostart_task.h" +int main() { + std::cout << crossdesk::platform::kAutostartRequiresElevation << '\n'; + const auto xml = crossdesk::platform::BuildAutostartTaskXml( + L"S-1-5-21-123-456-789-1001", L"C:\\程序 & Tools\\CrossDesk.exe", + L"C:\\程序 & Tools"); + for (wchar_t character : xml) std::cout << static_cast(character) << ' '; +} +''', encoding="utf-8") + compiler = shutil.which("c++") or shutil.which("clang++") + if not compiler: + raise unittest.SkipTest("C++17 compiler is unavailable") + cls.variants = {} + for name, defines in { + "installed": [], "portable": ["-DCROSSDESK_PORTABLE=1"], + "debug": ["-DCROSSDESK_DEBUG"], + "portable_debug": ["-DCROSSDESK_PORTABLE=1", "-DCROSSDESK_DEBUG"], + }.items(): + binary = directory / name + subprocess.run([compiler, "-std=c++17", "-Wall", "-Wextra", "-Werror", + "-I", str(ROOT / "apps/desktop/src"), *defines, + str(source), "-o", str(binary)], check=True) + policy, codepoints = subprocess.check_output([str(binary)], text=True).split("\n", 1) + xml = "".join(chr(int(value)) for value in codepoints.split()) + cls.variants[name] = (policy, ET.fromstring(xml)) + + def text(self, path): + return self.variants["installed"][1].findtext(path, namespaces=NAMESPACE) + + def test_privilege_policy_matches_build_type(self): + self.assertEqual({name: result[0] for name, result in self.variants.items()}, + {"installed": "1", "portable": "0", "debug": "0", "portable_debug": "0"}) + + def test_login_targets_same_interactive_user_without_password(self): + sid = "S-1-5-21-123-456-789-1001" + self.assertEqual(self.text("t:Triggers/t:LogonTrigger/t:UserId"), sid) + self.assertEqual(self.text("t:Principals/t:Principal/t:UserId"), sid) + self.assertEqual(self.text("t:Principals/t:Principal/t:LogonType"), "InteractiveToken") + self.assertEqual(self.text("t:Principals/t:Principal/t:RunLevel"), "HighestAvailable") + self.assertEqual(self.text("t:Triggers/t:LogonTrigger/t:Enabled"), "true") + + def test_command_is_not_shell_interpreted_and_unicode_round_trips(self): + self.assertEqual(self.text("t:Actions/t:Exec/t:Command"), r"C:\程序 & Tools\CrossDesk.exe") + self.assertEqual(self.text("t:Actions/t:Exec/t:WorkingDirectory"), r"C:\程序 & Tools") + self.assertIsNone(self.text("t:Actions/t:Exec/t:Arguments")) + task = self.variants["installed"][1] + self.assertEqual(task.find("t:Actions", NAMESPACE).get("Context"), + task.find("t:Principals/t:Principal", NAMESPACE).get("id")) + + def test_remote_client_can_run_indefinitely_on_battery(self): + self.assertEqual(self.text("t:Triggers/t:LogonTrigger/t:ExecutionTimeLimit"), "PT0S") + self.assertEqual(self.text("t:Settings/t:DisallowStartIfOnBatteries"), "false") + self.assertEqual(self.text("t:Settings/t:StopIfGoingOnBatteries"), "false") + self.assertEqual(self.text("t:Settings/t:ExecutionTimeLimit"), "PT0S") + self.assertEqual(self.text("t:Settings/t:MultipleInstancesPolicy"), "IgnoreNew") + + +if __name__ == "__main__": + unittest.main() diff --git a/apps/desktop/tests/windows_autostart_test.cpp b/apps/desktop/tests/windows_autostart_test.cpp new file mode 100644 index 00000000..a552bb7b --- /dev/null +++ b/apps/desktop/tests/windows_autostart_test.cpp @@ -0,0 +1,278 @@ +// Run from an elevated Windows terminal (the Windows CI runner is elevated). +// Uses a unique registration name and removes it before returning. +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +#include "platform/autostart_backend.h" +#include "platform/windows/autostart_task.h" +#include "rd_log.h" + +namespace { +using Microsoft::WRL::ComPtr; +namespace platform = crossdesk::platform; +constexpr wchar_t kRunKey[] = + L"Software\\Microsoft\\Windows\\CurrentVersion\\Run"; + +bool Expect(bool condition, const char* message) { + if (!condition) std::cerr << "FAIL: " << message << '\n'; + return condition; +} + +std::wstring UserSid() { + HANDLE token = nullptr; + if (!OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &token)) return {}; + DWORD size = 0; + GetTokenInformation(token, TokenUser, nullptr, 0, &size); + std::vector data(size); + const BOOL read = + GetTokenInformation(token, TokenUser, data.data(), size, &size); + CloseHandle(token); + if (!read) return {}; + LPWSTR sid = nullptr; + if (!ConvertSidToStringSidW( + reinterpret_cast(data.data())->User.Sid, &sid)) + return {}; + const std::wstring result(sid); + LocalFree(sid); + return result; +} + +bool SetRun(const std::wstring& name, const std::wstring& path) { + HKEY key = nullptr; + if (RegCreateKeyExW(HKEY_CURRENT_USER, kRunKey, 0, nullptr, 0, KEY_SET_VALUE, + nullptr, &key, nullptr) != ERROR_SUCCESS) + return false; + const std::wstring command = L"\"" + path + L"\""; + const LONG result = RegSetValueExW( + key, name.c_str(), 0, REG_SZ, + reinterpret_cast(command.c_str()), + static_cast((command.size() + 1) * sizeof(wchar_t))); + RegCloseKey(key); + return result == ERROR_SUCCESS; +} + +std::wstring ReadRun(const std::wstring& name) { + wchar_t value[32768] = {}; + DWORD size = sizeof(value); + if (RegGetValueW(HKEY_CURRENT_USER, kRunKey, name.c_str(), RRF_RT_REG_SZ, + nullptr, value, &size) != ERROR_SUCCESS) + return {}; + return value; +} + +bool CheckTask(ITaskFolder* folder, BSTR task_name, + const std::filesystem::path& executable, + const std::wstring& sid) { + ComPtr task; + if (!Expect(SUCCEEDED(folder->GetTask(task_name, task.GetAddressOf())), + "task exists")) + return false; + ComPtr definition; + if (FAILED(task->get_Definition(definition.GetAddressOf()))) return false; + ComPtr principal; + if (FAILED(definition->get_Principal(principal.GetAddressOf()))) return false; + TASK_RUNLEVEL level = TASK_RUNLEVEL_LUA; + TASK_LOGON_TYPE logon = TASK_LOGON_NONE; + bool ok = Expect(SUCCEEDED(principal->get_RunLevel(&level)) && + level == TASK_RUNLEVEL_HIGHEST, + "task requests elevation"); + ok &= Expect(SUCCEEDED(principal->get_LogonType(&logon)) && + logon == TASK_LOGON_INTERACTIVE_TOKEN, + "task uses interactive user"); + BSTR user = nullptr; + ok &= Expect(SUCCEEDED(principal->get_UserId(&user)) && user && sid == user, + "task belongs to current user SID"); + SysFreeString(user); + + ComPtr triggers; + ComPtr trigger; + ComPtr logon_trigger; + if (FAILED(definition->get_Triggers(triggers.GetAddressOf())) || + FAILED(triggers->get_Item(1, trigger.GetAddressOf())) || + FAILED(trigger.As(&logon_trigger))) + return false; + user = nullptr; + ok &= + Expect(SUCCEEDED(logon_trigger->get_UserId(&user)) && user && sid == user, + "logon trigger is restricted to the same user"); + SysFreeString(user); + + ComPtr settings; + if (FAILED(definition->get_Settings(settings.GetAddressOf()))) return false; + VARIANT_BOOL value = VARIANT_TRUE; + ok &= Expect(SUCCEEDED(settings->get_DisallowStartIfOnBatteries(&value)) && + value == VARIANT_FALSE, + "starts on battery"); + ok &= Expect(SUCCEEDED(settings->get_StopIfGoingOnBatteries(&value)) && + value == VARIANT_FALSE, + "survives switching to battery"); + BSTR limit = nullptr; + ok &= Expect(SUCCEEDED(settings->get_ExecutionTimeLimit(&limit)) && limit && + std::wstring(limit) == L"PT0S", + "has no three-day time limit"); + SysFreeString(limit); + + ComPtr actions; + ComPtr action; + ComPtr exec; + if (FAILED(definition->get_Actions(actions.GetAddressOf())) || + FAILED(actions->get_Item(1, action.GetAddressOf())) || + FAILED(action.As(&exec))) + return false; + BSTR path = nullptr; + ok &= Expect( + SUCCEEDED(exec->get_Path(&path)) && path && executable.wstring() == path, + "Unicode command round-trips"); + SysFreeString(path); + path = nullptr; + ok &= Expect(SUCCEEDED(exec->get_WorkingDirectory(&path)) && path && + executable.parent_path().wstring() == path, + "working directory is executable directory"); + SysFreeString(path); + return ok; +} + +int RunTests(ITaskFolder* folder) { + const std::string app = + "CrossDeskAutostartTest-" + std::to_string(GetCurrentProcessId()); + const std::wstring name(app.begin(), app.end()); + const auto sid = UserSid(); + if (sid.empty()) return 1; + const auto task_name = platform::AutostartTaskName(name, sid); + BSTR task_bstr = SysAllocString(task_name.c_str()); + if (!task_bstr) return 1; + const auto executable = + std::filesystem::u8path(platform::GetAutostartExecutablePath()); + const auto temporary = std::filesystem::temp_directory_path() / + (name + L" \u81ea\u542f & spaces"); + std::filesystem::create_directories(temporary); + const auto alternate = temporary / L"CrossDesk \u6d4b\u8bd5.exe"; + // Registration never executes this file. Exercise XML/path handling without + // leaving another runnable test program in the login path. + std::ofstream(alternate) << "autostart registration test"; + struct Cleanup { + ITaskFolder* folder; + BSTR task; + std::wstring name; + std::filesystem::path directory; + ~Cleanup() { + folder->DeleteTask(task, 0); + SysFreeString(task); + RegDeleteKeyValueW(HKEY_CURRENT_USER, kRunKey, name.c_str()); + std::error_code error; + std::filesystem::remove_all(directory, error); + } + } cleanup{folder, task_bstr, name, temporary}; + + bool ok = Expect(platform::DisableAutostart(app), + "disable absent entry is idempotent"); + ok &= Expect(!platform::IsAutostartEnabled(app), "initially disabled"); + ok &= Expect(platform::MigrateLegacyAutostart(app), + "migration without a legacy entry is a no-op"); + ok &= Expect(!platform::IsAutostartEnabled(app), + "migration never opts user in"); + ok &= + Expect(SetRun(name, alternate.wstring()), "seed another copy's startup"); + ok &= Expect(platform::MigrateLegacyAutostart(app), + "ignore another copy during migration"); + ok &= Expect(ReadRun(name) == L"\"" + alternate.wstring() + L"\"", + "another copy's registration is unchanged"); + ok &= Expect(SetRun(name, executable.wstring()), "seed legacy Run entry"); + ok &= Expect( + !platform::EnableAutostart(app, (temporary / L"missing.exe").u8string()), + "reject missing executable"); + ok &= Expect(!ReadRun(name).empty(), + "failed registration preserves legacy entry"); + ok &= Expect(platform::MigrateLegacyAutostart(app), "migrate legacy entry"); + ok &= Expect(platform::IsAutostartEnabled(app), "enabled after migration"); + + if (platform::kAutostartRequiresElevation) { + ok &= Expect(ReadRun(name).empty(), + "remove legacy entry after successful migration"); + ok &= CheckTask(folder, task_bstr, executable, sid); + } else { + ComPtr absent; + ok &= Expect(FAILED(folder->GetTask(task_bstr, absent.GetAddressOf())), + "portable/debug does not create elevated task"); + ok &= Expect(!ReadRun(name).empty(), + "portable/debug retains ordinary startup"); + } + ok &= Expect(platform::EnableAutostart(app, alternate.u8string()), + "update executable path"); + ok &= Expect(platform::EnableAutostart(app, alternate.u8string()), + "enable is idempotent"); + if (platform::kAutostartRequiresElevation) { + ok &= CheckTask(folder, task_bstr, alternate, sid); + ComPtr task; + if (SUCCEEDED(folder->GetTask(task_bstr, task.GetAddressOf()))) { + ok &= Expect(SUCCEEDED(task->put_Enabled(VARIANT_FALSE)), + "disable task externally"); + ok &= Expect(!platform::IsAutostartEnabled(app), + "disabled task is not reported enabled"); + ok &= Expect(platform::EnableAutostart(app, alternate.u8string()), + "reenable task"); + } else { + ok = false; + } + } else { + ok &= Expect(ReadRun(name) == L"\"" + alternate.wstring() + L"\"", + "Run command quotes Unicode path with spaces"); + } + ok &= Expect(platform::RemoveInstalledAutostart(app), + "uninstall skips another executable"); + ok &= Expect(platform::IsAutostartEnabled(app), + "other executable keeps its startup"); + ok &= Expect(platform::EnableAutostart(app, executable.u8string()), + "restore own executable"); + ok &= Expect(platform::RemoveInstalledAutostart(app), + "uninstall removes own registration"); + ok &= Expect(!platform::IsAutostartEnabled(app), + "uninstalled executable is no longer scheduled"); + ok &= Expect(platform::EnableAutostart(app, executable.u8string()), + "reenable before disable test"); + // Disabling must clean up a leftover legacy entry as well as the task. + ok &= + Expect(SetRun(name, executable.wstring()), "seed leftover legacy entry"); + ok &= + Expect(platform::DisableAutostart(app), "disable existing registration"); + ok &= Expect(ReadRun(name).empty() && !platform::IsAutostartEnabled(app), + "all active registrations removed"); + ok &= Expect(platform::DisableAutostart(app), "disable twice succeeds"); + return ok ? 0 : 1; +} +} // namespace + +int main() { + crossdesk::InitLogger(std::filesystem::temp_directory_path().string(), + "crossdesk-autostart-test"); + // Exercise the existing-STA case used by GUI settings as well as normal COM + // cleanup: the backend must not uninitialize this caller-owned apartment. + if (FAILED(CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED))) return 1; + int result = 1; + { + ComPtr service; + ComPtr folder; + const VARIANT empty{}; + BSTR root = SysAllocString(L"\\"); + if (root && + SUCCEEDED(CoCreateInstance(CLSID_TaskScheduler, nullptr, + CLSCTX_INPROC_SERVER, + IID_PPV_ARGS(service.GetAddressOf()))) && + SUCCEEDED(service->Connect(empty, empty, empty, empty)) && + SUCCEEDED(service->GetFolder(root, folder.GetAddressOf()))) { + result = RunTests(folder.Get()); + } + SysFreeString(root); + } + CoUninitialize(); + if (result == 0) std::cout << "Windows autostart integration tests passed\n"; + return result; +} diff --git a/apps/desktop/tests/windows_service_lifetime_test.cpp b/apps/desktop/tests/windows_service_lifetime_test.cpp new file mode 100644 index 00000000..ab76439a --- /dev/null +++ b/apps/desktop/tests/windows_service_lifetime_test.cpp @@ -0,0 +1,84 @@ +#include + +#include +#include +#include + +#include "service_lifetime.h" + +int wmain(int argc, wchar_t** argv) { + if (argc == 4 && std::wstring(argv[1]) == L"--child") { + crossdesk::ServiceLifetimeWatcher watcher; + if (!watcher.Start(argv[2])) return 2; + HANDLE ready = OpenEventW(EVENT_MODIFY_STATE, FALSE, argv[3]); + if (!ready) return 3; + SetEvent(ready); + CloseHandle(ready); + Sleep(INFINITE); // The real watcher must terminate this child. + return 4; + } + + wchar_t executable[32768]{}; + if (!GetModuleFileNameW(nullptr, executable, 32768)) return 1; + for (bool abandon : {false, true}) { + const auto suffix = std::to_wstring(GetCurrentProcessId()) + + (abandon ? L"-abandon" : L"-release"); + const auto name = L"Global\\CrossDeskLifetimeTest-" + suffix; + const auto ready_name = name + L"-ready"; + HANDLE owned = CreateEventW(nullptr, TRUE, FALSE, nullptr); + HANDLE exit_owner = CreateEventW(nullptr, TRUE, FALSE, nullptr); + HANDLE ready = CreateEventW(nullptr, TRUE, FALSE, ready_name.c_str()); + if (!owned || !exit_owner || !ready) return 1; + HANDLE lifetime = nullptr; + std::thread owner([&] { + lifetime = CreateMutexW(nullptr, TRUE, name.c_str()); + SetEvent(owned); + WaitForSingleObject(exit_owner, INFINITE); + if (!abandon && lifetime) ReleaseMutex(lifetime); + }); + WaitForSingleObject(owned, INFINITE); + bool ok = lifetime != nullptr; + if (ok) { + // Cancellation during an ordinary helper exit must not wait for or + // release the service's ownership. + crossdesk::ServiceLifetimeWatcher cancellation; + ok = cancellation.Start(name); + } + std::wstring command = L"\"" + std::wstring(executable) + L"\" --child \"" + + name + L"\" \"" + ready_name + L"\""; + STARTUPINFOW startup{}; + startup.cb = sizeof(startup); + PROCESS_INFORMATION child{}; + if (ok) + ok = CreateProcessW(executable, command.data(), nullptr, nullptr, FALSE, + CREATE_NO_WINDOW, nullptr, nullptr, &startup, + &child) != FALSE; + if (ok) + ok = WaitForSingleObject(ready, 10000) == WAIT_OBJECT_0 && + WaitForSingleObject(child.hProcess, 0) == WAIT_TIMEOUT; + SetEvent(exit_owner); + owner.join(); + if (ok) ok = WaitForSingleObject(child.hProcess, 5000) == WAIT_OBJECT_0; + DWORD exit_code = 0; + if (ok) + ok = GetExitCodeProcess(child.hProcess, &exit_code) && + exit_code == ERROR_BROKEN_PIPE; + if (child.hProcess) { + if (!ok) TerminateProcess(child.hProcess, 1); + WaitForSingleObject(child.hProcess, 5000); + CloseHandle(child.hProcess); + CloseHandle(child.hThread); + } + if (lifetime) CloseHandle(lifetime); + CloseHandle(owned); + CloseHandle(exit_owner); + CloseHandle(ready); + if (!ok) { + std::cerr << "Service lifetime test failed, abandoned=" << abandon + << '\n'; + return 1; + } + } + std::cout + << "Service release, crash and watcher cancellation checks passed\n"; +} diff --git a/apps/desktop/tests/windows_unattended_policy_test.cpp b/apps/desktop/tests/windows_unattended_policy_test.cpp new file mode 100644 index 00000000..e3776621 --- /dev/null +++ b/apps/desktop/tests/windows_unattended_policy_test.cpp @@ -0,0 +1,37 @@ +#include +#include + +#include "platform/windows/service/unattended_policy.h" + +int main() { + using crossdesk::UnattendedAction; + using Action = crossdesk::UnattendedProcessAction; + const auto expect = [](bool value, const char* message) { + if (!value) { + std::cerr << message << '\n'; + std::exit(1); + } + }; + constexpr uint32_t none = 0xFFFFFFFFu; + expect(UnattendedAction(false, 1, false, none) == Action::keep, + "fresh installs must not start a network host"); + expect(UnattendedAction(true, none, false, none) == Action::keep, + "boot before a console exists must wait"); + expect(UnattendedAction(true, 0, false, none) == Action::keep, + "never capture service session zero"); + expect(UnattendedAction(true, 1, false, none) == Action::start, + "a pre-login console must start the host"); + expect(UnattendedAction(true, 1, true, 1) == Action::keep, + "login, lock and GUI exit must retain the same host"); + expect(UnattendedAction(true, 2, true, 1) == Action::stop, + "user switching must stop capture in the old console first"); + expect(UnattendedAction(true, 2, false, none) == Action::start, + "a replacement console must start after the old host exits"); + expect(UnattendedAction(true, none, true, 2) == Action::stop, + "a detached console must not keep sending frames"); + expect(UnattendedAction(false, 2, true, 2) == Action::stop, + "disabling unattended mode must stop its host"); + expect(UnattendedAction(true, 2, false, 2) == Action::start, + "a crashed host must restart even if its session is unchanged"); + std::cout << "Unattended process lifecycle checks passed\n"; +} diff --git a/apps/desktop/tests/windows_unattended_security_test.cpp b/apps/desktop/tests/windows_unattended_security_test.cpp new file mode 100644 index 00000000..e43fc90f --- /dev/null +++ b/apps/desktop/tests/windows_unattended_security_test.cpp @@ -0,0 +1,107 @@ +#include +#include +#include + +#include +#include +#include + +#include "unattended_config.h" + +namespace { +bool Check(bool condition, const char* message) { + if (!condition) std::cerr << message << ", Win32=" << GetLastError() << '\n'; + return condition; +} +} // namespace + +int main() { + using crossdesk::unattended_detail::PreparePrivateDirectory; + if (!Check(crossdesk::IsAdministratorProcess(), "Run this test elevated")) + return 1; + const auto root = + std::filesystem::temp_directory_path() / + (L"crossdesk-unattended-test-" + std::to_wstring(GetCurrentProcessId())); + struct Cleanup { + std::filesystem::path path; + ~Cleanup() { + std::error_code error; + std::filesystem::remove_all(path, error); + } + } cleanup{root}; + // Never reuse/remove a directory we did not create for this test. + if (std::filesystem::exists(root)) { + cleanup.path.clear(); + return 1; + } + if (!Check(PreparePrivateDirectory(root, true), "create private profile") || + !Check(PreparePrivateDirectory(root, true), + "idempotent profile validation")) + return 1; + const auto secret = root / L"credential.txt"; + { + std::ofstream output(secret); + output << "test credential"; + } + + HANDLE token = nullptr, restricted = nullptr; + BYTE admin_sid[SECURITY_MAX_SID_SIZE]; + DWORD sid_size = sizeof(admin_sid); + if (!CreateWellKnownSid(WinBuiltinAdministratorsSid, nullptr, admin_sid, + &sid_size) || + !OpenProcessToken(GetCurrentProcess(), + TOKEN_DUPLICATE | TOKEN_QUERY | TOKEN_ASSIGN_PRIMARY | + TOKEN_IMPERSONATE, + &token)) + return 1; + SID_AND_ATTRIBUTES disabled{admin_sid, 0}; + const bool restricted_ok = + CreateRestrictedToken(token, DISABLE_MAX_PRIVILEGE, 1, &disabled, 0, + nullptr, 0, nullptr, &restricted) != FALSE; + CloseHandle(token); + if (!Check(restricted_ok, "create unprivileged token")) return 1; + const bool impersonated = ImpersonateLoggedOnUser(restricted) != FALSE; + bool denied = false; + if (impersonated) { + HANDLE file = CreateFileW(secret.c_str(), GENERIC_READ, FILE_SHARE_READ, + nullptr, OPEN_EXISTING, 0, nullptr); + denied = + file == INVALID_HANDLE_VALUE && GetLastError() == ERROR_ACCESS_DENIED; + if (file != INVALID_HANDLE_VALUE) CloseHandle(file); + if (!RevertToSelf()) std::terminate(); + } + CloseHandle(restricted); + if (!Check(impersonated && denied, + "ordinary token must not read credentials")) + return 1; + + const auto link = root / L"link"; + if (!Check(CreateSymbolicLinkW(link.c_str(), root.c_str(), + SYMBOLIC_LINK_FLAG_DIRECTORY) != FALSE, + "create directory link") || + !Check(!PreparePrivateDirectory(link, false), + "reject directory reparse points")) + return 1; + std::filesystem::remove(link); + + PSECURITY_DESCRIPTOR descriptor = nullptr; + if (!ConvertStringSecurityDescriptorToSecurityDescriptorW( + L"D:P(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)(A;OICI;GR;;;WD)", + SDDL_REVISION_1, &descriptor, nullptr)) + return 1; + BOOL present = FALSE, defaulted = FALSE; + PACL dacl = nullptr; + GetSecurityDescriptorDacl(descriptor, &present, &dacl, &defaulted); + const auto result = SetNamedSecurityInfoW( + const_cast(root.c_str()), SE_FILE_OBJECT, + DACL_SECURITY_INFORMATION | PROTECTED_DACL_SECURITY_INFORMATION, nullptr, + nullptr, dacl, nullptr); + LocalFree(descriptor); + if (!Check(result == ERROR_SUCCESS, "make profile permissive") || + !Check(!PreparePrivateDirectory(root, false), + "reject exposed credentials") || + !Check(!PreparePrivateDirectory(root, true), + "never silently adopt an unsafe profile")) + return 1; + std::cout << "Unattended profile ACL and reparse-point checks passed\n"; +} diff --git a/apps/desktop/xmake/targets.lua b/apps/desktop/xmake/targets.lua index 5e774d68..8dce5a33 100644 --- a/apps/desktop/xmake/targets.lua +++ b/apps/desktop/xmake/targets.lua @@ -184,6 +184,12 @@ function setup_targets() set_default(false) add_files("apps/desktop/tests/windows_service_mouse_ipc_test.cpp") + target("windows_unattended_policy_test") + set_kind("binary") + set_default(false) + add_includedirs("apps/desktop/src") + add_files("apps/desktop/tests/windows_unattended_policy_test.cpp") + target("windows_mouse_controller_safety_test") set_kind("binary") set_default(false) @@ -396,7 +402,11 @@ function setup_targets() add_deps("rd_log") add_files("apps/desktop/src/autostart/*.cpp") if is_os("windows") then + if is_config("CROSSDESK_PORTABLE", true) then + add_defines("CROSSDESK_PORTABLE=1") + end add_files("apps/desktop/src/platform/windows/autostart.cpp") + add_syslinks("Advapi32", "Ole32", "OleAut32", "Taskschd", {public = true}) elseif is_os("macosx") then add_files("apps/desktop/src/platform/macos/autostart.cpp") elseif is_os("linux") then @@ -405,6 +415,38 @@ function setup_targets() add_includedirs("apps/desktop/src") add_includedirs("apps/desktop/src/autostart", {public = true}) + if is_os("windows") then + target("windows_unattended_config") + set_kind("object") + add_packages("nlohmann_json") + add_files("apps/desktop/src/platform/windows/service/unattended_config.cpp") + add_includedirs("apps/desktop/src/platform/windows/service") + add_syslinks("Advapi32", "Shell32", "Ole32", {public = true}) + + target("windows_unattended_security_test") + set_kind("binary") + set_default(false) + add_deps("windows_unattended_config") + add_includedirs("apps/desktop/src/platform/windows/service") + add_files("apps/desktop/tests/windows_unattended_security_test.cpp") + + target("windows_service_lifetime_test") + set_kind("binary") + set_default(false) + add_includedirs("apps/desktop/src/platform/windows/service") + add_files("apps/desktop/tests/windows_service_lifetime_test.cpp") + + target("windows_autostart_test") + set_kind("binary") + set_default(false) + add_deps("autostart", "rd_log") + add_includedirs("apps/desktop/src") + if is_config("CROSSDESK_PORTABLE", true) then + add_defines("CROSSDESK_PORTABLE=1") + end + add_files("apps/desktop/tests/windows_autostart_test.cpp") + end + target("config_center") set_kind("object") add_deps("rd_log", "autostart") @@ -476,6 +518,8 @@ function setup_targets() add_includedirs("apps/desktop/src/platform/common/input") if is_os("windows") then add_cxxflags("/bigobj") + add_deps("windows_unattended_config") + add_files("apps/desktop/src/platform/windows/gui/application/unattended_application.cpp") add_links("opengl32") add_syslinks("Comctl32", {public = true}) add_files("apps/desktop/src/platform/windows/gui/tray/win_tray.cpp", @@ -535,7 +579,7 @@ function setup_targets() target("crossdesk_service") set_kind("binary") add_packages("nlohmann_json") - add_deps("rd_log", "path_manager") + add_deps("rd_log", "path_manager", "windows_unattended_config") add_links("Advapi32", "Wtsapi32", "Ole32", "Userenv") add_files("apps/desktop/src/platform/windows/service/main.cpp", "apps/desktop/src/platform/windows/service/service_host.cpp")