diff --git a/.artifactignore b/.artifactignore new file mode 100644 index 000000000000..f3ca6f8896b3 --- /dev/null +++ b/.artifactignore @@ -0,0 +1,17 @@ +**/* +# include freerdp build headers + +!Build/x86/include/freerdp/**/*.* +!Build/x86/winpr/include/winpr/**/*.* +!Build/x64/include/freerdp/**/*.* +!Build/x64/winpr/include/winpr/**/*.* +# include freerdp libs +!Build/x86/Debug/**/*.* +!Build/x86/Release/**/*.* +!Build/x64/Debug/**/*.* +!Build/x64/Release/**/*.* + +# include freerdp sources headers +!include/freerdp/**/*.* +!winpr/include/winpr/**/*.* + diff --git a/.ci/Scripts/PrepareFreeRdpDev.bat b/.ci/Scripts/PrepareFreeRdpDev.bat new file mode 100644 index 000000000000..2a1ddabe7ce6 --- /dev/null +++ b/.ci/Scripts/PrepareFreeRdpDev.bat @@ -0,0 +1,4 @@ +call "%~dp0\install_msvc" +call "%~dp0\getOpenSsl" +call "%~dp0\buildOpenSsl" +call "%~dp0\buildFreeRDP" Debug diff --git a/.ci/Scripts/buildFreeRdp.bat b/.ci/Scripts/buildFreeRdp.bat new file mode 100644 index 000000000000..8a91c7c6567f --- /dev/null +++ b/.ci/Scripts/buildFreeRdp.bat @@ -0,0 +1,25 @@ +pushd . +call "%~dp0\getvars.bat" + +cd "%freeRdpDir%" +git clean -xdff + +echo ">>>>>>>>>>>>>> create freerdp sln" +cmd /c cmake . -B"./Build/x64" -G"Visual Studio 17 2022"^ + -T v143,version=%vc_ver%^ + -A x64^ + -DOPENSSL_ROOT_DIR="../OpenSSL-VC-64"^ + -DCMAKE_INSTALL_PREFIX="./Install/x64"^ + -DMSVC_RUNTIME="static"^ + -DBUILD_SHARED_LIBS=OFF^ + -DWITH_CLIENT_INTERFACE=ON^ + -DCHANNEL_URBDRC=OFF^ + -DWITH_MEDIA_FOUNDATION=OFF^ + +rem build freerdp libs +set Configuration=%1 +if [%Configuration%] == [] set Configuration=Debug +echo ">>>>>>>>>>>>>>building freerdp configuration:<%Configuration%>" +%msbuild% "%buildDir%\x64\FreeRDP.sln" /p:Configuration=%Configuration% /p:Platform=x64 + +popd \ No newline at end of file diff --git a/.ci/Scripts/buildOpenSsl.bat b/.ci/Scripts/buildOpenSsl.bat new file mode 100644 index 000000000000..2ca19ca95641 --- /dev/null +++ b/.ci/Scripts/buildOpenSsl.bat @@ -0,0 +1,10 @@ +pushd . +call "%~dp0\getvars.bat" + +cd "%freeRdpDir%\..\OpenSSL" +git clean -xdff + +perl Configure VC-WIN64A no-asm no-shared no-module --prefix="%~dp0\..\..\..\OpenSSL-VC-64" +nmake +nmake install_dev +popd \ No newline at end of file diff --git a/.ci/Scripts/getOpenSsl.bat b/.ci/Scripts/getOpenSsl.bat new file mode 100644 index 000000000000..a5b67580faf6 --- /dev/null +++ b/.ci/Scripts/getOpenSsl.bat @@ -0,0 +1,12 @@ +pushd . +call "%~dp0\getvars.bat" + +cd "%freeRdpDir%\.." + +rem checkout openssl +mkdir OpenSSL +cd OpenSSL +git clone --no-checkout --filter=tree:0 --depth=1 --single-branch --branch=%openSSLTag% https://github.com/openssl/openssl . +git branch buildOpenSSl %openSSLTag% +git checkout buildOpenSSl +popd \ No newline at end of file diff --git a/.ci/Scripts/getvars.bat b/.ci/Scripts/getvars.bat new file mode 100644 index 000000000000..ecfab0ba5b1c --- /dev/null +++ b/.ci/Scripts/getvars.bat @@ -0,0 +1,57 @@ +@echo off +if defined freeRdpDir (exit /b) +@echo on + +set openSSLTag=openssl-3.0.12 +set freeRdpDir=%~dp0\..\.. +set buildDir=%freeRdpDir%\Build +set scriptsDir=%~dp0 +set vswhere="%ProgramFiles(x86)%\Microsoft Visual Studio\Installer\vswhere.exe" + +for /f "usebackq tokens=*" %%i in (`%vswhere% -latest -requires Microsoft.Component.MSBuild -find MSBuild\**\Bin\MSBuild.exe`) do ( + set msbuild="%%i" +) + +for /f "usebackq delims=" %%i in (`%vswhere% -prerelease -latest -property installationPath`) do ( + set vsDir=%%i +) + +set vc_ver_short=14.40 +set vc_ver=14.40.17.10 +set __call_SetupBuildEnvironment=(call "%vsdir%\VC\Auxiliary\Build\vcvarsall.bat" x64 -vcvars_ver=%vc_ver_short%) +%__call_SetupBuildEnvironment% + +set __check_VCToolsVersion_short=_ +if defined VCToolsVersion (set __check_VCToolsVersion_short=%VCToolsVersion:~0,5%) +if [%ERRORLEVEL%][%__check_VCToolsVersion_short%]==[0][%vc_ver_short%] ( + goto end +) + +if [%1]==[install-msvc] (goto install-msvc) + +echo =========================== ERROR: ============================== +echo please install 'msvc visual c++ build tools %vc_ver_short%' first +echo run `install_msvc.bat` as admin with vs studio stopped +echo ================================================================= +exit /b 111 + +:install-msvc +echo installing msvc +set vs_installer="%ProgramFiles(x86)%\Microsoft Visual Studio\Installer\vs_installer.exe" +%vs_installer% modify --installPath "%vsdir%" --add Microsoft.VisualStudio.Component.VC.%vc_ver%.x86.x64 Microsoft.VisualStudio.Component.VC.%vc_ver%.ATL --includeRecommended --quiet --installWhileDownloading + +if %ERRORLEVEL% neq 0 ( + echo =========================== ERROR: ============================== + echo installing msvc failed with exit code %ERRORLEVEL% + echo you need to run as admin and VisualStudio to be closed + echo ================================================================= + exit %ERRORLEVEL% +) + +echo installed msvc +%__call_SetupBuildEnvironment% + +:end +set vs +set vc + diff --git a/.ci/Scripts/install_msvc.bat b/.ci/Scripts/install_msvc.bat new file mode 100644 index 000000000000..9cb94c6b19c3 --- /dev/null +++ b/.ci/Scripts/install_msvc.bat @@ -0,0 +1 @@ +call "%~dp0\getvars.bat" install-msvc diff --git a/.ci/main.yml b/.ci/main.yml new file mode 100644 index 000000000000..2924b3f60e01 --- /dev/null +++ b/.ci/main.yml @@ -0,0 +1,138 @@ +name: $(Date:yyyyMMdd)$(Rev:-rr) + +trigger: + - uipath + +parameters: + - name: PublishNuGets + displayName: 'Publish the NuGet Packages' + type: boolean + default: false + +variables: + ${{ if eq(variables['Build.SourceBranchName'], 'uipath') }}: + CreateGitTag: true + PackageVersionSuffix: '$(Build.BuildNumber)' + ${{ else }}: + CreateGitTag: false + PackageVersionSuffix: '$(Build.BuildNumber)-dev' + +pool: + vmImage: 'windows-2022' + demands: + - msbuild + - visualstudio + +steps: +- checkout: self + persistCredentials: true + +- task: NuGetToolInstaller@1 + displayName: 'Use NuGet' + +- task: NuGetAuthenticate@1 + +- script: .\.ci\Scripts\install_msvc + displayName: install_msvc + +- script: | + call .ci\Scripts\getVars + echo on + cmake --version + perl -v + msbuild -version + cl + + set + name: getVersions + +- script: .\.ci\Scripts\getOpenSsl + displayName: getOpenSsl +- script: .\.ci\Scripts\buildOpenSsl + displayName: buildOpenSsl +- script: .\.ci\Scripts\buildFreeRDP Release + displayName: buildFreeRDP + +- task: PublishPipelineArtifact@1 + name: publishFreeRDPOnly + inputs: + targetPath: '.' + artifactName: 'freeRdpOnly' + parallel: true + +- task: PublishPipelineArtifact@1 + name: publishOpenSSL + inputs: + targetPath: '..\OpenSSL-VC-64' + artifactName: 'OpenSSL-VC-64' + parallel: true + +- task: NuGetCommand@2 + displayName: restore + inputs: + command: 'restore' + restoreSolution: 'UiPath.FreeRdpClient\UiPath.FreeRdpClient.sln' + +- task: MSBuild@1 + displayName: build + inputs: + solution: 'UiPath.FreeRdpClient\UiPath.FreeRdpClient.sln' + platform: 'x64' + configuration: 'Release' + msbuildArchitecture: 'x64' + msbuildArguments: "/p:VersionSuffix=$(PackageVersionSuffix)" + +- task: DotNetCoreCLI@2 + displayName: 'test' + inputs: + command: 'test' + configuration: 'Release' + arguments: '--no-build --configuration Release' + projects: 'UiPath.FreeRdpClient\**\*.Tests.csproj' + +- task: MSBuild@1 + displayName: restore (UseNugetRef) + inputs: + solution: 'UiPath.FreeRdpClient\UiPath.FreeRdpClient.sln' + platform: 'x64' + configuration: 'Release' + msbuildArchitecture: 'x64' + msbuildArguments: "/t:restore /p:VersionSuffix=$(PackageVersionSuffix) /p:UseNugetRef=true" + +- task: MSBuild@1 + displayName: build (UseNugetRef) + inputs: + solution: 'UiPath.FreeRdpClient\UiPath.FreeRdpClient.sln' + platform: 'x64' + configuration: 'Release' + msbuildArchitecture: 'x64' + msbuildArguments: "/p:VersionSuffix=$(PackageVersionSuffix) /p:UseNugetRef=true" + +- task: DotNetCoreCLI@2 + displayName: 'test (UseNugetRef)' + inputs: + command: 'test' + configuration: 'Release' + arguments: '--no-build --configuration Release /p:UseNugetRef=true' + projects: 'UiPath.FreeRdpClient\**\*.Tests.csproj' + +- task: NuGetCommand@2 + ${{ if eq(parameters.PublishNuGets, true) }}: + displayName: '(Enabled) NuGet Push to UiPath-Internal' + condition: succeeded() + ${{ if eq(parameters.PublishNuGets, false) }}: + displayName: '(Disabled) NuGet Push to UiPath-Internal' + condition: false + inputs: + command: push + packagesToPush: 'Output\nugets\UiPath.FreeRdpClient.*.nupkg;Output\nugets\UiPath.SessionTools.*.nupkg' + publishVstsFeed: '5b98d55c-1b14-4a03-893f-7a59746f1246/788028a9-5a01-48ee-b925-3af51ae46294' + +- ${{ if and(eq(parameters.PublishNuGets, true), eq(variables['CreateGitTag'], true)) }}: + - pwsh: | + $tag = (ls 'Output\nugets\UiPath.FreeRdpClient.*.nupkg').BaseName + git tag $tag + git push origin $tag + echo "Pushed tag $tag" + name: gitTag + condition: succeeded() \ No newline at end of file diff --git a/.gitignore b/.gitignore index c844922f6e80..03dcbae99f92 100644 --- a/.gitignore +++ b/.gitignore @@ -63,13 +63,14 @@ Debug-* Release-* # Windows -*.vcxproj -*.vcxproj.* +[!UiPath.NoGuiFreeRdpClient]\**\*.vcxproj +[!UiPath.NoGuiFreeRdpClient]\**\*.vcxproj.* +*.vcxproj.user *.vcproj *.vcproj.* *.aps *.sdf -*.sln +[!UiPath.NoGuiFreeRdpClient]\**\*.sln *.suo *.ncb *.opensdf @@ -85,7 +86,6 @@ RelWithDebInfo *.resource.txt *.embed.manifest* *.intermediate.manifest* -version.rc *.VC.db *.VC.opendb @@ -152,3 +152,6 @@ packaging/deb/freerdp-nightly/freerdp-nightly-dbg # VisualStudio Code .vscode cache/ +/**/.vs +/**/obj +/Output/nugets diff --git a/README.md b/README.md index 70cf40dcefa9..d56290626003 100644 --- a/README.md +++ b/README.md @@ -1,30 +1,54 @@ -# FreeRDP: A Remote Desktop Protocol Implementation +## UiPath fork of FreeRDP -FreeRDP is a free implementation of the Remote Desktop Protocol (RDP), released under the Apache license. -Enjoy the freedom of using your software wherever you want, the way you want it, in a world where -interoperability can finally liberate your computing experience. +This repo forks the FreeRDP repo, thus allowing us to make changes that fit our needs and augment the codebase with other components. +From time to time there is a need to merge the changes from the original repo into this one. -## Resources +### ❗When updating the FreeRDP library from the official repo, please update the following in this file (README.md): -Project website: https://www.freerdp.com/ -Issue tracker: https://github.com/FreeRDP/FreeRDP/issues -Sources: https://github.com/FreeRDP/FreeRDP/ -Downloads: https://pub.freerdp.com/releases/ -Wiki: https://github.com/FreeRDP/FreeRDP/wiki -API documentation: https://pub.freerdp.com/api/ +|**Original repository tag/branch used:**| `2.5.0` | +| --- | --- | +|**Original corresponding commit hash:**| `d50aef95520df4216c638495a6049125c00742cb` | -IRC channel: #freerdp @ irc.freenode.net -Mailing list: https://lists.sourceforge.net/lists/listinfo/freerdp-devel -## Microsoft Open Specifications +### Build instructions +* Visual Studio 2022 installed in `C:\Program Files` required. -Information regarding the Microsoft Open Specifications can be found at: -http://www.microsoft.com/openspecifications/ +* Install [StrawberryPerl](http://strawberryperl.com). Make sure the `perl` command is in PATH. + You may try on newer Windows 10: +``` + winget install -e --id StrawberryPerl.StrawberryPerl +``` -A list of reference documentation is maintained here: -https://github.com/FreeRDP/FreeRDP/wiki/Reference-Documentation +#### Build FreeRDP and Build OpenSSL (dependency for FreeRDP) -## Compilation +> Use a developer console for VS 2022 instead of normal PowerShell or CMD, the commands require `nmake` -Instructions on how to get started compiling FreeRDP can be found on the wiki: -https://github.com/FreeRDP/FreeRDP/wiki/Compilation +* Steps + * Clone [OpenSSL](https://github.com/openssl/openssl) to `..\openssl` && Checkout tag `OpenSSL_1_0_2u` (getOpenSsl) + * Generate OpenSSL build to `..\OpenSSL-VC-64`. (buildOpenSsl) + * Use CMake to generate and then build Visual Studio 2022 solutions. (BuildFreeRDP) + * The freerdp solution is generated in `.\Build\x64\` directories. + +* Scripts + * Simple + ``` + cd .ci/Scripts + .\PrepareFreeRdpDev + ``` + * or detailed + ``` + cd .ci/Scripts + .\getOpenSsl + .\buildOpenSsl + .\buildFreeRDP Debug + ``` + +### Work on the FreeRdpClient +* Open [UiPath.FreeRdpClient/UiPath.FreeRdpClient.sln](file://UiPath.FreeRdpClient/UiPath.FreeRdpClient.sln) +* To test with a nugetRef instead of projectRef edit the [UiPath.FreeRdp.Tests.csproj](file://UiPath.FreeRdpClient/UiPath.FreeRdpClient.Tests/UiPath.FreeRdp.Tests.csproj) +search for: `` + +#### Running unit tests + +* Run Visual Studio as Admin +* Make sure RDP is enabled on local machine - `View advanced system settings > Remote tab > Allow remote connections to this computer` diff --git a/UiPath.FreeRdpClient/BuildCpp.msbuildproj b/UiPath.FreeRdpClient/BuildCpp.msbuildproj new file mode 100644 index 000000000000..d739c7e04617 --- /dev/null +++ b/UiPath.FreeRdpClient/BuildCpp.msbuildproj @@ -0,0 +1,17 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/UiPath.FreeRdpClient/Directory.Build.props b/UiPath.FreeRdpClient/Directory.Build.props new file mode 100644 index 000000000000..d6c05ddb1853 --- /dev/null +++ b/UiPath.FreeRdpClient/Directory.Build.props @@ -0,0 +1,30 @@ + + + 24.2.0 + UiPath + UiPath + UiPath + © UiPath + en + + + false + false + enable + + + net6.0-windows + latest + $(MSBuildThisFileDirectory) + $(MSBuildThisFileDirectory)\..\Output\bin + $(MSBuildThisFileDirectory)\..\Output\nugets + true + + + + + + + 1998,1591,NU5048,NU5104,NU5118,NU5125 + + diff --git a/UiPath.FreeRdpClient/Directory.Build.targets b/UiPath.FreeRdpClient/Directory.Build.targets new file mode 100644 index 000000000000..e347648a08e9 --- /dev/null +++ b/UiPath.FreeRdpClient/Directory.Build.targets @@ -0,0 +1,42 @@ + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + 6.0.0 + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + + + + + + diff --git a/UiPath.FreeRdpClient/NuGet.Config b/UiPath.FreeRdpClient/NuGet.Config new file mode 100644 index 000000000000..ba6d1755ff8a --- /dev/null +++ b/UiPath.FreeRdpClient/NuGet.Config @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/UiPath.FreeRdpClient/SolutionExtras/SolutionExtras.msbuildproj b/UiPath.FreeRdpClient/SolutionExtras/SolutionExtras.msbuildproj new file mode 100644 index 000000000000..f2a133fd5cb2 --- /dev/null +++ b/UiPath.FreeRdpClient/SolutionExtras/SolutionExtras.msbuildproj @@ -0,0 +1,15 @@ + + + + SolutionFolderFiles\%(Filename)%(Extension) + + + + + .ci\%(RecursiveDir)\%(Filename)%(Extension) + + + + + + \ No newline at end of file diff --git a/UiPath.FreeRdpClient/UiPath.FreeRdpClient.Tests/Faking/IUserContext.cs b/UiPath.FreeRdpClient/UiPath.FreeRdpClient.Tests/Faking/IUserContext.cs new file mode 100644 index 000000000000..267d870f8874 --- /dev/null +++ b/UiPath.FreeRdpClient/UiPath.FreeRdpClient.Tests/Faking/IUserContext.cs @@ -0,0 +1,90 @@ +using Microsoft.Extensions.Logging; +using Newtonsoft.Json; +using System.Collections.Concurrent; +using System.Diagnostics; +using System.Net; +using UiPath.Rdp; +using UiPath.SessionTools; + +namespace UiPath.FreeRdp.Tests.Faking; + +public class UserExistsDetail +{ + private readonly NetworkCredential _credentials = new(); + + public string UserName { get => _credentials.GetFullUserName(); set => _credentials.SetFullUserName(value); } + public string Password { get => _credentials.Password; set => _credentials.Password = value; } + + internal string GetLocalUserName() => UserName.ToLowerInvariant() + .Replace(".\\", "") + .Replace(UserNames.DefaultDomainName.ToLowerInvariant() + "\\", ""); + + public List Groups { get; } = new() { "Remote Desktop Users", "Administrators" }; + + public RdpConnectionSettings ToRdpConnectionSettings(DisconnectCallback? disconnectCallback = null) + => new( + username: UserName.Split("\\")[1], + password: Password, + domain: UserName.Split("\\")[0]) + { + DisconnectCallback = disconnectCallback + }; +} + +public interface IUserContext +{ + Task EnsureUserExists(UserExistsDetail userDetail); +} + +public class UserContextReal : UserContextBase +{ + private readonly ILogger _log; + + public UserContextReal(ILogger log) + { + _log = log; + } + + protected override async Task DoCreateUser(UserExistsDetail userDetail) + { + using var cts = new CancellationTokenSource(GlobalSettings.DefaultTimeout); + + await new ProcessRunner(_log).EnsureUserIsSetUp(userDetail.GetLocalUserName(), userDetail.Password, admin: true, cts.Token); + } + +} +public abstract class UserContextBase : IUserContext +{ + protected static readonly ConcurrentDictionary CreatedUsersByName = new(); + + public async Task EnsureUserExists(UserExistsDetail userDetail) + { + var userDetailAsJson = JsonConvert.SerializeObject(userDetail); + _ = CreatedUsersByName.TryGetValue(userDetail.UserName, out var user); + if (user == userDetailAsJson) + { + return; + } + + await DoCreateUser(userDetail); + _ = CreatedUsersByName.TryAdd(userDetail.UserName, userDetailAsJson); + } + + public UserExistsDetail? GetUser(string userName) + { + if (CreatedUsersByName.TryGetValue(userName, out var user)) + { + return JsonConvert.DeserializeObject(user); + } + + return null; + } + + protected abstract Task DoCreateUser(UserExistsDetail userDetail); +} + +public class UserContextFake : UserContextBase +{ + protected override Task DoCreateUser(UserExistsDetail userDetail) + => Task.FromResult(false); +} \ No newline at end of file diff --git a/UiPath.FreeRdpClient/UiPath.FreeRdpClient.Tests/Faking/UserContextExtensions.cs b/UiPath.FreeRdpClient/UiPath.FreeRdpClient.Tests/Faking/UserContextExtensions.cs new file mode 100644 index 000000000000..243d86d5b5a5 --- /dev/null +++ b/UiPath.FreeRdpClient/UiPath.FreeRdpClient.Tests/Faking/UserContextExtensions.cs @@ -0,0 +1,99 @@ +using Microsoft.Extensions.Configuration.UserSecrets; +using Microsoft.Extensions.DependencyInjection; +using Newtonsoft.Json.Linq; +using Newtonsoft.Json; +using System.Reflection; + +namespace UiPath.FreeRdp.Tests.Faking; + +public static class UserContextExtensions +{ + static UserContextExtensions() + => EnsureUserSecrets(); + + private static void EnsureUserSecrets() + { + string secretsPath = GetSecretsFilePath(); + string initialSecretsJson; + if (!File.Exists(secretsPath)) + { + Directory.CreateDirectory(Path.GetDirectoryName(secretsPath)!); + initialSecretsJson = "{}"; + } + else + { + initialSecretsJson = File.ReadAllText(secretsPath); + } + + var secretsModel = JObject.Parse(initialSecretsJson); + initialSecretsJson = JsonConvert.SerializeObject(secretsModel, Formatting.Indented); + EnsureUsers(); + UpadateSecretsIfNeeded(); + + void EnsureUsers() + { + + const string UserAdminNameKey = "UserAdminName"; + const string UserOtherNameKey = "UserOtherName"; + const string UserAdminPasswordKey = "UserAdminPassword"; + const string UserOtherPasswordKey = "UserOtherPasswordKey"; + + UserAdmin = new() + { + UserName = GetOrAddSecret(UserAdminNameKey, "UserAdmin"), + Password = GetOrAddSecret(UserAdminPasswordKey, "!2A") + }; + + Other = new() + { + UserName = GetOrAddSecret(UserOtherNameKey, "UserOther"), + Password = GetOrAddSecret(UserOtherPasswordKey, "!2O") + }; + } + + string GetOrAddSecret(string key, string prefix) + { + if (!secretsModel.TryGetValue(key, out var value)) + { + value = secretsModel[key] = prefix + Path.GetRandomFileName().Replace(".", ""); + } + return value.ToString(); + } + + void UpadateSecretsIfNeeded() + { + var updatedSecretsJson = JsonConvert.SerializeObject(secretsModel, Formatting.Indented); + if (initialSecretsJson.Equals(updatedSecretsJson)) + return; + + File.WriteAllText(secretsPath, updatedSecretsJson); + } + } + + private static string GetSecretsFilePath() + { + var secretsId = Assembly.GetExecutingAssembly().GetCustomAttribute()!.UserSecretsId; + var secretsPath = PathHelper.GetSecretsPathFromSecretsId(secretsId); + return secretsPath; + } + + public static UserExistsDetail UserAdmin { get; private set; } = null!; + public static UserExistsDetail Other { get; private set; } = null!; + + public static async Task GivenUser(this TestHost host) + => await host.EnsureUserExists(UserAdmin); + + public static async Task GivenAdmin(this TestHost host) + => await host.EnsureUserExists(UserAdmin); + + public static async Task GivenUserOther(this TestHost host) + => await host.EnsureUserExists(Other); + + public static async Task EnsureUserExists(this TestHost host, UserExistsDetail user) + { + var uc = host.GetRequiredService(); + await uc.EnsureUserExists(user); + + return user; + } +} \ No newline at end of file diff --git a/UiPath.FreeRdpClient/UiPath.FreeRdpClient.Tests/InformationalVersionTests.cs b/UiPath.FreeRdpClient/UiPath.FreeRdpClient.Tests/InformationalVersionTests.cs new file mode 100644 index 000000000000..a8e598e89e95 --- /dev/null +++ b/UiPath.FreeRdpClient/UiPath.FreeRdpClient.Tests/InformationalVersionTests.cs @@ -0,0 +1,40 @@ +using System.Reflection; +using System.Text.RegularExpressions; +using UiPath.Rdp; +using UiPath.SessionTools; + +namespace UiPath.FreeRdp.Tests; + +[Trait("Category", "Build")] +public class InformationalVersionTests +{ + [Theory] + [InlineData(typeof(IFreeRdpClient))] + [InlineData(typeof(Wts))] + public void InformationalVersionIsCorrect(Type specimen) + { + var assembly = specimen.Assembly; + var attribute = assembly.GetCustomAttribute(); + attribute.ShouldNotBeNull(); + + var match = Regex.Match(attribute.InformationalVersion); + match.Success.ShouldBeTrue(); + } + + [Theory] + [InlineData("1.2.3+6e78f515d09a6f9fd226bb1e81276b8ab4228961")] + [InlineData("1+eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee")] + [InlineData("1.2.3+0000000000000000000000000000000000000000")] + public void Regex_ShouldMatch_GivenValidInformationalVersion(string input) + => Regex.Match(input).Success.ShouldBeTrue(); + + [Theory] + [InlineData("1.2.3")] + [InlineData("+6e78f515d09a6f9fd226bb1e81276b8ab4228961")] + [InlineData("1.2.3+ge78f515d09a6f9fd226bb1e81276b8ab4228961")] + [InlineData("1.2.3+6e78f515d09a6f9fd226bb1e81")] + public void Regex_ShouldNotMatch_GivenInvalidInformationalVersion(string input) + => Regex.Match(input).Success.ShouldBeFalse(); + + private static readonly Regex Regex = new(@"^(?:[^+]+)\+(?:[0-9a-fA-F]{40})$"); +} diff --git a/UiPath.FreeRdpClient/UiPath.FreeRdpClient.Tests/LoggingTests.cs b/UiPath.FreeRdpClient/UiPath.FreeRdpClient.Tests/LoggingTests.cs new file mode 100644 index 000000000000..aae06104e7bc --- /dev/null +++ b/UiPath.FreeRdpClient/UiPath.FreeRdpClient.Tests/LoggingTests.cs @@ -0,0 +1,136 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Moq; +using Nito.Disposables; +using System.Collections.Concurrent; +using UiPath.Rdp; +using UiPath.SessionTools; + +namespace UiPath.FreeRdp.Tests; + +public class LoggingTests : TestsBase +{ + private readonly ConcurrentDictionary> _logsByCategory = new(); + private readonly ConcurrentBag _scopes = new(); + private readonly Mock _loggerMock = new(); + + private async Task Connect(RdpConnectionSettings connectionSettings) + { + return await Host.Connect(connectionSettings); + } + + public LoggingTests(ITestOutputHelper output) : base(output) + { + var logsByCategoryProvider = new Mock(); + logsByCategoryProvider.Setup(p => p.CreateLogger(It.IsAny())) + .Returns((string category) => new FakeLogger(_logsByCategory.GetOrAdd(category, c => new()))); + Host.AddRegistry(s => s.AddLogging(b => b.AddProvider(logsByCategoryProvider.Object))); + + var scopesProvider = new Mock(); + scopesProvider.Setup(p => p.CreateLogger(It.IsAny())) + .Returns((string category) => _loggerMock.Object); + Host.AddRegistry(s => s.AddLogging(b => b.AddProvider(scopesProvider.Object))); + _loggerMock.Setup(l => l.BeginScope(It.IsAny())).Callback((object o) => _scopes.Add(o)); + } + + private class FakeLogger : ILogger + { + private readonly ConcurrentBag<(LogLevel logLevel, string message)> _logsBag; + + public FakeLogger(ConcurrentBag<(LogLevel logLevel, string message)> logsBag) + { + _logsBag = logsBag; + } + + public IDisposable BeginScope(TState state) + => NoopDisposable.Instance; + + public bool IsEnabled(LogLevel logLevel) => true; + + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) + => _logsBag.Add(new(logLevel, formatter(state, exception))); + } + + [Fact] + public async Task ShouldProduceLogsFromFreerdp() + { + var user = await Host.GivenUser(); + var connectionSettings = user.ToRdpConnectionSettings(); + + await using var sut = await Connect(connectionSettings); + var sessionId = await Host.FindSession(connectionSettings); + await WaitFor.Predicate(() => Host.GetWts().QuerySessionInformation(sessionId).ConnectState() + is Windows.Win32.System.RemoteDesktop.WTS_CONNECTSTATE_CLASS.WTSActive + or Windows.Win32.System.RemoteDesktop.WTS_CONNECTSTATE_CLASS.WTSConnected); + + await sut.DisposeAsync(); + await Host.WaitNoSession(connectionSettings); + + const string acceptedDebugCategory = "com.freerdp.core.nego"; + var negoLogs = _logsByCategory.Where(kv => kv.Key.StartsWith(acceptedDebugCategory)) + .SelectMany(kv => kv.Value) + .Where(l => l.logLevel == LogLevel.Debug) + .ToArray(); + negoLogs.ShouldNotBeEmpty(); + + const string wrapperCategory = "UiPath.FreeRdpWrapper"; + var wrapperLogs = _logsByCategory.Where(kv => kv.Key.StartsWith(wrapperCategory)) + .SelectMany(kv => kv.Value) + .ToArray(); + wrapperLogs.ShouldNotBeEmpty(); + + const string freeRdpLoggingCategory = "UiPath.FreeRdpLogging"; + var nonDebugFreeRdpLogs = _logsByCategory.Where(kv => kv.Key.StartsWith(freeRdpLoggingCategory)) + .SelectMany(kv => kv.Value) + .ToArray(); + nonDebugFreeRdpLogs.ShouldNotBeEmpty(); + nonDebugFreeRdpLogs.ShouldAllBe(l => l.logLevel == LogLevel.Information); + nonDebugFreeRdpLogs.ShouldContain(l => l.message.Contains("forwardFreeRdpLogs:true")); + + var scopes = _scopes.OfType>>() + .Where(kvl => kvl.Any(kv => kv.Key == NativeLoggingForwarder.ScopeName && connectionSettings.ScopeName.Equals(kv.Value))) + .ToArray(); + scopes.ShouldNotBeEmpty(); + } + + [Fact] + public async Task ErrorLogsShouldBeFilteredAndTranslatedToWarn() + { + var forwarder = Host.GetRequiredService(); + forwarder.FilterRemoveStartsWith = new[] { Guid.NewGuid().ToString(), Guid.NewGuid().ToString(), Guid.NewGuid().ToString() }; + forwarder.FilterRemoveStartsWithWarnings = new[] { Guid.NewGuid().ToString(), Guid.NewGuid().ToString(), Guid.NewGuid().ToString() }; + + var someTestCategory = Guid.NewGuid().ToString(); + var testLogs = _logsByCategory.Where(kv => kv.Key == someTestCategory) + .SelectMany(kv => kv.Value); + + foreach (var startWith in forwarder.FilterRemoveStartsWith) + { + forwarder.LogCallbackDelegate(someTestCategory, LogLevel.Error, startWith + "_extra1"); + forwarder.LogCallbackDelegate(someTestCategory, LogLevel.Error, startWith + "_extra2"); + forwarder.LogCallbackDelegate(someTestCategory, LogLevel.Error, startWith); + } + foreach (var startWith in forwarder.FilterRemoveStartsWithWarnings) + { + forwarder.LogCallbackDelegate(someTestCategory, LogLevel.Warning, startWith + "_extra1"); + forwarder.LogCallbackDelegate(someTestCategory, LogLevel.Warning, startWith + "_extra2"); + forwarder.LogCallbackDelegate(someTestCategory, LogLevel.Warning, startWith); + } + testLogs.ShouldBeEmpty(); + + foreach (var startWith in forwarder.FilterRemoveStartsWith) + { + forwarder.LogCallbackDelegate(someTestCategory, LogLevel.Error, "_" + startWith); + } + foreach (var startWith in forwarder.FilterRemoveStartsWithWarnings) + { + forwarder.LogCallbackDelegate(someTestCategory, LogLevel.Warning, "_" + startWith); + } + testLogs.Count() + .ShouldBe(forwarder.FilterRemoveStartsWith.Length + forwarder.FilterRemoveStartsWithWarnings.Length); + testLogs.Count(l => l.logLevel is LogLevel.Warning) + .ShouldBe(forwarder.FilterRemoveStartsWith.Length + forwarder.FilterRemoveStartsWithWarnings.Length); + testLogs.Where(l => l.logLevel is LogLevel.Error) + .ShouldBeEmpty(); + } +} \ No newline at end of file diff --git a/UiPath.FreeRdpClient/UiPath.FreeRdpClient.Tests/ProcessRunnerExtensions.cs b/UiPath.FreeRdpClient/UiPath.FreeRdpClient.Tests/ProcessRunnerExtensions.cs new file mode 100644 index 000000000000..1d75d6b37825 --- /dev/null +++ b/UiPath.FreeRdpClient/UiPath.FreeRdpClient.Tests/ProcessRunnerExtensions.cs @@ -0,0 +1,41 @@ +using System.Globalization; +using System.Text; +using UiPath.SessionTools; + +namespace UiPath.FreeRdp.Tests; + +public static class ProcessRunnerExtensions +{ + public static Task CreateRDPRedirect(this ProcessRunner processRunner, int listenPort, CancellationToken ct = default) + => processRunner.Run( + "cmd", + $"/c netsh interface portproxy add v4tov4 listenaddress=127.0.0.1 listenport={listenPort} connectaddress=127.0.0.1 connectport=3389", + throwOnNonZero: true, + ct: ct); + + public static async Task PortWithStateExists(this ProcessRunner processRunner, int port, string state, CancellationToken ct = default) + => await processRunner.PortWithStateExists(port, state, processId: null, ct); + public static async Task PortWithStateExists(this ProcessRunner processRunner, int port, string state, int? processId, CancellationToken ct = default) + { + (var output, var exitCode) = await processRunner.Run("cmd", GetArguments(), throwOnNonZero: false, ct: ct); + + if (exitCode is not 0) { return false; } + + string needle = port.ToString(CultureInfo.InvariantCulture); + + return output.Contains(needle); + + string GetArguments() + { + StringBuilder sb = new(); + + sb.Append($"/c netstat -ona|find \":{port}\"|find \"{state}\""); + if (processId is not null) + { + sb.Append($"|find \"{processId}\""); + } + + return sb.ToString(); + } + } +} diff --git a/UiPath.FreeRdpClient/UiPath.FreeRdpClient.Tests/Properties/launchSettings.json b/UiPath.FreeRdpClient/UiPath.FreeRdpClient.Tests/Properties/launchSettings.json new file mode 100644 index 000000000000..58b02880abc3 --- /dev/null +++ b/UiPath.FreeRdpClient/UiPath.FreeRdpClient.Tests/Properties/launchSettings.json @@ -0,0 +1,8 @@ +{ + "profiles": { + "UiPath.FreeRdp.Tests": { + "commandName": "Project", + "nativeDebugging": true + } + } +} \ No newline at end of file diff --git a/UiPath.FreeRdpClient/UiPath.FreeRdpClient.Tests/RdpClientTests.cs b/UiPath.FreeRdpClient/UiPath.FreeRdpClient.Tests/RdpClientTests.cs new file mode 100644 index 000000000000..ca4e2e01ed8b --- /dev/null +++ b/UiPath.FreeRdpClient/UiPath.FreeRdpClient.Tests/RdpClientTests.cs @@ -0,0 +1,243 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Nito.Disposables; +using System.Diagnostics; +using System.Runtime.InteropServices; +using UiPath.Rdp; +using UiPath.SessionTools; + +namespace UiPath.FreeRdp.Tests; + +public class RdpClientTests : TestsBase +{ + private const string StateEstablished = "ESTABLISHED"; + private const string StateListening = "LISTENING"; + private readonly ITestOutputHelper _output; + + private readonly Wts _wts; + + private async Task Connect(RdpConnectionSettings connectionSettings) + { + return await Host.Connect(connectionSettings); + } + + public RdpClientTests(ITestOutputHelper output) : base(output) + { + _output = output; + _wts = Host.GetWts(); + } + + [Fact] + public async Task ScopeNameIsUniqueForAWhile() + { + var user = await Host.GivenUser(); + var count = 10_000; + var clientNamesHistory = new HashSet(); + + while (count-- > 0) + { + var connectionSettings = user.ToRdpConnectionSettings(); + + clientNamesHistory.Add(connectionSettings.ScopeName).ShouldBe(true); + } + } + + [InlineData(32, 32)] + [InlineData(24, 8)] + [InlineData(16, 4)] + //[InlineData(15, 16, Skip = "This retuns same as 16 bit on my machine")] + //[InlineData(8, 2, Skip = "This retuns same as 16 bit on my machine")] + //[InlineData(4, 1, Skip = "This retuns same as 16 bit on my machine")] + [Theory] + public async Task ShouldConnect(int colorDepthInput, int expectedWtsApiValue) + { + var user = await Host.GivenUser(); + + var disconnectCalled = false; + var connectionSettings = user.ToRdpConnectionSettings(disconnectCallback: () => { disconnectCalled = true; }); + + connectionSettings.DesktopWidth = 3 * 4 * 101; + connectionSettings.DesktopHeight = 3 * 4 * 71; + connectionSettings.ColorDepth = colorDepthInput; + + await using (var sut = await Connect(connectionSettings)) + { + var sessionId = await Host.FindSession(connectionSettings); + var displayInfo = _wts.QuerySessionInformation(sessionId).ClientDisplay(); + + ((int)displayInfo.HorizontalResolution).ShouldBe(connectionSettings.DesktopWidth); + ((int)displayInfo.VerticalResolution).ShouldBe(connectionSettings.DesktopHeight); + //((int)displayInfo.ColorDepth).ShouldBe(expectedWtsApiValue); + } + + await Host.WaitNoSession(connectionSettings); + disconnectCalled.ShouldBeTrue(); + } + + + [Fact] + public async Task HostDispose_ShouldNotTriggerCrash_WhenSessionIsStillActive() + { + var user = await Host.GivenUser(); + var connectionSettings = user.ToRdpConnectionSettings(); + + await using var sut = await Connect(connectionSettings); + + await Host.DisposeAsync(); + await Task.Delay(1000); + + // The assertion is that this process hasn't crashed. + // To make it crash, comment out the following guard: + // https://github.com/UiPath/FreeRDP/blob/8da62c46223929d548fbd6984453d9ccf50a80af/UiPath.FreeRdpClient/UiPath.FreeRdpWrapper/Logging.cpp#L14-L15 + } + + [Fact] + public async Task ParallelConnectWorksOnFirstUse() + { + var user = await Host.GivenUser(); + var connectionSettings1 = user.ToRdpConnectionSettings(); + + user = await Host.GivenUserOther(); + var connectionSettings2 = user.ToRdpConnectionSettings(); + + var iterations = 10; + while (iterations-- > 0) + { + var host = new TestHost(_output).AddFreeRdp(); + await host.StartAsync(); + await using var hostStop = AsyncDisposable.Create(async () => + { + await host.StopAsync(); + await host.DisposeAsync(); + }); + + var freerdpAppDataFolder = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "freerdp"); + if (Directory.Exists(freerdpAppDataFolder)) + Directory.Delete(freerdpAppDataFolder, recursive: true); + + var connect1Task = host.Connect(connectionSettings1); + var connect2Task = host.Connect(connectionSettings2); + + await using var d = new CollectionAsyncDisposable(await Task.WhenAll(connect1Task, connect2Task)); + + await d.DisposeAsync(); + await host.WaitNoSession(connectionSettings1); + await host.WaitNoSession(connectionSettings2); + } + } + + [Fact] + public async Task ShouldConnectWithDifferentPort() + { + var port = 44444 + DateTime.Now.Millisecond % 10; + if (port == Environment.ProcessId) + port++; + await WithPortRedirectToDefaultRdp(port); + var user = await Host.GivenUser(); + + var connectionSettings = user.ToRdpConnectionSettings(); + + connectionSettings.Port = port; + + await ShouldNotHavePortWithState(port, StateEstablished); + + await using var sut = await Connect(connectionSettings); + var sessionId = await Host.FindSession(connectionSettings); + + await ShouldHavePortWithState(port, StateEstablished, Environment.ProcessId); + + await sut.DisposeAsync(); + await ShouldNotHavePortWithState(port, StateEstablished); + await Host.WaitNoSession(connectionSettings); + } + + [Fact] + public async Task DisconnectCallbackShouldBeCalledWhenSessionIsDisconnected() + { + var callbackCalled = false; + var user = await Host.GivenUser(); + var connectionSettings = user.ToRdpConnectionSettings( + disconnectCallback: () => { callbackCalled = true; }); + + // Connect + var connection = await Connect(connectionSettings); + var sessionId = await Host.FindSession(connectionSettings); + + // Garbage collect + connection = null; + connectionSettings.DisconnectCallback = null; + GC.Collect(); + GC.WaitForFullGCComplete(); + + // Disconnect + _wts.DisconnectSession(server: null, sessionId, wait: true); + await Host.WaitNoSession(connectionSettings); + // FreeRDP takes a while to call disconnect after we manually disconnect the session + await WaitFor.Predicate(() => callbackCalled); + } + + [Fact] + public async Task DisconnectCallbackShouldBeCalledWhenConnectionIsDisposed() + { + var callbackCalled = false; + var user = await Host.GivenUser(); + var connectionSettings = user.ToRdpConnectionSettings( + disconnectCallback: () => { callbackCalled = true; }); + + // Connect + var connection = await Connect(connectionSettings); + var sessionId = await Host.FindSession(connectionSettings); + + // Garbage collect + connectionSettings.DisconnectCallback = null; + GC.Collect(); + GC.WaitForFullGCComplete(); + + // Disconnect + await connection.DisposeAsync(); + await Host.WaitNoSession(connectionSettings); + + callbackCalled.ShouldBeTrue(); + } + + private async Task WithPortRedirectToDefaultRdp(int port) + { + var log = Host.GetRequiredService>(); + + using var ctsTimeout = new CancellationTokenSource(GlobalSettings.DefaultTimeout); + + await new ProcessRunner(log).CreateRDPRedirect(port, ctsTimeout.Token); + + await ShouldHavePortWithState(port, StateListening); + } + + private async Task ShouldHavePortWithState(int port, string state, int? processId = null) + { + var log = Host.GetRequiredService>(); + + using var ctsTimeout = new CancellationTokenSource(GlobalSettings.DefaultTimeout); + + (await new ProcessRunner(log).PortWithStateExists(port, state, processId, ctsTimeout.Token)).ShouldBeTrue(); + } + + private async Task ShouldNotHavePortWithState(int port, string state) + { + var log = Host.GetRequiredService>(); + + using var ctsTimeout = new CancellationTokenSource(GlobalSettings.DefaultTimeout); + + (await new ProcessRunner(log).PortWithStateExists(port, state, ctsTimeout.Token)).ShouldBeFalse(); + } + + [Fact] + public async Task WrongPassword_ShouldFail() + { + var user = await Host.GivenUser(); + user.Password += "_"; + + var connectionSettings = user.ToRdpConnectionSettings(); + + var exception = await Connect(connectionSettings).ShouldThrowAsync(); + exception.Message.Contains("Logon Failed", StringComparison.InvariantCultureIgnoreCase); + } +} \ No newline at end of file diff --git a/UiPath.FreeRdpClient/UiPath.FreeRdpClient.Tests/TestInfra/GlobalSettings.cs b/UiPath.FreeRdpClient/UiPath.FreeRdpClient.Tests/TestInfra/GlobalSettings.cs new file mode 100644 index 000000000000..3a234358979f --- /dev/null +++ b/UiPath.FreeRdpClient/UiPath.FreeRdpClient.Tests/TestInfra/GlobalSettings.cs @@ -0,0 +1,6 @@ +namespace System.Diagnostics; + +public static partial class GlobalSettings +{ + public static readonly TimeSpan DefaultTimeout = TimeSpan.FromMinutes(2); +} diff --git a/UiPath.FreeRdpClient/UiPath.FreeRdpClient.Tests/TestInfra/TestHost.cs b/UiPath.FreeRdpClient/UiPath.FreeRdpClient.Tests/TestInfra/TestHost.cs new file mode 100644 index 000000000000..b96c2e1817ac --- /dev/null +++ b/UiPath.FreeRdpClient/UiPath.FreeRdpClient.Tests/TestInfra/TestHost.cs @@ -0,0 +1,179 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Configuration; +using Moq; +using Microsoft.Extensions.Logging; +using Nito.Disposables; +using MartinCostello.Logging.XUnit; +using UiPath.SessionTools; + +namespace UiPath.FreeRdp.Tests.TestInfra; + +public static class TestContextExtensions +{ + public static IConfiguration GetConfiguration(this IServiceProvider serviceProvider) + => serviceProvider.GetRequiredService(); +} + +public sealed class TestHost : IServiceProvider, IHost, IAsyncDisposable +{ + private readonly Lazy _hostLazy; + private readonly Dictionary _fakeObjectsByType = new(); + private readonly Dictionary _fakeTypesByType = new(); + private readonly List> _configureServices = new(); + private readonly ITestOutputHelper? _output; + private readonly IMessageSink? _messageSink; + private readonly CollectionAsyncDisposable _disposables = new(); + private bool _hostStarted; + private static int HostCounter = 0; + + public int HostId { get; } = Interlocked.Increment(ref HostCounter); + + private IHost _host => _hostLazy.Value; + + public TestHost(IMessageSink messageSink) : this() + { + _messageSink = messageSink; + } + + public TestHost(ITestOutputHelper? output = null) + { + _hostLazy = new(CreateHost); + _output = output; + } + + private void TaskScheduler_UnobservedTaskException(object? sender, UnobservedTaskExceptionEventArgs e) + { + this.GetRequiredService>().LogError($"UnobservedTaskException:{e.Exception}"); + e.SetObserved(); + } + + private void CurrentDomain_UnhandledException(object sender, UnhandledExceptionEventArgs e) + { + this.GetRequiredService>().LogError($"UnhandledException:{e.ExceptionObject}"); + } + + public void AddDisposable(IDisposable disposable) + => _disposables.AddAsync(disposable.ToAsyncDisposable()); + + public void AddDisposable(IAsyncDisposable disposable) + => _disposables.AddAsync(disposable); + + public TestHost UseService() + where TFake : class, T + where T : class + { + _fakeTypesByType[typeof(T)] = typeof(TFake); + return this; + } + + public TestHost UseMock() + where T : class + => UseFake(new Mock().Object); + + private IHost CreateHost() + => Host.CreateDefaultBuilder() + .ConfigureServices(s => s.AddSingleton(this)) + .ConfigureServices(services => _configureServices.ForEach(cs => cs(services))) + .ConfigureServices(AddFakes) + .ConfigureLogging(loggingBuilder => + { + loggingBuilder.AddSimpleConsole(o => + { + o.UseUtcTimestamp = true; + o.TimestampFormat = $"[C {HostId} dd.HH:mm:ss.fffff] "; + o.IncludeScopes = true; + }); + + if (_output != null) + { + loggingBuilder.AddXUnit(_output, ConfigureXUnit); + } + + if (_messageSink != null) + { + loggingBuilder.AddXUnit(_messageSink, ConfigureXUnit); + } + + loggingBuilder.SetMinimumLevel(LogLevel.Trace); + void ConfigureXUnit(XUnitLoggerOptions o) + { + o.TimestampFormat = $"X {HostId} dd.HH:mm:ss.fffff"; + o.IncludeScopes = true; + } + }) + .Build(); + + public TestHost AddRegistry(Action configureServices) + { + _configureServices.Add(configureServices); + return this; + } + + private void AddFakes(IServiceCollection services) + { + foreach (var fake in _fakeTypesByType) + services + .AddSingleton(fake.Key, sp => sp.GetService(fake.Value) + ?? throw new InvalidOperationException($"Strangely registered types:{fake.Key}<=>{fake.Value}")); + foreach (var fake in _fakeObjectsByType) + services.AddSingleton(fake.Key, fake.Value); + } + + public object? GetService(Type serviceType) + { + EnsureHostIsStarted(); + return _host.Services.GetService(serviceType); + } + + private void EnsureHostIsStarted() + { + if (_hostStarted) + return; + + Task.Run(async () => await StartAsync()) + .GetAwaiter().GetResult(); + } + + public TestHost UseFake(T fake) + where T : class + { + _fakeObjectsByType[typeof(T)] = fake; + return this; + } + + public async Task StartAsync(CancellationToken cancellationToken = default) + { + await _host.StartAsync(cancellationToken); + AppDomain.CurrentDomain.UnhandledException += CurrentDomain_UnhandledException; + TaskScheduler.UnobservedTaskException += TaskScheduler_UnobservedTaskException; + _hostStarted = true; + } + + public Task StopAsync(CancellationToken cancellationToken = default) + => DisposeAsync().AsTask(); + + public void Dispose() + { + Task.Run(() => DisposeAsync().AsTask().Wait()).Wait(); + } + + public async ValueTask DisposeAsync() + { + await _host.StopAsync(); + await _disposables.DisposeAsync(); + AppDomain.CurrentDomain.UnhandledException -= CurrentDomain_UnhandledException; + TaskScheduler.UnobservedTaskException -= TaskScheduler_UnobservedTaskException; + + _host.Dispose(); + } + + IServiceProvider IHost.Services + { + get + { + EnsureHostIsStarted(); + return _host.Services; + } + } +} \ No newline at end of file diff --git a/UiPath.FreeRdpClient/UiPath.FreeRdpClient.Tests/TestInfra/TestHostExtensions.cs b/UiPath.FreeRdpClient/UiPath.FreeRdpClient.Tests/TestInfra/TestHostExtensions.cs new file mode 100644 index 000000000000..a4e70997768b --- /dev/null +++ b/UiPath.FreeRdpClient/UiPath.FreeRdpClient.Tests/TestInfra/TestHostExtensions.cs @@ -0,0 +1,12 @@ +using Microsoft.Extensions.DependencyInjection; +using UiPath.Rdp; +using UiPath.SessionTools; + +namespace UiPath.FreeRdp.Tests.TestInfra; + +internal static class TestHostExtensions +{ + public static TestHost AddFreeRdp(this TestHost host) + => host.AddRegistry(s => s.AddFreeRdp()) + .AddBeforeConnectTimestamps(); +} diff --git a/UiPath.FreeRdpClient/UiPath.FreeRdpClient.Tests/TestInfra/TestsBase.cs b/UiPath.FreeRdpClient/UiPath.FreeRdpClient.Tests/TestInfra/TestsBase.cs new file mode 100644 index 000000000000..a1f635e8f53a --- /dev/null +++ b/UiPath.FreeRdpClient/UiPath.FreeRdpClient.Tests/TestInfra/TestsBase.cs @@ -0,0 +1,24 @@ +using Microsoft.Extensions.DependencyInjection; + +namespace UiPath.FreeRdp.Tests.TestInfra; + +public abstract class TestsBase : IAsyncLifetime +{ + protected readonly TestHost Host; + protected TestsBase(ITestOutputHelper output) + { + Host = new TestHost(output).AddFreeRdp() + .AddRegistry(s => s.AddSingleton()); + } + + public virtual async Task InitializeAsync() + { + await Host.StartAsync(); + } + + public virtual async Task DisposeAsync() + { + await Host.StopAsync(); + await Host.DisposeAsync(); + } +} \ No newline at end of file diff --git a/UiPath.FreeRdpClient/UiPath.FreeRdpClient.Tests/TestInfra/UserNames.cs b/UiPath.FreeRdpClient/UiPath.FreeRdpClient.Tests/TestInfra/UserNames.cs new file mode 100644 index 000000000000..29dc8113c840 --- /dev/null +++ b/UiPath.FreeRdpClient/UiPath.FreeRdpClient.Tests/TestInfra/UserNames.cs @@ -0,0 +1,42 @@ +using System.DirectoryServices.ActiveDirectory; +using System.Net; + +namespace UiPath.FreeRdp.Tests.TestInfra; + +internal static class UserNames +{ + public readonly static string DefaultDomainName = GetDefaultDomain(); + + private static string GetDefaultDomain() + { + try + { + return Domain.GetComputerDomain().Name.Split('.').First(); + } + catch + { + return Environment.MachineName.ToLowerInvariant(); + } + } + + public static string Normalize(string username) + { + if (username.Split("\\").Length < 2) + username = DefaultDomainName + "\\" + username; + return username.ToLowerInvariant(); + } + + public static void SetFullUserName(this NetworkCredential networkCredential, string fullUserName) + { + var splits = fullUserName.Split("\\"); + + if (splits.Length == 1) + networkCredential.Domain = DefaultDomainName; + else + networkCredential.Domain = splits[0].ToLowerInvariant(); + + networkCredential.UserName = splits.Last().ToLowerInvariant(); + } + public static string GetFullUserName(this NetworkCredential networkCredential) + => networkCredential.Domain + "\\" + networkCredential.UserName; +} diff --git a/UiPath.FreeRdpClient/UiPath.FreeRdpClient.Tests/TestInfra/WaitFor.cs b/UiPath.FreeRdpClient/UiPath.FreeRdpClient.Tests/TestInfra/WaitFor.cs new file mode 100644 index 000000000000..ef81777755c2 --- /dev/null +++ b/UiPath.FreeRdpClient/UiPath.FreeRdpClient.Tests/TestInfra/WaitFor.cs @@ -0,0 +1,39 @@ +using System.Runtime.CompilerServices; + +namespace UiPath.FreeRdp.Tests.TestInfra; + +internal class WaitFor +{ + private static TimeSpan DefaultTimeout = TimeSpan.FromSeconds(10); + + public static async Task Predicate(Func predicate, TimeSpan timeout, bool throwOnTimeout = true, [CallerArgumentExpression(nameof(predicate))] string? expression = null) + => await Predicate(predicate: () => Task.FromResult(predicate()), timeout: timeout, throwOnTimeout: throwOnTimeout, expression: expression); + + public static async Task Predicate(Func> predicate, TimeSpan timeout, bool throwOnTimeout = true, [CallerArgumentExpression(nameof(predicate))] string? expression = null) + => await Predicate(predicate: predicate, cancelToken: CreateCancelToken(timeout), throwOnTimeout: throwOnTimeout, expression: expression); + + public static async Task Predicate(Func predicate, CancellationToken cancelToken = default, bool throwOnTimeout = true, [CallerArgumentExpression(nameof(predicate))] string? expression = null) + => await Predicate(predicate: () => Task.FromResult(predicate()), cancelToken: cancelToken, throwOnTimeout: throwOnTimeout, expression: expression); + + public static async Task Predicate(Func> predicate, CancellationToken cancelToken = default, bool throwOnTimeout = true, [CallerArgumentExpression(nameof(predicate))] string? expression = null) + { + cancelToken = cancelToken == default ? CreateCancelToken() : cancelToken; + while (!await predicate()) + { + if (cancelToken.IsCancellationRequested) + { + if (throwOnTimeout) + throw new TimeoutException(expression); + break; + } + await Task.Delay(40); + } + } + + private static CancellationToken CreateCancelToken(TimeSpan timeout = default) + { + var source = new CancellationTokenSource(); + source.CancelAfter(timeout == default ? DefaultTimeout : timeout); + return source.Token; + } +} diff --git a/UiPath.FreeRdpClient/UiPath.FreeRdpClient.Tests/TestInfra/WtsExtensions.cs b/UiPath.FreeRdpClient/UiPath.FreeRdpClient.Tests/TestInfra/WtsExtensions.cs new file mode 100644 index 000000000000..cb26a875eb28 --- /dev/null +++ b/UiPath.FreeRdpClient/UiPath.FreeRdpClient.Tests/TestInfra/WtsExtensions.cs @@ -0,0 +1,64 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using UiPath.Rdp; +using UiPath.SessionTools; + +namespace UiPath.FreeRdp.Tests; +using ScopeToBeforeConnectTimestamp = Dictionary; + +internal static class HostExtensions +{ + public static Wts GetWts(this TestHost _) => new(); + + public static TestHost AddBeforeConnectTimestamps(this TestHost host) + => host.AddRegistry(s => s.AddSingleton()); + public static ScopeToBeforeConnectTimestamp GetScopeTimestamps(this TestHost host) + => host.GetRequiredService(); + + + private static void SaveBeforeConnectTimestamp(this TestHost host, RdpConnectionSettings connectionSettings) + => host.GetScopeTimestamps()[connectionSettings.ScopeName] = DateTimeOffset.UtcNow; + private static DateTimeOffset GetBeforeConnectTimestamp(this TestHost host, RdpConnectionSettings connectionSettings) + => host.GetScopeTimestamps()[connectionSettings.ScopeName]; + + public static async Task Connect(this TestHost host, RdpConnectionSettings connectionSettings) + { + var freeRdpClient = host.GetRequiredService(); + var log = host.GetRequiredService>(); + using var logScope = log.BeginScope($"{NativeLoggingForwarder.ScopeName}", connectionSettings.ScopeName + "_fromTest"); + host.SaveBeforeConnectTimestamp(connectionSettings); + return await freeRdpClient.Connect(connectionSettings); + } + + public static async Task FindSession(this TestHost host, RdpConnectionSettings connectionSettings) + { + int? sessionId = null; + await WaitFor.Predicate(() => (sessionId = host.FindFirstSession(connectionSettings)) is not null); + return sessionId!.Value; + } + + public static async Task WaitNoSession(this TestHost host, RdpConnectionSettings connectionSettings) + { + await WaitFor.Predicate(() => host.FindFirstSession(connectionSettings) is null); + } + + private static int? FindFirstSession(this TestHost host, RdpConnectionSettings connectionSettings) + { + var wts = host.GetWts(); + var sessionIds = wts.GetSessionIdList(); + + foreach (int sessionId in sessionIds) + { + var sessionInfo = wts.QuerySessionInformation(sessionId).SessionInfo(); + if (DateTimeOffset + .FromFileTime(sessionInfo.Data.ConnectTime) >= host.GetBeforeConnectTimestamp(connectionSettings) + && sessionInfo.Data.ConnectTime > sessionInfo.Data.DisconnectTime + ) + { + return sessionId; + } + } + + return null; + } +} diff --git a/UiPath.FreeRdpClient/UiPath.FreeRdpClient.Tests/UiPath.FreeRdp.Tests.csproj b/UiPath.FreeRdpClient/UiPath.FreeRdpClient.Tests/UiPath.FreeRdp.Tests.csproj new file mode 100644 index 000000000000..2f05c6df57ba --- /dev/null +++ b/UiPath.FreeRdpClient/UiPath.FreeRdpClient.Tests/UiPath.FreeRdp.Tests.csproj @@ -0,0 +1,48 @@ + + + net6.0-windows + enable + true + false + true + 57c50d74-20d6-40cc-b246-b316d10bf8c8 + + + + PreserveNewest + + + + + FreeRdpSRC\libfreerdp\%(RecursiveDir)%(Filename)%(Extension) + + + + + + + + + + + + + + + + + + + true + $(NugetsOutDir);https://api.nuget.org/v3/index.json;$(RestoreSources) + + + + + False + + + + + + diff --git a/UiPath.FreeRdpClient/UiPath.FreeRdpClient.Tests/Usings.cs b/UiPath.FreeRdpClient/UiPath.FreeRdpClient.Tests/Usings.cs new file mode 100644 index 000000000000..f3f7d82c8f49 --- /dev/null +++ b/UiPath.FreeRdpClient/UiPath.FreeRdpClient.Tests/Usings.cs @@ -0,0 +1,7 @@ +global using System; +global using System.Threading.Tasks; +global using Xunit; +global using Xunit.Abstractions; +global using Shouldly; +global using UiPath.FreeRdp.Tests.TestInfra; +global using UiPath.FreeRdp.Tests.Faking; diff --git a/UiPath.FreeRdpClient/UiPath.FreeRdpClient.Tests/xunit.runner.json b/UiPath.FreeRdpClient/UiPath.FreeRdpClient.Tests/xunit.runner.json new file mode 100644 index 000000000000..01191b97fcb9 --- /dev/null +++ b/UiPath.FreeRdpClient/UiPath.FreeRdpClient.Tests/xunit.runner.json @@ -0,0 +1,8 @@ +{ + "diagnosticMessages": true, + "longRunningTestSeconds": 2, + "parallelizeAssembly": false, + "parallelizeTestCollections": false, + "maxParallelThreads": 1, + "shadowCopy": false +} diff --git a/UiPath.FreeRdpClient/UiPath.FreeRdpClient.sln b/UiPath.FreeRdpClient/UiPath.FreeRdpClient.sln new file mode 100644 index 000000000000..dd089586c11d --- /dev/null +++ b/UiPath.FreeRdpClient/UiPath.FreeRdpClient.sln @@ -0,0 +1,103 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.2.32505.173 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "UiPath.FreeRdpWrapper", "UiPath.FreeRdpWrapper\UiPath.FreeRdpWrapper.vcxproj", "{EA15A988-201D-4F25-8CA4-8D324996799C}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "UiPath.FreeRdp.Tests", "UiPath.FreeRdpClient.Tests\UiPath.FreeRdp.Tests.csproj", "{757E396C-50CE-4BD8-BCBB-86E32A2A9683}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "UiPath.FreeRdpClient", "UiPath.FreeRdpClient\UiPath.FreeRdpClient.csproj", "{345E7D3C-E9E3-471C-B09E-30F712A7E7FA}" +EndProject +Project("{13B669BE-BB05-4DDF-9536-439F39A36129}") = "SolutionExtras", "SolutionExtras\SolutionExtras.msbuildproj", "{C90304C5-E2F3-4B53-97A6-22309709E3B5}" +EndProject +Project("{13B669BE-BB05-4DDF-9536-439F39A36129}") = "BuildCpp", "BuildCpp.msbuildproj", "{017D87E5-4E55-4E6C-8DA5-65F8F66868CF}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Rdp", "Rdp", "{F9FF2A8C-CA71-49FE-8D0D-68C05A606C0B}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "SessionTools", "SessionTools", "{319A381E-EA9D-4AA5-A511-B339057448D4}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "UiPath.SessionTools", "UiPath.SessionTools\UiPath.SessionTools.csproj", "{8702DE19-56CC-47F4-BC82-783B71CEA77C}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "UiPath.SessionTools.Tests", "UiPath.SessionTools.Tests\UiPath.SessionTools.Tests.csproj", "{6BCDF288-AD17-4D0F-ABD7-3639D8CBD624}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {EA15A988-201D-4F25-8CA4-8D324996799C}.Debug|Any CPU.ActiveCfg = Debug|x64 + {EA15A988-201D-4F25-8CA4-8D324996799C}.Debug|Any CPU.Build.0 = Debug|x64 + {EA15A988-201D-4F25-8CA4-8D324996799C}.Debug|x64.ActiveCfg = Debug|x64 + {EA15A988-201D-4F25-8CA4-8D324996799C}.Debug|x64.Build.0 = Debug|x64 + {EA15A988-201D-4F25-8CA4-8D324996799C}.Release|Any CPU.ActiveCfg = Release|x64 + {EA15A988-201D-4F25-8CA4-8D324996799C}.Release|Any CPU.Build.0 = Release|x64 + {EA15A988-201D-4F25-8CA4-8D324996799C}.Release|x64.ActiveCfg = Release|x64 + {EA15A988-201D-4F25-8CA4-8D324996799C}.Release|x64.Build.0 = Release|x64 + {757E396C-50CE-4BD8-BCBB-86E32A2A9683}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {757E396C-50CE-4BD8-BCBB-86E32A2A9683}.Debug|Any CPU.Build.0 = Debug|Any CPU + {757E396C-50CE-4BD8-BCBB-86E32A2A9683}.Debug|x64.ActiveCfg = Debug|Any CPU + {757E396C-50CE-4BD8-BCBB-86E32A2A9683}.Debug|x64.Build.0 = Debug|Any CPU + {757E396C-50CE-4BD8-BCBB-86E32A2A9683}.Release|Any CPU.ActiveCfg = Release|Any CPU + {757E396C-50CE-4BD8-BCBB-86E32A2A9683}.Release|Any CPU.Build.0 = Release|Any CPU + {757E396C-50CE-4BD8-BCBB-86E32A2A9683}.Release|x64.ActiveCfg = Release|Any CPU + {757E396C-50CE-4BD8-BCBB-86E32A2A9683}.Release|x64.Build.0 = Release|Any CPU + {345E7D3C-E9E3-471C-B09E-30F712A7E7FA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {345E7D3C-E9E3-471C-B09E-30F712A7E7FA}.Debug|Any CPU.Build.0 = Debug|Any CPU + {345E7D3C-E9E3-471C-B09E-30F712A7E7FA}.Debug|x64.ActiveCfg = Debug|Any CPU + {345E7D3C-E9E3-471C-B09E-30F712A7E7FA}.Debug|x64.Build.0 = Debug|Any CPU + {345E7D3C-E9E3-471C-B09E-30F712A7E7FA}.Release|Any CPU.ActiveCfg = Release|Any CPU + {345E7D3C-E9E3-471C-B09E-30F712A7E7FA}.Release|Any CPU.Build.0 = Release|Any CPU + {345E7D3C-E9E3-471C-B09E-30F712A7E7FA}.Release|x64.ActiveCfg = Release|Any CPU + {345E7D3C-E9E3-471C-B09E-30F712A7E7FA}.Release|x64.Build.0 = Release|Any CPU + {C90304C5-E2F3-4B53-97A6-22309709E3B5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C90304C5-E2F3-4B53-97A6-22309709E3B5}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C90304C5-E2F3-4B53-97A6-22309709E3B5}.Debug|x64.ActiveCfg = Debug|Any CPU + {C90304C5-E2F3-4B53-97A6-22309709E3B5}.Debug|x64.Build.0 = Debug|Any CPU + {C90304C5-E2F3-4B53-97A6-22309709E3B5}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C90304C5-E2F3-4B53-97A6-22309709E3B5}.Release|Any CPU.Build.0 = Release|Any CPU + {C90304C5-E2F3-4B53-97A6-22309709E3B5}.Release|x64.ActiveCfg = Release|Any CPU + {C90304C5-E2F3-4B53-97A6-22309709E3B5}.Release|x64.Build.0 = Release|Any CPU + {017D87E5-4E55-4E6C-8DA5-65F8F66868CF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {017D87E5-4E55-4E6C-8DA5-65F8F66868CF}.Debug|Any CPU.Build.0 = Debug|Any CPU + {017D87E5-4E55-4E6C-8DA5-65F8F66868CF}.Debug|x64.ActiveCfg = Debug|Any CPU + {017D87E5-4E55-4E6C-8DA5-65F8F66868CF}.Debug|x64.Build.0 = Debug|Any CPU + {017D87E5-4E55-4E6C-8DA5-65F8F66868CF}.Release|Any CPU.ActiveCfg = Release|Any CPU + {017D87E5-4E55-4E6C-8DA5-65F8F66868CF}.Release|Any CPU.Build.0 = Release|Any CPU + {017D87E5-4E55-4E6C-8DA5-65F8F66868CF}.Release|x64.ActiveCfg = Release|Any CPU + {017D87E5-4E55-4E6C-8DA5-65F8F66868CF}.Release|x64.Build.0 = Release|Any CPU + {8702DE19-56CC-47F4-BC82-783B71CEA77C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8702DE19-56CC-47F4-BC82-783B71CEA77C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8702DE19-56CC-47F4-BC82-783B71CEA77C}.Debug|x64.ActiveCfg = Debug|Any CPU + {8702DE19-56CC-47F4-BC82-783B71CEA77C}.Debug|x64.Build.0 = Debug|Any CPU + {8702DE19-56CC-47F4-BC82-783B71CEA77C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8702DE19-56CC-47F4-BC82-783B71CEA77C}.Release|Any CPU.Build.0 = Release|Any CPU + {8702DE19-56CC-47F4-BC82-783B71CEA77C}.Release|x64.ActiveCfg = Release|Any CPU + {8702DE19-56CC-47F4-BC82-783B71CEA77C}.Release|x64.Build.0 = Release|Any CPU + {6BCDF288-AD17-4D0F-ABD7-3639D8CBD624}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {6BCDF288-AD17-4D0F-ABD7-3639D8CBD624}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6BCDF288-AD17-4D0F-ABD7-3639D8CBD624}.Debug|x64.ActiveCfg = Debug|Any CPU + {6BCDF288-AD17-4D0F-ABD7-3639D8CBD624}.Debug|x64.Build.0 = Debug|Any CPU + {6BCDF288-AD17-4D0F-ABD7-3639D8CBD624}.Release|Any CPU.ActiveCfg = Release|Any CPU + {6BCDF288-AD17-4D0F-ABD7-3639D8CBD624}.Release|Any CPU.Build.0 = Release|Any CPU + {6BCDF288-AD17-4D0F-ABD7-3639D8CBD624}.Release|x64.ActiveCfg = Release|Any CPU + {6BCDF288-AD17-4D0F-ABD7-3639D8CBD624}.Release|x64.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {EA15A988-201D-4F25-8CA4-8D324996799C} = {F9FF2A8C-CA71-49FE-8D0D-68C05A606C0B} + {757E396C-50CE-4BD8-BCBB-86E32A2A9683} = {F9FF2A8C-CA71-49FE-8D0D-68C05A606C0B} + {345E7D3C-E9E3-471C-B09E-30F712A7E7FA} = {F9FF2A8C-CA71-49FE-8D0D-68C05A606C0B} + {017D87E5-4E55-4E6C-8DA5-65F8F66868CF} = {F9FF2A8C-CA71-49FE-8D0D-68C05A606C0B} + {8702DE19-56CC-47F4-BC82-783B71CEA77C} = {319A381E-EA9D-4AA5-A511-B339057448D4} + {6BCDF288-AD17-4D0F-ABD7-3639D8CBD624} = {319A381E-EA9D-4AA5-A511-B339057448D4} + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {7F546E6A-4D31-421C-9D95-F2A8ADF501B8} + EndGlobalSection +EndGlobal diff --git a/UiPath.FreeRdpClient/UiPath.FreeRdpClient/IFreeRdpClient.cs b/UiPath.FreeRdpClient/UiPath.FreeRdpClient/IFreeRdpClient.cs new file mode 100644 index 000000000000..1d71e104535d --- /dev/null +++ b/UiPath.FreeRdpClient/UiPath.FreeRdpClient/IFreeRdpClient.cs @@ -0,0 +1,6 @@ +namespace UiPath.Rdp; + +public interface IFreeRdpClient +{ + Task Connect(RdpConnectionSettings connectionSettings); +} diff --git a/UiPath.FreeRdpClient/UiPath.FreeRdpClient/Internals/FreeRdpClient.cs b/UiPath.FreeRdpClient/UiPath.FreeRdpClient/Internals/FreeRdpClient.cs new file mode 100644 index 000000000000..bf0516c110ef --- /dev/null +++ b/UiPath.FreeRdpClient/UiPath.FreeRdpClient/Internals/FreeRdpClient.cs @@ -0,0 +1,94 @@ +using Microsoft.Extensions.Logging; +using Nito.AsyncEx; +using Nito.Disposables; +using System.Collections.Concurrent; +using System.Net.Sockets; + +namespace UiPath.Rdp; + +internal class FreeRdpClient : IFreeRdpClient +{ + static FreeRdpClient() + { + //Make sure winsock is initialized + using var _ = new TcpClient(); + } + + public FreeRdpClient(ILogger logger) + { + _log = logger; + _disconnectCallback = OnDisconnect; + NativeInterface.SetDisconnectCallback(_disconnectCallback); + } + + private readonly ILogger _log; + private readonly AsyncLock _initLock = new(); + private readonly ConcurrentDictionary _disconnectCallbacks = []; + private bool _initialized = false; + + internal readonly NativeInterface.DisconnectCallback _disconnectCallback; + + public async Task Connect(RdpConnectionSettings connectionSettings) + { + ArgumentNullException.ThrowIfNull(connectionSettings.Username); + ArgumentNullException.ThrowIfNull(connectionSettings.Domain); + ArgumentNullException.ThrowIfNull(connectionSettings.Password); + + NativeInterface.ConnectOptions connectOptions = new() + { + Width = connectionSettings.DesktopWidth, + Height = connectionSettings.DesktopHeight, + Depth = connectionSettings.ColorDepth, + FontSmoothing = connectionSettings.FontSmoothing, + User = connectionSettings.Username, + Domain = connectionSettings.Domain, + Password = connectionSettings.Password, + ScopeName = connectionSettings.ScopeName, + ClientName = connectionSettings.ClientName, + HostName = connectionSettings.HostName, + Port = connectionSettings.Port ?? default + }; + + using (await _initLock.LockAsync()) + { + /// Make sure freerdp static initilizers are not run concurrently + /// when they do they fail with + if (!_initialized) + { + _log.LogInformation("RdpInitLock acquired."); + var connection = await DoConnect(); + _initialized = true; + _log.LogInformation("RdpInitLock released."); + return connection; + } + } + + return await DoConnect(); + + Task DoConnect() => Task.Run(() => + { + NativeInterface.RdpLogon(connectOptions, out var releaseObjectName); + var connection = new AsyncDisposable(async () => + { + Disconnect(releaseObjectName); + }); + + _disconnectCallbacks[releaseObjectName] = connectionSettings.DisconnectCallback; + + return connection; + }); + } + + private void Disconnect(string releaseObjectName) + { + if (releaseObjectName != default) + NativeInterface.RdpRelease(releaseObjectName); + } + + private void OnDisconnect(string releaseObjectName) + { + var callback = _disconnectCallbacks.GetValueOrDefault(releaseObjectName); + callback?.Invoke(); + _disconnectCallbacks.Remove(releaseObjectName, out _); + } +} \ No newline at end of file diff --git a/UiPath.FreeRdpClient/UiPath.FreeRdpClient/Internals/InternalsVisibleTo.cs b/UiPath.FreeRdpClient/UiPath.FreeRdpClient/Internals/InternalsVisibleTo.cs new file mode 100644 index 000000000000..9aca23bfc64c --- /dev/null +++ b/UiPath.FreeRdpClient/UiPath.FreeRdpClient/Internals/InternalsVisibleTo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("UiPath.FreeRdp.Tests")] \ No newline at end of file diff --git a/UiPath.FreeRdpClient/UiPath.FreeRdpClient/Internals/NativeInterface.cs b/UiPath.FreeRdpClient/UiPath.FreeRdpClient/Internals/NativeInterface.cs new file mode 100644 index 000000000000..9355bb6260f4 --- /dev/null +++ b/UiPath.FreeRdpClient/UiPath.FreeRdpClient/Internals/NativeInterface.cs @@ -0,0 +1,56 @@ +using Microsoft.Extensions.Logging; +using System.Runtime.InteropServices; + +namespace UiPath.Rdp; + +internal class NativeInterface +{ + const string FreeRdpClientDll = "UiPath.FreeRdpWrapper.dll"; + + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + public struct ConnectOptions + { + public int Width; + public int Height; + public int Depth; + public bool FontSmoothing; + [MarshalAs(UnmanagedType.BStr)] + public string User; + [MarshalAs(UnmanagedType.BStr)] + public string Domain; + [MarshalAs(UnmanagedType.BStr)] + public string Password; + [MarshalAs(UnmanagedType.BStr)] + public string ScopeName; + [MarshalAs(UnmanagedType.BStr)] + public string? ClientName; + [MarshalAs(UnmanagedType.BStr)] + public string HostName; + public int Port; + } + + [UnmanagedFunctionPointer(CallingConvention.Cdecl, CharSet = CharSet.Unicode)] + public delegate void LogCallback([MarshalAs(UnmanagedType.LPStr)] string category, [MarshalAs(UnmanagedType.I4)] LogLevel logLevel, [MarshalAs(UnmanagedType.LPWStr)] string message); + + [UnmanagedFunctionPointer(CallingConvention.Cdecl, CharSet = CharSet.Ansi)] + public delegate void RegisterThreadScopeCallback([MarshalAs(UnmanagedType.LPStr)] string category); + + [UnmanagedFunctionPointer(CallingConvention.Cdecl)] + public delegate void DisconnectCallback([MarshalAs(UnmanagedType.BStr)] string releaseObjectName); + + [DllImport(FreeRdpClientDll, PreserveSig = false, CharSet = CharSet.Unicode)] + public extern static void InitializeLogging([MarshalAs(UnmanagedType.FunctionPtr)] LogCallback? logCallback, + [MarshalAs(UnmanagedType.FunctionPtr)] RegisterThreadScopeCallback? registerThreadScopeCallback, + [MarshalAs(UnmanagedType.Bool)] bool forwardFreeRdpLogs); + + [DllImport(FreeRdpClientDll, PreserveSig = false, CharSet = CharSet.Unicode)] + public extern static void SetDisconnectCallback([MarshalAs(UnmanagedType.FunctionPtr)] DisconnectCallback? disconnectCallback); + + [DllImport(FreeRdpClientDll, PreserveSig = false, CharSet = CharSet.Unicode)] + public extern static void RdpLogon( + [In] ConnectOptions rdpOptions, + [MarshalAs(UnmanagedType.BStr)] out string releaseObjectName); + + [DllImport(FreeRdpClientDll, PreserveSig = false, CharSet = CharSet.Unicode)] + public extern static void RdpRelease(string releaseObjectName); +} diff --git a/UiPath.FreeRdpClient/UiPath.FreeRdpClient/Internals/NativeLoggingForwarder.cs b/UiPath.FreeRdpClient/UiPath.FreeRdpClient/Internals/NativeLoggingForwarder.cs new file mode 100644 index 000000000000..7aefb33889e9 --- /dev/null +++ b/UiPath.FreeRdpClient/UiPath.FreeRdpClient/Internals/NativeLoggingForwarder.cs @@ -0,0 +1,108 @@ +using Microsoft.Extensions.Logging; + +namespace UiPath.Rdp; + +internal sealed class NativeLoggingForwarder : IDisposable +{ + public static string ScopeName { get; set; } = "RunId"; + public readonly NativeInterface.LogCallback LogCallbackDelegate; + private readonly NativeInterface.RegisterThreadScopeCallback _registerThreadScopeCallbackDelegate; + + private volatile bool _disposed = false; + private readonly ILoggerFactory _loggerFactory; + + public string[] FilterRemoveStartsWith { get; set; } = new[] + { + // ordered by frequency + "freerdp_check_fds() failed - 0", //24000+ in 50 sec + "WARNING: invalid packet signature", + "transport_check_fds: transport->ReceiveCallback() - -4", + "fastpath_recv_update_data() fail", + "Stream_GetRemainingLength() < size",//20000+ in 50 sec + "fastpath_recv_update_data: fastpath_recv_update() -", + "Fastpath update Orders [0] failed, status 0",//?? + "Total size (", // 21234214) exceeds MultifragMaxRequestSize (65535) // 2000+ in 50 secs + "Unexpected FASTPATH_FRAGMENT_SINGLE",//800+ in 50 sec + "SECONDARY ORDER [0x",//<04/05/...>] Cache Bitmap V2 (Compressed) failed", + "bulk_decompress() failed", + "Decompression failure!", + "Unknown bulk compression type 00000003", + "Unsupported bulk compression type 00000003", + "order flags ", + "history buffer index out of range",//10+ + "history buffer overflow", + "fastpath_recv_update() - -1", + }; + + public string[] FilterRemoveStartsWithWarnings = new[] + { + "Primary Drawing Order", + }; + + public NativeLoggingForwarder(ILoggerFactory loggerFactory) + { + _loggerFactory = loggerFactory; + LogCallbackDelegate = Log; + _registerThreadScopeCallbackDelegate = RegisterThreadScope; + EnableNativeLogsForwarding(); + } + + private void Log(string category, LogLevel logLevel, string message) + { + if (_disposed) + return; + + if (!FilterLogs(logLevel, message)) + return; + + // This is here so that they don't show up in Windows Event Log. + if (logLevel is LogLevel.Error) + { + logLevel = LogLevel.Warning; + } + + var log = _loggerFactory.CreateLogger(category); + log.Log(logLevel, message); + } + + + private void RegisterThreadScope(string scope) + { + if (_disposed) + return; + + _ = _loggerFactory.CreateLogger(nameof(RegisterThreadScope)).BeginScope($"{{{ScopeName}}}", scope); + } + + private bool FilterLogs(LogLevel logLevel, string message) + { + if (logLevel is LogLevel.Error + && Array.Exists(FilterRemoveStartsWith, message.StartsWith)) + return false; + + if (logLevel is LogLevel.Warning + && Array.Exists(FilterRemoveStartsWithWarnings, message.StartsWith)) + return false; + + return true; + } + + private void EnableNativeLogsForwarding() + { + var forwardFreeRdpLogs = Environment.GetEnvironmentVariable("WLOG_FILEAPPENDER_OUTPUT_FILE_PATH") is null; + NativeInterface.InitializeLogging(logCallback: LogCallbackDelegate, + registerThreadScopeCallback: _registerThreadScopeCallbackDelegate, + forwardFreeRdpLogs: forwardFreeRdpLogs); + } + + private void DisableNativeLogsForwarding() + { + NativeInterface.InitializeLogging(logCallback: null, registerThreadScopeCallback: null, forwardFreeRdpLogs: false); + } + + public void Dispose() + { + _disposed = true; + DisableNativeLogsForwarding(); + } +} diff --git a/UiPath.FreeRdpClient/UiPath.FreeRdpClient/RdpConnectionSettings.cs b/UiPath.FreeRdpClient/UiPath.FreeRdpClient/RdpConnectionSettings.cs new file mode 100644 index 000000000000..3ca4cb23d87b --- /dev/null +++ b/UiPath.FreeRdpClient/UiPath.FreeRdpClient/RdpConnectionSettings.cs @@ -0,0 +1,40 @@ +using System.ComponentModel.DataAnnotations; +using System.Globalization; + +namespace UiPath.Rdp; + +public delegate void DisconnectCallback(); + +public class RdpConnectionSettings +{ + public string Username { get; } + public string Domain { get; } + public string Password { get; } + + public int DesktopWidth { get; set; } = 1024; + public int DesktopHeight { get; set; } = 768; + public int ColorDepth { get; set; } = 32; + public bool FontSmoothing { get; set; } + + public string HostName { get; set; } = "localhost"; + public int? Port { get; set; } + + public string ScopeName { get; set; } + [MaxLength(15, ErrorMessage = "Sometimes :) Windows returns only first 15 chars for a session ClientName")] + public string? ClientName { get; set; } + + public DisconnectCallback? DisconnectCallback { get; set; } + + public RdpConnectionSettings(string username, string domain, string password) + { + Username = username; + Domain = domain; + Password = password; + ScopeName = GetUniqueScopeName(); + } + + private static volatile int ConnectionId = 0; + private static readonly string ScopeNameBase = "RDP_" + Environment.ProcessId.ToString(CultureInfo.InvariantCulture) + "_"; + private static string GetUniqueScopeName() => ScopeNameBase + + Interlocked.Increment(ref ConnectionId).ToString(CultureInfo.InvariantCulture); +} diff --git a/UiPath.FreeRdpClient/UiPath.FreeRdpClient/ServiceRegistryExtensions.cs b/UiPath.FreeRdpClient/UiPath.FreeRdpClient/ServiceRegistryExtensions.cs new file mode 100644 index 000000000000..e52c4e30dda4 --- /dev/null +++ b/UiPath.FreeRdpClient/UiPath.FreeRdpClient/ServiceRegistryExtensions.cs @@ -0,0 +1,19 @@ +using Microsoft.Extensions.DependencyInjection; + +namespace UiPath.Rdp; + +public static class ServiceRegistryExtensions +{ + public static IServiceCollection AddFreeRdp(this IServiceCollection services, string scopeName = "RunId") + { + NativeLoggingForwarder.ScopeName = scopeName; + return services + .AddSingleton() + .AddSingleton(sp => + { + EnsureNativeLogsForwarding(); + return ActivatorUtilities.CreateInstance(sp); + void EnsureNativeLogsForwarding() => sp.GetRequiredService(); + }); + } +} diff --git a/UiPath.FreeRdpClient/UiPath.FreeRdpClient/UiPath.FreeRdpClient.csproj b/UiPath.FreeRdpClient/UiPath.FreeRdpClient/UiPath.FreeRdpClient.csproj new file mode 100644 index 000000000000..865de38daf51 --- /dev/null +++ b/UiPath.FreeRdpClient/UiPath.FreeRdpClient/UiPath.FreeRdpClient.csproj @@ -0,0 +1,39 @@ + + + net6.0-windows + enable + UiPath + FreeRdp client for c# + true + true + $(NugetsOutDir) + $(AppVersion) + UiPath.Rdp + + + + + + + + + + runtimes\win\native\%(Filename)%(Extension) + %(Filename)%(Extension) + PreserveNewest + + + runtimes\win\native\%(Filename)%(Extension) + %(Filename)%(Extension) + PreserveNewest + + + + + False + + + + + + diff --git a/UiPath.FreeRdpClient/UiPath.FreeRdpWrapper/FreeRdpWrapper.cpp b/UiPath.FreeRdpClient/UiPath.FreeRdpWrapper/FreeRdpWrapper.cpp new file mode 100644 index 000000000000..1dfa66c32c25 --- /dev/null +++ b/UiPath.FreeRdpClient/UiPath.FreeRdpWrapper/FreeRdpWrapper.cpp @@ -0,0 +1,328 @@ +#include "pch.h" +#include "FreeRdpWrapper.h" +#include "Logging.h" + +#pragma warning(disable : 4324 4201 4245) +#include +#include +#pragma warning(default : 4324 4201 4245) +#pragma once +using namespace Logging; +using namespace FreeRdpClient; + +namespace FreeRdpClient +{ + static pFreeRdpDisconnectedCallback _disconnectCallback = nullptr; + + char* ConvToUtf8(BSTR source) + { + std::wstring_convert, wchar_t> convToUTF8; + return _strdup(convToUTF8.to_bytes(source).c_str()); + } + + class instance_data + { + public: + rdpContext* context; + HANDLE transportStopEvent; + char* scopeName; + + instance_data(rdpContext* context, ConnectOptions* rdpOptions) + { + transportStopEvent = NULL; + this->context = context; + this->scopeName = ConvToUtf8(rdpOptions->ScopeName); + } + ~instance_data() + { + if (this->transportStopEvent) + { + CloseHandle(this->transportStopEvent); + this->transportStopEvent = NULL; + } + if (this->scopeName) + { + free(this->scopeName); + this->scopeName = nullptr; + } + } + _bstr_t getEventName() + { + return "Global\\" + (_bstr_t)(this->scopeName); + } + }; + + inline HRESULT SetErrorInfo(LPCWSTR szError) + { + CComPtr pICEI; + CHECK_HRESULT_RET_HR(CreateErrorInfo(&pICEI)); + + CHECK_HRESULT_RET_HR(pICEI->SetDescription((LPOLESTR)szError)); + + CComPtr pErrorInfo; + CHECK_HRESULT_RET_HR(pICEI->QueryInterface(__uuidof(IErrorInfo), (void**)&pErrorInfo)); + CHECK_HRESULT_RET_HR(SetErrorInfo(0, pErrorInfo)); + return S_OK; + } + + void SetLastError(rdpContext* context) + { + auto rdpError = freerdp_get_last_error(context); + const char* rdpErrorString = freerdp_get_last_error_string(rdpError); + + + WCHAR szMsgBuff[MAX_TRACE_MSG]; + swprintf_s(szMsgBuff, _countof(szMsgBuff), + L"Rdp connection failed: Message: %S Last error: %d", rdpErrorString, rdpError); + SetErrorInfo(szMsgBuff); + DT_ERROR(szMsgBuff); + } + + freerdp* CreateFreeRdpInstance() + { + freerdp* instance = NULL; + + instance = freerdp_new(); + if (instance == NULL) + { + DT_ERROR(L"Failed create the rdp instance"); + return NULL; + } + + if (freerdp_context_new(instance) == FALSE) + { + freerdp_free(instance); + DT_ERROR(L"Failed create the rdp context"); + return NULL; + } + return instance; + } + + void PrepareRdpContext(rdpContext* context, const ConnectOptions* rdpOptions) + { + context->settings->ServerHostname = ConvToUtf8(rdpOptions->HostName); + + if (rdpOptions->Port) + context->settings->ServerPort = rdpOptions->Port; + + context->settings->Domain = ConvToUtf8(rdpOptions->Domain); + context->settings->Username = ConvToUtf8(rdpOptions->User); + context->settings->Password = ConvToUtf8(rdpOptions->Pass); + + if (rdpOptions->ClientName) + context->settings->ClientHostname = ConvToUtf8(rdpOptions->ClientName); + + context->settings->SoftwareGdi = TRUE; + context->settings->LocalConnection = TRUE; + context->settings->ProxyType = PROXY_TYPE_IGNORE; + + // Without this setting the RDP session getting disconnected unexpectedly after a time + // This issue can be reproduced using 2.5.0 freerdp version + // (https://uipath.atlassian.net/browse/ROBO-2607) and seems to be introduced by this + // commit: + // https://github.com/FreeRDP/FreeRDP/pull/5151/commits/7610917a48e2ea4f1e1065bd226643120cbce4e5 + context->settings->BitmapCacheEnabled = TRUE; + + // Increase the TcpAckTimeout to 60 seconds (default is 9 seconds). Used to wait for an + // active tcp connection (CONNECTION_STATE_ACTIVE) + // https://github.com/FreeRDP/FreeRDP/blob/fa3cf9417ffb67a3433ecb48d18a1c2b3190a03e/libfreerdp/core/connection.c#L380 + context->settings->TcpAckTimeout = 60000; + + // The freerdp is used only to create a session on local machine (localhost) => we ignore + // certificate + context->settings->IgnoreCertificate = TRUE; + + if (rdpOptions->Width > 0) + context->settings->DesktopWidth = rdpOptions->Width; + if (rdpOptions->Height > 0) + context->settings->DesktopHeight = rdpOptions->Height; + if (rdpOptions->Depth > 0) + context->settings->ColorDepth = rdpOptions->Depth; + + context->settings->AllowFontSmoothing = rdpOptions->FontSmoothing; + } + + DWORD ReleaseAll(instance_data* instanceData) + { + DT_TRACE(L"RdpRelease: Start"); + + freerdp* instance = instanceData->context->instance; + if (instance->context->cache != NULL) + { + cache_free(instance->context->cache); + } + + freerdp_disconnect(instance); + freerdp_context_free(instance); + freerdp_free(instance); + + auto releaseObjectName = instanceData->getEventName(); + + delete instanceData; + + if (_disconnectCallback) + { + _disconnectCallback(releaseObjectName); + } + + DT_TRACE(L"RdpRelease: Finish"); + return ERROR_SUCCESS; + } + + // Freerdp async transport implementation + // Was removed from freerdp core (https://github.com/FreeRDP/FreeRDP/pull/4815), and remains + // only on freerdp clients Seems to still needed for Windows7 disconnected session + // (https://github.com/UiPath/Driver/commit/dbc3ea9009b988471eee124ed379b02a63b993eb) + + DWORD WINAPI transport_thread(LPVOID pData) + { + instance_data* instanceData = (instance_data*)pData; + + rdpContext* context = instanceData->context; + + Logging::RegisterCurrentThreadScope(instanceData->scopeName); + + context->cache = cache_new(context->instance->settings); + + HANDLE handles[64]{}; + handles[0] = instanceData->transportStopEvent; + + while (1) + { + DWORD nCount = 1; // transportStopEvent + + DWORD nCountTmp = freerdp_get_event_handles(context, &handles[nCount], 64 - nCount); + if (nCountTmp == 0) + { + DT_ERROR(L"freerdp_get_event_handles failed"); + break; + } + + nCount += nCountTmp; + DWORD status = WaitForMultipleObjects(nCount, handles, FALSE, INFINITE); + + if (status == WAIT_OBJECT_0) + { + DT_TRACE(L"freerdp: transportStopEvent triggered"); + break; + } + + if (status > WAIT_OBJECT_0 && status < (WAIT_OBJECT_0 + nCount)) + { + freerdp_check_event_handles(context); + if (freerdp_shall_disconnect(context->instance)) + { + DT_TRACE(L"freerdp_shall_disconnect()"); + freerdp_set_error_info(context->rdp, ERRINFO_PEER_DISCONNECTED); + break; + } + } + else + { + DT_ERROR(L"WaitForMultipleObjects returned 0x%08", status); + break; + } + } + + ReleaseAll(instanceData); + return 0; + } + + BOOL transport_start( + rdpContext* context, + ConnectOptions* rdpOptions, + _bstr_t &eventName) + { + instance_data* instanceData = new instance_data(context, rdpOptions); + + eventName = instanceData->getEventName(); + auto existingEvent = OpenEvent(NULL, false, eventName.GetBSTR()); + if (existingEvent) + { + CloseHandle(existingEvent); + DT_ERROR(L"Failed to create freerdp transport stop event, error: alreadyExists: %s", eventName.GetBSTR()); + delete instanceData; + return FALSE; + } + + instanceData->transportStopEvent = CreateEvent(NULL, TRUE, FALSE, eventName.GetBSTR()); + if (!instanceData->transportStopEvent) + { + DT_ERROR(L"Failed to create freerdp transport stop event, error: %u", GetLastError()); + delete instanceData; + return FALSE; + } + + auto transportThreadHandle = CreateThread(NULL, 0, transport_thread, instanceData, 0, NULL); + if (!transportThreadHandle) + { + DT_ERROR(L"Failed to create freerdp transport client thread, error: %u", GetLastError()); + delete instanceData; + return FALSE; + } + CloseHandle(transportThreadHandle); + return TRUE; + } + + HRESULT STDAPICALLTYPE RdpLogon( + ConnectOptions* rdpOptions, + BSTR& releaseEventName) + { + DT_TRACE(L"Start for user: [%s], domain: [%s], scopeName: [%s]", rdpOptions->User, + rdpOptions->Domain, rdpOptions->ScopeName); + releaseEventName = NULL; + auto instance = CreateFreeRdpInstance(); + if (!instance) + return E_OUTOFMEMORY; + + rdpContext* context = instance->context; + PrepareRdpContext(context, rdpOptions); + + auto connectResult = freerdp_connect(instance); + if (connectResult) + { + _bstr_t eventName; + if (transport_start(context, rdpOptions, eventName)) + { + releaseEventName = eventName.Detach(); + DT_TRACE(L"Connection succeeded"); + return S_OK; + } + else + { + DT_ERROR(L"Failed start the freerdp transport thread"); + } + } + + SetLastError(context); + + freerdp_context_free(instance); + freerdp_free(instance); + + return E_FAIL; + } + + HRESULT STDAPICALLTYPE RdpRelease(BSTR releaseEventName) + { + DT_TRACE(L"RdpRelease"); + auto eventHandle = OpenEvent(EVENT_MODIFY_STATE, false, releaseEventName); + if (!eventHandle) + return S_OK; + + if (!SetEvent(eventHandle)) + { + auto lastError = GetLastError(); + CloseHandle(eventHandle); + return HRESULT_FROM_WIN32(lastError); + } + + CloseHandle(eventHandle); + return S_OK; + } + + HRESULT STDAPICALLTYPE SetDisconnectCallback(pFreeRdpDisconnectedCallback disconnectCallback) + { + _disconnectCallback = disconnectCallback; + return S_OK; + } +} \ No newline at end of file diff --git a/UiPath.FreeRdpClient/UiPath.FreeRdpWrapper/FreeRdpWrapper.h b/UiPath.FreeRdpClient/UiPath.FreeRdpWrapper/FreeRdpWrapper.h new file mode 100644 index 000000000000..5ecb9ac3bb59 --- /dev/null +++ b/UiPath.FreeRdpClient/UiPath.FreeRdpWrapper/FreeRdpWrapper.h @@ -0,0 +1,30 @@ +#pragma once + +#include "pch.h" + +namespace FreeRdpClient +{ + typedef struct + { + long Width; + long Height; + long Depth; + BOOL FontSmoothing; + BSTR User; + BSTR Domain; + BSTR Pass; + BSTR ScopeName; + BSTR ClientName; + BSTR HostName; + long Port; + } ConnectOptions; + + using pFreeRdpDisconnectedCallback = void (*)(BSTR); + + EXTERN_C __declspec(dllexport) HRESULT STDAPICALLTYPE + RdpLogon( + ConnectOptions* rdpOptions, + BSTR& releaseEventName); + EXTERN_C __declspec(dllexport) HRESULT STDAPICALLTYPE RdpRelease(BSTR releaseEventName); + EXTERN_C __declspec(dllexport) HRESULT STDAPICALLTYPE SetDisconnectCallback(pFreeRdpDisconnectedCallback disconnectCallback); +} \ No newline at end of file diff --git a/UiPath.FreeRdpClient/UiPath.FreeRdpWrapper/Logging.cpp b/UiPath.FreeRdpClient/UiPath.FreeRdpWrapper/Logging.cpp new file mode 100644 index 000000000000..daa7c03aa350 --- /dev/null +++ b/UiPath.FreeRdpClient/UiPath.FreeRdpWrapper/Logging.cpp @@ -0,0 +1,79 @@ +#include "pch.h" +#include "Logging.h" +#include "winpr/wlog.h" + +namespace Logging +{ + static pRegisterThreadScopeCallback _registerThreadScopeCallback = nullptr; + static pLogCallback _clientLogCallback = nullptr; + static wLogCallbacks _wlogCallbacks = { 0 }; + static char _defaultCategory[] = "UiPath.FreeRdpWrapper"; + + BOOL wLog_Message(const wLogMessage* msg) + { + if (!_clientLogCallback) + return FALSE; + + wchar_t wbuffer[MAX_TRACE_MSG]; + mbstowcs_s(nullptr, wbuffer, msg->TextString, MAX_TRACE_MSG); + + _clientLogCallback(msg->PrefixString, msg->Level, wbuffer); + return true; + } + + HRESULT STDAPICALLTYPE InitializeLogging( + pLogCallback logCallback, + pRegisterThreadScopeCallback registerThreadScopeCallback, + BOOL forwardFreeRdpLogs + ) + { + _clientLogCallback = logCallback; + _registerThreadScopeCallback = registerThreadScopeCallback; + if (!_clientLogCallback) + { + return S_OK; + } + + wLog* log = WLog_GetRoot(); + if (!forwardFreeRdpLogs) + { + log = WLog_Get(_defaultCategory); + } + + WLog_SetLogAppenderType(log, WLOG_APPENDER_CALLBACK); + auto appender = WLog_GetLogAppender(log); + _wlogCallbacks.message = wLog_Message; + WLog_ConfigureAppender(appender, "callbacks", &_wlogCallbacks); + auto layout = WLog_GetLogLayout(log); + WLog_Layout_SetPrefixFormat(log, layout, "%mn"); + WLog_SetLogLevel(log, WLOG_INFO); + + auto negoLog = WLog_Get("com.freerdp.core.nego"); + WLog_SetLogLevel(negoLog, WLOG_TRACE); + + WLog_INFO("UiPath.FreeRdpLogging", "Native logging forwarding initialized. (forwardFreeRdpLogs:%s)", forwardFreeRdpLogs ? "true" : "false"); + + return S_OK; + } + + void Log(DWORD level, const wchar_t* fmt, ...) + { + if (!_clientLogCallback) + return; + + va_list args; + va_start(args, fmt); + wchar_t wBuffer[MAX_TRACE_MSG]; + vswprintf(wBuffer, _countof(wBuffer), fmt, args); + va_end(args); + _clientLogCallback(_defaultCategory, level, wBuffer); + } + + void RegisterCurrentThreadScope(char* scope) + { + if (!_registerThreadScopeCallback) + return; + + _registerThreadScopeCallback(scope); + } +} \ No newline at end of file diff --git a/UiPath.FreeRdpClient/UiPath.FreeRdpWrapper/Logging.h b/UiPath.FreeRdpClient/UiPath.FreeRdpWrapper/Logging.h new file mode 100644 index 000000000000..4ae1fbcbe7eb --- /dev/null +++ b/UiPath.FreeRdpClient/UiPath.FreeRdpWrapper/Logging.h @@ -0,0 +1,35 @@ +#pragma once + +#include "pch.h" +#include "winpr/wlog.h" + +namespace Logging +{ + +#define MAX_TRACE_MSG 2048 +#define DT_ERROR(format, ...) Logging::Log(WLOG_ERROR, format, __VA_ARGS__) +#define DT_TRACE(format, ...) Logging::Log(WLOG_TRACE, format, __VA_ARGS__) + +#define CHECK_HRESULT_RET_HR(Stmt) \ + { \ + HRESULT hrTmp = Stmt; \ + if (FAILED(hrTmp)) \ + { \ + DT_ERROR(L"%S:%d: error: %u [%x]", __FUNCTION__, __LINE__, hrTmp, hrTmp); \ + return hrTmp; \ + } \ + } + + using pLogCallback = void (*)(char* category, DWORD errorLevel, wchar_t* message); + using pRegisterThreadScopeCallback = void (*)(char* category); + + EXTERN_C __declspec(dllexport) HRESULT STDAPICALLTYPE + InitializeLogging( + pLogCallback logCallback, + pRegisterThreadScopeCallback registerThreadScopeCallback, + BOOL forwardFreeRdpLogs + ); + + void Log(DWORD level, const wchar_t* fmt, ...); + void RegisterCurrentThreadScope(char* scope); +} \ No newline at end of file diff --git a/UiPath.FreeRdpClient/UiPath.FreeRdpWrapper/UiPath.FreeRdpWrapper.vcxproj b/UiPath.FreeRdpClient/UiPath.FreeRdpWrapper/UiPath.FreeRdpWrapper.vcxproj new file mode 100644 index 000000000000..165a966c0d04 --- /dev/null +++ b/UiPath.FreeRdpClient/UiPath.FreeRdpWrapper/UiPath.FreeRdpWrapper.vcxproj @@ -0,0 +1,130 @@ + + + + + Debug + x64 + + + Release + x64 + + + + 16.0 + Win32Proj + {ea15a988-201d-4f25-8ca4-8d324996799c} + UiPathFreeRdpWrapper + 10.0 + UiPath.FreeRdpWrapper + + + + DynamicLibrary + true + v143 + Unicode + x64 + + + DynamicLibrary + false + v143 + true + Unicode + x64 + + + + + + + + + + + + + + + + + + + + + $(CppOutDir)\$(Configuration)\$(PlatformTarget)\ + $(ExternalIncludePath) + $(SourcePath) + $(CppOutDir)\obj\$(Platform)\$(Configuration)\ + + + + Level3 + true + true + Create + pch.h + ..\..\include;..\..\winpr\include;..\..\Build\$(PlatformTarget)\winpr\include;..\..\Build\$(PlatformTarget)\include;%(AdditionalIncludeDirectories) + WIN$(PlatformArchitecture);FREERDP_EXPORTS;_WINDOWS;_USRDLL;APP_VERSION="$(AppVersion)";%(PreprocessorDefinitions) + MultiThreadedDebug + + + Windows + true + false + comsuppw.lib;comsupp.lib;freerdp2.lib;winpr2.lib;libcrypto.lib;libssl.lib;Dbghelp.lib;Secur32.lib;ntdsapi.lib;Rpcrt4.lib;Crypt32.lib;ncrypt.lib;Userenv.lib;ws2_32.lib;ntdll.lib;%(AdditionalDependencies) + ..\..\..\OpenSSL-VC-$(PlatformArchitecture)\lib;..\..\Build\$(PlatformTarget)\$(Configuration) + + + + + APP_VERSION=\"$(AppVersion)\";APP_VERSION_BINARY=$(AppVersion.Replace('.', ','));%(PreprocessorDefinitions) + + + + + Level3 + true + true + true + true + Create + pch.h + ..\..\include;..\..\winpr\include;..\..\Build\$(PlatformTarget)\winpr\include;..\..\Build\$(PlatformTarget)\include;%(AdditionalIncludeDirectories) + WIN$(PlatformArchitecture);FREERDP_EXPORTS;_WINDOWS;_USRDLL;APP_VERSION="$(AppVersion)";%(PreprocessorDefinitions) + MultiThreaded + false + + + Windows + true + true + true + false + comsuppw.lib;comsupp.lib;freerdp2.lib;winpr2.lib;libcrypto.lib;libssl.lib;Dbghelp.lib;Secur32.lib;ntdsapi.lib;Rpcrt4.lib;Crypt32.lib;ncrypt.lib;Userenv.lib;ws2_32.lib;ntdll.lib;%(AdditionalDependencies) + ..\..\..\OpenSSL-VC-$(PlatformArchitecture)\lib;..\..\Build\$(PlatformTarget)\$(Configuration) + + + + + APP_VERSION=\"$(AppVersion)\";APP_VERSION_BINARY=$(AppVersion.Replace('.', ','));%(PreprocessorDefinitions) + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/UiPath.FreeRdpClient/UiPath.FreeRdpWrapper/UiPath.FreeRdpWrapper.vcxproj.filters b/UiPath.FreeRdpClient/UiPath.FreeRdpWrapper/UiPath.FreeRdpWrapper.vcxproj.filters new file mode 100644 index 000000000000..7c1dd4c139d5 --- /dev/null +++ b/UiPath.FreeRdpClient/UiPath.FreeRdpWrapper/UiPath.FreeRdpWrapper.vcxproj.filters @@ -0,0 +1,44 @@ + + + + + {4FC737F1-C7A5-4376-A066-2A32D752A2FF} + cpp;c;cc;cxx;c++;cppm;ixx;def;odl;idl;hpj;bat;asm;asmx + + + {93995380-89BD-4b04-88EB-625FBE52EBFB} + h;hh;hpp;hxx;h++;hm;inl;inc;ipp;xsd + + + {67DA6AB6-F800-4c08-8B7A-83BB121AAD01} + rc;ico;cur;bmp;dlg;rc2;rct;bin;rgs;gif;jpg;jpeg;jpe;resx;tiff;tif;png;wav;mfcribbon-ms + + + + + Header Files + + + Header Files + + + Header Files + + + Header Files + + + + + Source Files + + + Source Files + + + + + Resource Files + + + \ No newline at end of file diff --git a/UiPath.FreeRdpClient/UiPath.FreeRdpWrapper/Version.rc b/UiPath.FreeRdpClient/UiPath.FreeRdpWrapper/Version.rc new file mode 100644 index 000000000000..090537868de6 --- /dev/null +++ b/UiPath.FreeRdpClient/UiPath.FreeRdpWrapper/Version.rc @@ -0,0 +1,100 @@ +// Microsoft Visual C++ generated resource script. +// +#include "resource.h" + +#define APSTUDIO_READONLY_SYMBOLS +///////////////////////////////////////////////////////////////////////////// +// +// Generated from the TEXTINCLUDE 2 resource. +// +#include "winres.h" + +///////////////////////////////////////////////////////////////////////////// +#undef APSTUDIO_READONLY_SYMBOLS + +///////////////////////////////////////////////////////////////////////////// +// English (United States) resources + +#if !defined(AFX_RESOURCE_DLL) || defined(AFX_TARG_ENU) +LANGUAGE LANG_ENGLISH, SUBLANG_ENGLISH_US +#pragma code_page(1252) + +#ifdef APSTUDIO_INVOKED +///////////////////////////////////////////////////////////////////////////// +// +// TEXTINCLUDE +// + +1 TEXTINCLUDE +BEGIN + "resource.h\0" +END + +2 TEXTINCLUDE +BEGIN + "#include ""winres.h""\r\n" + "\0" +END + +3 TEXTINCLUDE +BEGIN + "\r\n" + "\0" +END + +#endif // APSTUDIO_INVOKED + + +///////////////////////////////////////////////////////////////////////////// +// +// Version +// + +VS_VERSION_INFO VERSIONINFO + FILEVERSION APP_VERSION_BINARY + PRODUCTVERSION APP_VERSION_BINARY + FILEFLAGSMASK 0x3fL +#ifdef _DEBUG + FILEFLAGS 0x1L +#else + FILEFLAGS 0x0L +#endif + FILEOS 0x40004L + FILETYPE 0x0L + FILESUBTYPE 0x0L +BEGIN + BLOCK "StringFileInfo" + BEGIN + BLOCK "040904b0" + BEGIN + VALUE "CompanyName", "UiPath" + VALUE "FileDescription", "UiPath.FreeRdpWrapper" + VALUE "FileVersion", APP_VERSION + VALUE "InternalName", "UiPath.FreeRdpWrapper.dll" + VALUE "LegalCopyright", "Copyright (C) UiPath" + VALUE "OriginalFilename", "UiPath.FreeRdpWrapper.dll" + VALUE "ProductName", "UiPath" + VALUE "ProductVersion", APP_VERSION + END + END + BLOCK "VarFileInfo" + BEGIN + VALUE "Translation", 0x409, 1200 + END +END + +#endif // English (United States) resources +///////////////////////////////////////////////////////////////////////////// + + + +#ifndef APSTUDIO_INVOKED +///////////////////////////////////////////////////////////////////////////// +// +// Generated from the TEXTINCLUDE 3 resource. +// + + +///////////////////////////////////////////////////////////////////////////// +#endif // not APSTUDIO_INVOKED + diff --git a/UiPath.FreeRdpClient/UiPath.FreeRdpWrapper/pch.h b/UiPath.FreeRdpClient/UiPath.FreeRdpWrapper/pch.h new file mode 100644 index 000000000000..a3f8632d5afd --- /dev/null +++ b/UiPath.FreeRdpClient/UiPath.FreeRdpWrapper/pch.h @@ -0,0 +1,20 @@ +// pch.h: This is a precompiled header file. +// Files listed below are compiled only once, improving build performance for future builds. +// This also affects IntelliSense performance, including code completion and many code browsing features. +// However, files listed here are ALL re-compiled if any one of them is updated between builds. +// Do not add files here that you will be updating frequently as this negates the performance advantage. +#ifndef FRW_PCH_H +#define FRW_PCH_H + +// add headers that you want to pre-compile here + +#define WIN32_LEAN_AND_MEAN // Exclude rarely-used stuff from Windows headers +// Windows Header Files +#include +#include +#include +#include +#include +#include + +#endif \ No newline at end of file diff --git a/UiPath.FreeRdpClient/UiPath.FreeRdpWrapper/resource.h b/UiPath.FreeRdpClient/UiPath.FreeRdpWrapper/resource.h new file mode 100644 index 000000000000..b2e97dc3229c --- /dev/null +++ b/UiPath.FreeRdpClient/UiPath.FreeRdpWrapper/resource.h @@ -0,0 +1,14 @@ +//{{NO_DEPENDENCIES}} +// Microsoft Visual C++ generated include file. +// Used by Version.rc +// +// Next default values for new objects +// +#ifdef APSTUDIO_INVOKED +#ifndef APSTUDIO_READONLY_SYMBOLS +#define _APS_NEXT_RESOURCE_VALUE 103 +#define _APS_NEXT_COMMAND_VALUE 40001 +#define _APS_NEXT_CONTROL_VALUE 1001 +#define _APS_NEXT_SYMED_VALUE 101 +#endif +#endif diff --git a/UiPath.FreeRdpClient/UiPath.SessionTools.Tests/AssertionExtensions.cs b/UiPath.FreeRdpClient/UiPath.SessionTools.Tests/AssertionExtensions.cs new file mode 100644 index 000000000000..13529611e724 --- /dev/null +++ b/UiPath.FreeRdpClient/UiPath.SessionTools.Tests/AssertionExtensions.cs @@ -0,0 +1,25 @@ +namespace UiPath.SessionTools.Tests; + +internal static class AssertionExtensions +{ + public static ulong ShouldBeUlongParsable(this string candidate) + { + ulong.TryParse(candidate, out var result).ShouldBeTrue(); + return result; + } + + public static IEnumerable ShouldAllBeUlongParsable(this IEnumerable candidates) + => candidates.Select( + candidate => candidate.ShouldBeUlongParsable()); + + public static void ShouldBeConsecutive(this IEnumerable numbers, ulong start = default) + { + ulong expected = start; + + foreach (var number in numbers) + { + number.ShouldBe(expected); + expected++; + } + } +} diff --git a/UiPath.FreeRdpClient/UiPath.SessionTools.Tests/EnsureUserIsSetupTests.cs b/UiPath.FreeRdpClient/UiPath.SessionTools.Tests/EnsureUserIsSetupTests.cs new file mode 100644 index 000000000000..143e7999e1c1 --- /dev/null +++ b/UiPath.FreeRdpClient/UiPath.SessionTools.Tests/EnsureUserIsSetupTests.cs @@ -0,0 +1,73 @@ +using Microsoft.Extensions.Logging; +using Xunit.Abstractions; + +namespace UiPath.SessionTools.Tests; + +[Trait("Subject", $"{nameof(Commands)}.{nameof(Commands.EnsureUserIsSetUp)}")] +public class EnsureUserIsSetupTests : IAsyncLifetime +{ + private const string Username = "useradmin4f742c874a1"; + private string? _password; + + private readonly ILoggerFactory _loggerFactory; + private readonly ILogger _logger; + private readonly ProcessRunner _processRunner; + private readonly UserChecks _userChecks; + + public EnsureUserIsSetupTests(ITestOutputHelper outputHelper) + { + _loggerFactory = LoggerFactory.Create(builder => builder.AddXUnit(outputHelper)); + _logger = _loggerFactory.CreateLogger(); + _processRunner = new(_logger); + _userChecks = new(_logger); + } + + [Fact] + public async Task ItShould_CreateAUserWithTheProperCharacteristics() + { + _password.ShouldNotBeNull(); + _userChecks.UserExistsAndHasPassword(Username, _password).ShouldBeFalse(); + + using var ctsTimeout = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + + await _processRunner.EnsureUserIsSetUp(Username, _password, admin: true, ctsTimeout.Token); + + _userChecks.UserExistsAndHasPassword(Username, _password).ShouldBeTrue(); + + _userChecks.GetAccountInfo(Username) + .ShouldNotBeNull() + .ShouldSatisfyAllConditions( + info => info.Enabled.ShouldBe(true), + info => info.AccountExpirationDate.ShouldBeNull(), + info => info.PasswordNeverExpires.ShouldBeTrue(), + info => info.UserCannotChangePassword.ShouldBeTrue(), + info => info.GroupNames.ShouldContain("Administrators")); + } + + private async Task EnsureUserIsDeleted() + { + try + { + await _processRunner.Run("net", $"user {Username} /delete"); + } + catch (Exception ex) + { + _logger.LogWarning(ex, message: null); + } + } + + async Task IAsyncLifetime.InitializeAsync() + { + await EnsureUserIsDeleted(); + _password = GeneratePassword(); + + static string GeneratePassword() + => $"Ua@1{Path.GetRandomFileName()}".Substring(0, 14); + } + + async Task IAsyncLifetime.DisposeAsync() + { + await EnsureUserIsDeleted(); + _loggerFactory.Dispose(); + } +} diff --git a/UiPath.FreeRdpClient/UiPath.SessionTools.Tests/InternalCommandsTests.cs b/UiPath.FreeRdpClient/UiPath.SessionTools.Tests/InternalCommandsTests.cs new file mode 100644 index 000000000000..c3d15b2af297 --- /dev/null +++ b/UiPath.FreeRdpClient/UiPath.SessionTools.Tests/InternalCommandsTests.cs @@ -0,0 +1,84 @@ +using Microsoft.Extensions.Logging; +using Moq; +using System.Runtime.CompilerServices; + +namespace UiPath.SessionTools.Tests; + +using static Commands; +using Sut = Commands; + +[Trait("Subject", nameof(Commands))] +public class InternalCommandsTests +{ + public InternalCommandsTests() + { + _logger = new(); + _processRunner = new(_logger.Object); + } + + private readonly Mock _logger; + private readonly Mock _processRunner; + + [Theory(DisplayName = $"{nameof(Sut.UserExists)} should return the truth value of the exit code being equal to 0.")] + [InlineData(0, true)] + [InlineData(1, false)] + public async Task UserExists_ShouldReturnTheTruthValueOfExitCodeEquals0(int exitCode, bool expected) + { + _processRunner.SetupRun(exitCode); + + (await _processRunner.Object.UserExists("foo")) + .ShouldBe(expected); + } + + [Theory(DisplayName = $"Call should not throw when the exit code is 0.")] + [MemberData(nameof(EnumerateCallsForAllThrowingCommands))] + public async Task CreateUser_ShouldNotThrow_WhenExitCodeIs0(ThrowingCommandCall call) + { + _processRunner.SetupRun(exitCode: 0); + + await call.Value(_processRunner.Object) + .ShouldNotThrowAsync(); + } + + [Theory(DisplayName = $"Call should throw when the exit code is not 0.")] + [MemberData(nameof(EnumerateCallsForAllThrowingCommands))] + public async Task CreateUser_ShouldThrow_WhenExitCodeIsNot0(ThrowingCommandCall call) + { + _processRunner.SetupRun(exitCode: 1); + + await call.Value(_processRunner.Object) + .ShouldThrowAsync(); + } + + [Theory(DisplayName = $"{nameof(Sut.EnsureUserIsInGroup)} should not throw regardless of the exit code.")] + [InlineData(0)] + [InlineData(1)] + public async Task EnsureUserIsInGroup_ShouldNotThrow_RegardlessOfTheExitCode(int exitCode) + { + _processRunner.SetupRun(exitCode); + + var act = () => _processRunner.Object.EnsureUserIsInGroup("some user", "some group"); + + await act.ShouldNotThrowAsync(); + } + + public readonly record struct ThrowingCommandCall(Func Value, string ToStringValue) + { + public override string ToString() => ToStringValue; + } + + private static IEnumerable EnumerateCallsForAllThrowingCommands() + { + yield return Make(_ => _.CreateUser("UserName", "Password")); + yield return Make(_ => _.SetPassword("UserName", "Password")); + yield return Make(_ => _.ActivateUserAndDisableExpiration("UserName")); + yield return Make(_ => _.ProhibitPasswordChange("UserName")); + yield return Make(_ => _.DisablePasswordExpiration("UserName")); + + object[] Make(Func value, [CallerArgumentExpression(nameof(value))] string toStringValue = null!) + { + ThrowingCommandCall call = new(value, toStringValue); + return new object[] { call }; + } + } +} diff --git a/UiPath.FreeRdpClient/UiPath.SessionTools.Tests/LoggingTests.cs b/UiPath.FreeRdpClient/UiPath.SessionTools.Tests/LoggingTests.cs new file mode 100644 index 000000000000..71cad54a27a8 --- /dev/null +++ b/UiPath.FreeRdpClient/UiPath.SessionTools.Tests/LoggingTests.cs @@ -0,0 +1,18 @@ +namespace UiPath.SessionTools.Tests; + +using Microsoft.Extensions.Logging; +using System.Reflection; +using static Logging; + +[Trait("Subject", nameof(Logging))] +public class LoggingTests +{ + [Fact(DisplayName = $"{nameof(FormatRunFailed)} should return the template used by {nameof(Logging.LogRunFailed)} when provided with args equal to their respective names.")] + public void FormatRunFailed_ShouldReturnAStrEqualToRunFinished_WhenTheArgsEqualTheirNames() + { + var @delegate = Logging.LogRunFailed; + + FormatRunFailed("{fileName}", "{arguments}", "{exitCode}", "{stdout}", "{stderr}") + .ShouldBe(@delegate.Method.GetCustomAttribute()!.Message); + } +} diff --git a/UiPath.FreeRdpClient/UiPath.SessionTools.Tests/ProcessRunnerTests.cs b/UiPath.FreeRdpClient/UiPath.SessionTools.Tests/ProcessRunnerTests.cs new file mode 100644 index 000000000000..8396f8d85539 --- /dev/null +++ b/UiPath.FreeRdpClient/UiPath.SessionTools.Tests/ProcessRunnerTests.cs @@ -0,0 +1,46 @@ +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.TraceSource; +using System.Diagnostics; +using System.Text; + +namespace UiPath.SessionTools.Tests; + +[Trait("Subject", nameof(ProcessRunner))] +public class ProcessRunnerTests +{ + [Fact] + public async Task Run_ShouldProduceAConsistentReport_WhenCancellationOccurs() + { + const string reachable1 = "bf2d62bdc431482fbc5b8b5587cb5ee2"; + const string reachable2 = "4da0af0dae7246e998a5c579e922041f"; + const string unreachable = "44c681c32fd14b8fb3fda81371970f52"; + + CreateSpyLogger(out var logger, out var sbLogs); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + + var act = () => new ProcessRunner(logger).Run( + fileName: "cmd.exe", + arguments: $"/c echo {reachable1} & echo {reachable2} & ping -l 0 -n 30 127.0.0.1 & echo {unreachable}", + workingDirectory: "", + throwOnNonZero: true, + ct: cts.Token); + + await act.ShouldThrowAsync(); + + var logs = sbLogs.ToString(); + + CountAppearances(reachable1, logs).ShouldBe(3); + CountAppearances(reachable2, logs).ShouldBe(3); // The reachable strings appear twice in the command lines and once in the stdout. + CountAppearances(unreachable, logs).ShouldBe(2); // The unreachable string appears only in the command lines. + } + + private static void CreateSpyLogger(out ILogger logger, out StringBuilder sbLogs) + => logger = new TraceSourceLoggerProvider( + new SourceSwitch(name: "") { Level = SourceLevels.All }, + new TextWriterTraceListener(new StringWriter(sbLogs = new StringBuilder()))) + .CreateLogger("spy"); + + private static int CountAppearances(string needle, string haystack) + => haystack.Split(needle).Length - 1; +} diff --git a/UiPath.FreeRdpClient/UiPath.SessionTools.Tests/Properties/launchSettings.json b/UiPath.FreeRdpClient/UiPath.SessionTools.Tests/Properties/launchSettings.json new file mode 100644 index 000000000000..ccd544627de2 --- /dev/null +++ b/UiPath.FreeRdpClient/UiPath.SessionTools.Tests/Properties/launchSettings.json @@ -0,0 +1,8 @@ +{ + "profiles": { + "UiPath.SessionTools.Tests": { + "commandName": "Project", + "nativeDebugging": true + } + } +} \ No newline at end of file diff --git a/UiPath.FreeRdpClient/UiPath.SessionTools.Tests/TestHelpers.cs b/UiPath.FreeRdpClient/UiPath.SessionTools.Tests/TestHelpers.cs new file mode 100644 index 000000000000..061053b09450 --- /dev/null +++ b/UiPath.FreeRdpClient/UiPath.SessionTools.Tests/TestHelpers.cs @@ -0,0 +1,40 @@ +using Moq; +using System.Diagnostics.CodeAnalysis; +using System.Linq.Expressions; + +namespace UiPath.SessionTools.Tests; + +internal static class TestHelpers +{ + public static void SetupRun(this Mock mock, int exitCode, string output = "mock-output") + { + mock.Setup(Run(throwsOnNonZero: false)) + .ReturnsAsync((output, exitCode)); + + if (exitCode is 0) + { + mock.Setup(Run(throwsOnNonZero: true)) + .ReturnsAsync((output, exitCode)); + return; + } + + mock.Setup(Run(throwsOnNonZero: true)) + .ThrowsAsync(new ProcessRunner.NonZeroExitCodeException(exitCode, output, "mock-error", "mock-message")); + } + + private static Expression>> Run(bool throwsOnNonZero) + => processRunner => processRunner.Run( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.Is(throwsOnNonZero, BoolComparer.Instance), + It.IsAny()); + + private sealed class BoolComparer : IEqualityComparer + { + public static readonly BoolComparer Instance = new(); + + public bool Equals(bool x, bool y) => x == y; + public int GetHashCode([DisallowNull] bool obj) => obj.GetHashCode(); + } +} diff --git a/UiPath.FreeRdpClient/UiPath.SessionTools.Tests/UiPath.SessionTools.Tests.csproj b/UiPath.FreeRdpClient/UiPath.SessionTools.Tests/UiPath.SessionTools.Tests.csproj new file mode 100644 index 000000000000..10b75fac402f --- /dev/null +++ b/UiPath.FreeRdpClient/UiPath.SessionTools.Tests/UiPath.SessionTools.Tests.csproj @@ -0,0 +1,26 @@ + + + + net6.0 + enable + false + true + true + preview + true + + + + + + + + + + + + + + + + diff --git a/UiPath.FreeRdpClient/UiPath.SessionTools.Tests/UserChecks.cs b/UiPath.FreeRdpClient/UiPath.SessionTools.Tests/UserChecks.cs new file mode 100644 index 000000000000..f4b7b5eed0a6 --- /dev/null +++ b/UiPath.FreeRdpClient/UiPath.SessionTools.Tests/UserChecks.cs @@ -0,0 +1,99 @@ +using Microsoft.Extensions.Logging; +using System.ComponentModel; +using System.Diagnostics; +using System.DirectoryServices.AccountManagement; +using System.Security; + +namespace UiPath.SessionTools.Tests; + +internal class UserChecks +{ + private readonly ILogger _logger; + + public UserChecks(ILogger logger) + { + _logger = logger; + } + + public bool UserExistsAndHasPassword(string username, string password) + { + using Process process = new() + { + StartInfo = new() + { + FileName = "cmd.exe", + UserName = username, + Password = ToSecure(password), + UseShellExecute = false, + } + }; + + try + { + _ = process.Start(); + return true; + } + catch (Win32Exception ex) when (ex is { NativeErrorCode: 1326 or 1311 }) + { + return false; + } + catch (Exception ex) + { + _logger.LogError(ex, message: null); + throw; + } + finally + { + try + { + process.Kill(entireProcessTree: true); + } + catch (Exception ex) + { + _logger.LogWarning(ex, message: null); + } + } + + static SecureString ToSecure(string str) + { + SecureString secure = new(); + foreach (char ch in str) + { + secure.AppendChar(ch); + } + return secure; + } + } + + public AccountInfo? GetAccountInfo(string username) + { + using PrincipalContext context = new(ContextType.Machine); + using var principal = Principal.FindByIdentity(context, username); + + if (principal is null) + { + return null; + } + + if (principal is not AuthenticablePrincipal authenticatable) + { + throw new InvalidOperationException(); + } + + var groupNames = authenticatable.GetGroups().Select(x => x.Name).ToArray(); + + return new( + Enabled: authenticatable.Enabled, + AccountExpirationDate: authenticatable.AccountExpirationDate, + PasswordNeverExpires: authenticatable.PasswordNeverExpires, + UserCannotChangePassword: authenticatable.UserCannotChangePassword, + GroupNames: groupNames); + } + + public record struct AccountInfo( + bool? Enabled, + DateTime? AccountExpirationDate, + bool PasswordNeverExpires, + bool UserCannotChangePassword, + IReadOnlyList GroupNames); +} diff --git a/UiPath.FreeRdpClient/UiPath.SessionTools.Tests/Usings.cs b/UiPath.FreeRdpClient/UiPath.SessionTools.Tests/Usings.cs new file mode 100644 index 000000000000..bd8299f6f213 --- /dev/null +++ b/UiPath.FreeRdpClient/UiPath.SessionTools.Tests/Usings.cs @@ -0,0 +1,2 @@ +global using Xunit; +global using Shouldly; \ No newline at end of file diff --git a/UiPath.FreeRdpClient/UiPath.SessionTools.Tests/WtsTests.cs b/UiPath.FreeRdpClient/UiPath.SessionTools.Tests/WtsTests.cs new file mode 100644 index 000000000000..5c1c36d6e271 --- /dev/null +++ b/UiPath.FreeRdpClient/UiPath.SessionTools.Tests/WtsTests.cs @@ -0,0 +1,28 @@ +using Windows.Win32.System.RemoteDesktop; + +namespace UiPath.SessionTools.Tests; + +[Trait("Subject", nameof(Wts))] +public class WtsTests +{ + [Fact(DisplayName = $"{nameof(Wts.QuerySessionInformation)}.{nameof(WtsInfoProviderExtensions.ConnectState)} should work.")] + public void ConnectState_ShouldWork() + { + var connectState = new Wts().QuerySessionInformation(sessionId: 0).ConnectState(); + connectState.ShouldBe(WTS_CONNECTSTATE_CLASS.WTSDisconnected); + } + + [Fact(DisplayName = $"{nameof(Wts.QuerySessionInformation)}.{nameof(WtsInfoProviderExtensions.ClientDisplay)} should work.")] + public void ClientDisplay_ShouldWork() + { + var act = () => _ = new Wts().QuerySessionInformation(sessionId: 0).ClientDisplay(); + act.ShouldNotThrow(); + } + + [Fact(DisplayName = $"{nameof(Wts.QuerySessionInformation)}.{nameof(WtsInfoProviderExtensions.SessionInfo)} should work.")] + public void SessionInfo_ShouldWork() + { + var act = () => _ = new Wts().QuerySessionInformation(sessionId: 0).SessionInfo(); + act.ShouldNotThrow(); + } +} diff --git a/UiPath.FreeRdpClient/UiPath.SessionTools/Helpers/PInvoke.WtsApi32.cs b/UiPath.FreeRdpClient/UiPath.SessionTools/Helpers/PInvoke.WtsApi32.cs new file mode 100644 index 000000000000..a0147163c8e0 --- /dev/null +++ b/UiPath.FreeRdpClient/UiPath.SessionTools/Helpers/PInvoke.WtsApi32.cs @@ -0,0 +1,72 @@ +using System.Runtime.InteropServices; +using System.Runtime.Versioning; + +namespace Windows.Win32; + +partial class PInvoke//WtsApi32 +{ + /// Connects a Remote Desktop Services session to an existing session on the local computer. + /// + /// The logon ID of the session to connect to. The user of that session must have permissions to connect to an existing session. The output of this session will be routed to the session identified by the TargetLogonId parameter. This can be LOGONID_CURRENT to use the current session. + /// Read more on docs.microsoft.com. + /// + /// + /// The logon ID of the session to receive the output of the session represented by the LogonId parameter. The output of the session identified by the LogonId parameter will be routed to this session. This can be LOGONID_CURRENT to use the current session. + /// Read more on docs.microsoft.com. + /// + /// A pointer to the password for the user account that is specified in the LogonId parameter. The value of pPassword can be an empty string if the caller is logged on using the same domain name and user name as the logon ID. The value of pPassword cannot be NULL. + /// Indicates whether the operation is synchronous. Specify TRUE to wait for the operation to complete, or FALSE to return immediately. + /// + /// If the function succeeds, the return value is a nonzero value. If the function fails, the return value is zero. To get extended error information, call GetLastError. + /// + /// + /// Learn more about this API from docs.microsoft.com. + /// + [DllImport("WtsApi32", ExactSpelling = true, EntryPoint = "WTSConnectSessionW", SetLastError = true)] + [DefaultDllImportSearchPaths(DllImportSearchPath.System32)] + [SupportedOSPlatform("windows6.0.6000")] + internal static extern Foundation.BOOL WTSConnectSession(uint LogonId, uint TargetLogonId, [MarshalAs(UnmanagedType.LPWStr)][In] string pPassword, bool bWait); + + /// Retrieves session information for the specified session on the specified Remote Desktop Session Host (RD Session Host) server. + /// + /// A handle to an RD Session Host server. Specify a handle opened by the WTSOpenServer function, or specify WTS_CURRENT_SERVER_HANDLE to indicate the RD Session Host server on which your application is running. + /// Read more on docs.microsoft.com. + /// + /// + /// A Remote Desktop Services session identifier. To indicate the session in which the calling application is running (or the current session) specify WTS_CURRENT_SESSION. Only specify WTS_CURRENT_SESSION when obtaining session information on the local server. If WTS_CURRENT_SESSION is specified when querying session information on a remote server, the returned session information will be inconsistent. Do not use the returned data. You can use the WTSEnumerateSessions function to retrieve the identifiers of all sessions on a specified RD Session Host server. To query information for another user's session, you must have Query Information permission. For more information, see Remote Desktop Services Permissions. To modify permissions on a session, use the Remote Desktop Services Configuration administrative tool. + /// Read more on docs.microsoft.com. + /// + /// + /// A value of the WTS_INFO_CLASS enumeration that indicates the type of session information to retrieve in a call to the WTSQuerySessionInformation function. + /// Read more on docs.microsoft.com. + /// + /// + /// A pointer to a variable that receives a pointer to the requested information. The format and contents of the data depend on the information class specified in the WTSInfoClass parameter. To free the returned buffer, call the WTSFreeMemory function. + /// Read more on docs.microsoft.com. + /// + /// + /// A pointer to a variable that receives the size, in bytes, of the data returned in ppBuffer. + /// Read more on docs.microsoft.com. + /// + /// + /// If the function succeeds, the return value is a nonzero value. If the function fails, the return value is zero. To get extended error information, call GetLastError. + /// + /// + /// Learn more about this API from docs.microsoft.com. + /// + [DllImport("WtsApi32", ExactSpelling = true, EntryPoint = "WTSQuerySessionInformationW", SetLastError = true)] + [DefaultDllImportSearchPaths(DllImportSearchPath.System32)] + [SupportedOSPlatform("windows6.0.6000")] + internal static extern Foundation.BOOL WTSQuerySessionInformation(Foundation.HANDLE hServer, uint SessionId, System.RemoteDesktop.WTS_INFO_CLASS WTSInfoClass, out IntPtr ppBuffer, out uint pBytesReturned); + + /// Frees memory allocated by a Remote Desktop Services function. + /// Pointer to the memory to free. + /// + /// Several Remote Desktop Services functions allocate buffers to return information. Use the WTSFreeMemory function to free these buffers. + /// Read more on docs.microsoft.com. + /// + [DllImport("WtsApi32", ExactSpelling = true)] + [DefaultDllImportSearchPaths(DllImportSearchPath.System32)] + [SupportedOSPlatform("windows6.0.6000")] + internal static extern void WTSFreeMemory(IntPtr pMemory); +} diff --git a/UiPath.FreeRdpClient/UiPath.SessionTools/Helpers/PInvoke.cs b/UiPath.FreeRdpClient/UiPath.SessionTools/Helpers/PInvoke.cs new file mode 100644 index 000000000000..913f0be4faa1 --- /dev/null +++ b/UiPath.FreeRdpClient/UiPath.SessionTools/Helpers/PInvoke.cs @@ -0,0 +1,20 @@ +using System.Runtime.InteropServices; +using UiPath.SessionTools; +using Windows.Win32.Foundation; + +namespace Windows.Win32; + +partial class PInvoke +{ + public static void ThrowOnLastError(this BOOL pinvokeResult, string api) + { + var error = (WIN32_ERROR)Marshal.GetLastWin32Error(); + + if (pinvokeResult) + { + return; + } + + throw new PInvokeException(api, error); + } +} diff --git a/UiPath.FreeRdpClient/UiPath.SessionTools/Helpers/PInvokeException.cs b/UiPath.FreeRdpClient/UiPath.SessionTools/Helpers/PInvokeException.cs new file mode 100644 index 000000000000..1fcfa3fe8b58 --- /dev/null +++ b/UiPath.FreeRdpClient/UiPath.SessionTools/Helpers/PInvokeException.cs @@ -0,0 +1,41 @@ +using System.ComponentModel; +using Windows.Win32.Foundation; + +namespace UiPath.SessionTools; + +public class PInvokeException : Win32Exception +{ + public string Api { get; } + internal WIN32_ERROR Error { get; } + public string? KernelMessage { get; } + + private readonly string _exceptionMessage; + + internal PInvokeException(string api, WIN32_ERROR error) + { + Api = api; + Error = error; + + Decode(api, error, + out _exceptionMessage, + out string? kernelMessage); + KernelMessage = kernelMessage; + } + + public override int ErrorCode => ErrorCode; + + public override string Message => _exceptionMessage; + + private static void Decode(string api, WIN32_ERROR error, out string exceptionMessage, out string? kernelMessage) + { + if (error is WIN32_ERROR.NO_ERROR) + { + exceptionMessage = $"Win32 API call failed. The called API was: {api}. Could not retrieve further info."; + kernelMessage = null; + return; + } + + kernelMessage = new Win32Exception((int)error).Message; + exceptionMessage = $"Win32 API call failed. The called API was: {api}. Error: {error}={(int)error}=0x{error:X} \"{kernelMessage}\""; + } +} diff --git a/UiPath.FreeRdpClient/UiPath.SessionTools/Messages.cs b/UiPath.FreeRdpClient/UiPath.SessionTools/Messages.cs new file mode 100644 index 000000000000..f083b0b1b2b5 --- /dev/null +++ b/UiPath.FreeRdpClient/UiPath.SessionTools/Messages.cs @@ -0,0 +1,14 @@ +namespace UiPath.SessionTools; + +internal static class Messages +{ + public const string UnexpectedNetLocalGroupOutput = "Encountered an example of net localgroup whose stdout does not fit the expected format."; + + public static string NonZeroExitCode(string report) + => string.Format(NonZeroExitCodeTemplate, report); + + private const string NonZeroExitCodeTemplate = """ + The process exited with a non zero code. + {0} + """; +} diff --git a/UiPath.FreeRdpClient/UiPath.SessionTools/NativeMethods.json b/UiPath.FreeRdpClient/UiPath.SessionTools/NativeMethods.json new file mode 100644 index 000000000000..01643a3825d6 --- /dev/null +++ b/UiPath.FreeRdpClient/UiPath.SessionTools/NativeMethods.json @@ -0,0 +1 @@ +{ "$schema": "https://aka.ms/CsWin32.schema.json", "emitSingleFile": true } \ No newline at end of file diff --git a/UiPath.FreeRdpClient/UiPath.SessionTools/NativeMethods.txt b/UiPath.FreeRdpClient/UiPath.SessionTools/NativeMethods.txt new file mode 100644 index 000000000000..9397b9e183be --- /dev/null +++ b/UiPath.FreeRdpClient/UiPath.SessionTools/NativeMethods.txt @@ -0,0 +1 @@ +wtsapi32.* WTSGetActiveConsoleSessionId SendMessage IsOS OpenProcessToken OpenThreadToken ImpersonateLoggedOnUser RevertToSelf IsProcessInJob QueryInformationJobObject JOBOBJECT_BASIC_LIMIT_INFORMATION GetTokenInformation SetTokenInformation TOKEN_ELEVATION TOKEN_LINKED_TOKEN LookupPrivilegeValue AdjustTokenPrivileges GetCurrentThread DuplicateTokenEx LogonUser CreateProcessAsUser PROCESS_CREATION_FLAGS CreateEnvironmentBlock DestroyEnvironmentBlock OpenInputDesktop SetThreadDesktop LsaRegisterLogonProcess LsaLogonUser LsaFreeReturnBuffer LsaNtStatusToWinError LsaDeregisterLogonProcess LsaLookupAuthenticationPackage AllocateLocallyUniqueId KERB_CERTIFICATE_LOGON KERB_INTERACTIVE_LOGON KERB_INTERACTIVE_PROFILE LoadUserProfile UnloadUserProfile NetUserGetInfo PI_NOUI NEGOSSP_NAME_A MICROSOFT_KERBEROS_NAME_A CredPackAuthenticationBuffer WIN32_ERROR WTS_CLIENT_DISPLAY \ No newline at end of file diff --git a/UiPath.FreeRdpClient/UiPath.SessionTools/Polyfills/CompilerFeatureRequiredAttribute.cs b/UiPath.FreeRdpClient/UiPath.SessionTools/Polyfills/CompilerFeatureRequiredAttribute.cs new file mode 100644 index 000000000000..7399306fb67e --- /dev/null +++ b/UiPath.FreeRdpClient/UiPath.SessionTools/Polyfills/CompilerFeatureRequiredAttribute.cs @@ -0,0 +1,39 @@ +// Adapted from: https://github.com/dotnet/runtime/blob/fdd104ec5e1d0d2aa24a6723995a98d0124f724b/src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/CompilerFeatureRequiredAttribute.cs + +#if !NET7_0_OR_GREATER + +namespace System.Runtime.CompilerServices; + +/// +/// Indicates that compiler support for a particular feature is required for the location where this attribute is applied. +/// +[AttributeUsage(AttributeTargets.All, AllowMultiple = true, Inherited = false)] +public sealed class CompilerFeatureRequiredAttribute : Attribute +{ + public CompilerFeatureRequiredAttribute(string featureName) + { + FeatureName = featureName; + } + + /// + /// The name of the compiler feature. + /// + public string FeatureName { get; } + + /// + /// If true, the compiler can choose to allow access to the location where this attribute is applied if it does not understand . + /// + public bool IsOptional { get; init; } + + /// + /// The used for the ref structs C# feature. + /// + public const string RefStructs = nameof(RefStructs); + + /// + /// The used for the required members C# feature. + /// + public const string RequiredMembers = nameof(RequiredMembers); +} + +#endif \ No newline at end of file diff --git a/UiPath.FreeRdpClient/UiPath.SessionTools/Polyfills/RequiredMemberAttribute.cs b/UiPath.FreeRdpClient/UiPath.SessionTools/Polyfills/RequiredMemberAttribute.cs new file mode 100644 index 000000000000..994044229655 --- /dev/null +++ b/UiPath.FreeRdpClient/UiPath.SessionTools/Polyfills/RequiredMemberAttribute.cs @@ -0,0 +1,12 @@ +// Adapted from: https://github.com/dotnet/runtime/blob/fdd104ec5e1d0d2aa24a6723995a98d0124f724b/src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/RequiredMemberAttribute.cs + +#if !NET7_0_OR_GREATER + +namespace System.Runtime.CompilerServices; + +/// Specifies that a type has required members or that a member is required. +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct | AttributeTargets.Field | AttributeTargets.Property, AllowMultiple = false, Inherited = false)] +internal sealed class RequiredMemberAttribute : Attribute +{ } + +#endif \ No newline at end of file diff --git a/UiPath.FreeRdpClient/UiPath.SessionTools/Polyfills/SetsRequiredMembersAttribute.cs b/UiPath.FreeRdpClient/UiPath.SessionTools/Polyfills/SetsRequiredMembersAttribute.cs new file mode 100644 index 000000000000..5c5a466ded4d --- /dev/null +++ b/UiPath.FreeRdpClient/UiPath.SessionTools/Polyfills/SetsRequiredMembersAttribute.cs @@ -0,0 +1,15 @@ +// Adapted from: https://github.com/dotnet/runtime/blob/1448de846be0db431adaedb2d50d1c660b1a5089/src/libraries/System.Private.CoreLib/src/System/Diagnostics/CodeAnalysis/SetsRequiredMembersAttribute.cs#L16 + +#if !NET7_0_OR_GREATER + +namespace System.Diagnostics.CodeAnalysis; + +/// +/// Specifies that this constructor sets all required members for the current type, and callers +/// do not need to set any required members themselves. +/// +[AttributeUsage(AttributeTargets.Constructor, AllowMultiple = false, Inherited = false)] +internal sealed class SetsRequiredMembersAttribute : Attribute +{ } + +#endif \ No newline at end of file diff --git a/UiPath.FreeRdpClient/UiPath.SessionTools/Polyfills/UnreachableException.cs b/UiPath.FreeRdpClient/UiPath.SessionTools/Polyfills/UnreachableException.cs new file mode 100644 index 000000000000..4b0ee5561464 --- /dev/null +++ b/UiPath.FreeRdpClient/UiPath.SessionTools/Polyfills/UnreachableException.cs @@ -0,0 +1,32 @@ +#if !NET7_0_OR_GREATER + +namespace System.Diagnostics; + +/// +/// Exception thrown when the program executes an instruction that was thought to be unreachable. +/// +public sealed class UnreachableException : Exception +{ + /// + /// Initializes a new instance of the class with the default error message. + /// + public UnreachableException() : base("The program executed an instruction that was thought to be unreachable.") { } + + /// + /// Initializes a new instance of the + /// class with a specified error message. + /// + /// The error message that explains the reason for the exception. + public UnreachableException(string? message) : base(message) { } + + /// + /// Initializes a new instance of the + /// class with a specified error message and a reference to the inner exception that is the cause of + /// this exception. + /// + /// The error message that explains the reason for the exception. + /// The exception that is the cause of the current exception. + public UnreachableException(string? message, Exception? innerException) : base(message, innerException) { } +} + +#endif \ No newline at end of file diff --git a/UiPath.FreeRdpClient/UiPath.SessionTools/Processes/Commands.cs b/UiPath.FreeRdpClient/UiPath.SessionTools/Processes/Commands.cs new file mode 100644 index 000000000000..6a7985eeefd7 --- /dev/null +++ b/UiPath.FreeRdpClient/UiPath.SessionTools/Processes/Commands.cs @@ -0,0 +1,65 @@ +namespace UiPath.SessionTools; + +public static class Commands +{ + /// + /// Ensures a local user account is ready for integration tests. + /// If the user account does not exist, it will be created. + /// Then its password will be set to the given value. + /// The user account will be activated and its expiration will be disabled. + /// The expiration of its password will be disabled and it will be prohibited to change the password. + /// The user account will be added to the "Remote Desktop Users" and "Administrators" local groups in case it wasn't already a member of them. + /// + /// The instance to execute the subcommands on. + /// The account's username. + /// The account's password. + /// The operation's . + /// + public static async Task EnsureUserIsSetUp(this ProcessRunner processRunner, string userName, string password, bool admin = false, CancellationToken ct = default) + { + await processRunner.EnsureUserHasPassword(userName, password, ct); + await processRunner.ActivateUserAndDisableExpiration(userName, ct); + await processRunner.ProhibitPasswordChange(userName, ct); + await processRunner.DisablePasswordExpiration(userName, ct); + + if (admin) + { + await processRunner.EnsureUserIsInGroup(userName, "Administrators", ct); + } + } + + internal static async Task UserExists(this ProcessRunner processRunner, string userName, CancellationToken ct = default) + { + var report = await processRunner.Run("net", $"user \"{userName}\"", ct: ct); + return report.exitCode is 0; + } + internal static Task CreateUser(this ProcessRunner processRunner, string userName, string password, CancellationToken ct = default) + => processRunner.Run("net", $"user \"{userName}\" {password} /add", throwOnNonZero: true, ct: ct); + + internal static Task SetPassword(this ProcessRunner processRunner, string userName, string password, CancellationToken ct = default) + => processRunner.Run("net", $"user \"{userName}\" {password}", throwOnNonZero: true, ct: ct); + + internal static async Task EnsureUserHasPassword(this ProcessRunner processRunner, string userName, string password, CancellationToken ct = default) + { + if (await processRunner.UserExists(userName, ct)) + { + await processRunner.SetPassword(userName, password, ct); + return; + } + + await processRunner.CreateUser(userName, password, ct); + } + internal static Task ActivateUserAndDisableExpiration(this ProcessRunner processRunner, string userName, CancellationToken ct = default) + => processRunner + .Run("net", $"user \"{userName}\" /active /expires:never", throwOnNonZero: true, ct: ct); + + internal static Task ProhibitPasswordChange(this ProcessRunner processRunner, string userName, CancellationToken ct = default) + => processRunner + .Run("net", $"user \"{userName}\" /passwordchg:no", throwOnNonZero: true, ct: ct); + + internal static Task DisablePasswordExpiration(this ProcessRunner processRunner, string userName, CancellationToken ct = default) + => processRunner.Run("wmic", $"useraccount WHERE Name='{userName.Replace("'","\\'")}' set PasswordExpires=false", throwOnNonZero: true, ct: ct); + + internal static Task EnsureUserIsInGroup(this ProcessRunner processRunner, string userName, string groupName, CancellationToken ct = default) + => processRunner.Run("net", $"localgroup \"{groupName}\" \"{userName}\" /add", ct: ct); +} diff --git a/UiPath.FreeRdpClient/UiPath.SessionTools/Processes/Logging.cs b/UiPath.FreeRdpClient/UiPath.SessionTools/Processes/Logging.cs new file mode 100644 index 000000000000..bf55970caa21 --- /dev/null +++ b/UiPath.FreeRdpClient/UiPath.SessionTools/Processes/Logging.cs @@ -0,0 +1,79 @@ +using Microsoft.Extensions.Logging; + +namespace UiPath.SessionTools; + +internal static partial class Logging +{ + [LoggerMessage( + EventId = EventId.RunStarted, + EventName = nameof(EventId.RunStarted), + Level = LogLevel.Information, + Message = "Run started: {fileName} {arguments}")] + internal static partial void LogRunStarted(this ILogger logger, string fileName, string arguments); + + [LoggerMessage( + EventId = EventId.RunSucceeded, + EventName = nameof(EventId.RunSucceeded), + Level = LogLevel.Information, + Message = """ + Run succeeded: {fileName} {arguments} + Exit code: 0 + ~~~~~~~~ StdOut ~~~~~~~~ + {stdout} + ~~~~~~~~ StdErr ~~~~~~~~ + {stderr} + ~~~~~~~~~~~~~~~~~~~~~~~~ + """)] + internal static partial void LogRunSucceeded(this ILogger logger, string fileName, string arguments, string stdout, string stderr); + + [LoggerMessage( + EventId = EventId.RunFailed, + EventName = nameof(EventId.RunFailed), + Level = LogLevel.Error, + Message = """ + Run failed: {fileName} {arguments} + Exit code: {exitCode} + ~~~~~~~~ StdOut ~~~~~~~~ + {stdout} + ~~~~~~~~ StdErr ~~~~~~~~ + {stderr} + ~~~~~~~~~~~~~~~~~~~~~~~~ + """)] + internal static partial void LogRunFailed(this ILogger logger, string fileName, string arguments, int exitCode, string stdout, string stderr); + + [LoggerMessage( + EventId = EventId.RunWaitCanceled, + EventName = nameof(EventId.RunWaitCanceled), + Level = LogLevel.Error, + Message = """ + Wait canceled: {fileName} {arguments} + Exit code: not available + ~~~ StdOut (partial) ~~~ + {stdout} + ~~~ StdErr (partial) ~~~ + {stderr} + ~~~~~~~~~~~~~~~~~~~~~~~~ + """)] + internal static partial void LogRunWaitCanceled(this ILogger logger, string fileName, string arguments, string stdout, string stderr); + + + public static string FormatRunFailed(string fileName, string arguments, string exitCode, string stdout, string stderr) + => $""" + Run failed: {fileName} {arguments} + Exit code: {exitCode} + ~~~~~~~~ StdOut ~~~~~~~~ + {stdout} + ~~~~~~~~ StdErr ~~~~~~~~ + {stderr} + ~~~~~~~~~~~~~~~~~~~~~~~~ + """; + + private static class EventId + { + private const int Base = 10_000; + public const int RunStarted = Base + 0; + public const int RunSucceeded = Base + 1; + public const int RunFailed = Base + 2; + public const int RunWaitCanceled = Base + 3; + } +} diff --git a/UiPath.FreeRdpClient/UiPath.SessionTools/Processes/ProcessRunner.cs b/UiPath.FreeRdpClient/UiPath.SessionTools/Processes/ProcessRunner.cs new file mode 100644 index 000000000000..d154ec2bc0a7 --- /dev/null +++ b/UiPath.FreeRdpClient/UiPath.SessionTools/Processes/ProcessRunner.cs @@ -0,0 +1,90 @@ +using Microsoft.Extensions.Logging; +using System.Diagnostics; +using System.Text; + +namespace UiPath.SessionTools; + +using static Logging; + +public partial class ProcessRunner +{ + private readonly ILogger? _logger; + + public ProcessRunner(ILogger? logger = null) + { + _logger = logger; + } + + public virtual async Task<(string output, int exitCode)> Run(string fileName, string arguments, string workingDirectory = "", bool throwOnNonZero = false, CancellationToken ct = default) + { + var startInfo = new ProcessStartInfo() + { + FileName = fileName, + Arguments = arguments, + WorkingDirectory = workingDirectory, + + CreateNoWindow = true, + UseShellExecute = false, + WindowStyle = ProcessWindowStyle.Hidden, + RedirectStandardOutput = true, + RedirectStandardError = true + }; + + using var process = new Process() { StartInfo = startInfo }; + + var stdout = new StringBuilder(); + var stderr = new StringBuilder(); + + process.OutputDataReceived += (_, e) => stdout.AppendLine(e.Data); + process.ErrorDataReceived += (_, e) => stderr.AppendLine(e.Data); + + _ = process.Start(); + _logger?.LogRunStarted(startInfo.FileName, startInfo.Arguments); + + process.BeginOutputReadLine(); + process.BeginErrorReadLine(); + + try + { + await process.WaitForExitAsync(ct); + } + catch (OperationCanceledException) + { + _logger?.LogRunWaitCanceled(startInfo.FileName, startInfo.Arguments, stdout.ToString(), stderr.ToString()); + + throw; + } + + if (process.ExitCode is 0) + { + _logger?.LogRunSucceeded(startInfo.FileName, startInfo.Arguments, stdout.ToString(), stderr.ToString()); + return (stdout.ToString(), process.ExitCode); + } + + string strStdout = stdout.ToString(); + string strStderr = stderr.ToString(); + _logger?.LogRunFailed(startInfo.FileName, startInfo.Arguments, process.ExitCode, strStdout, strStderr); + + if (throwOnNonZero) + { + throw new NonZeroExitCodeException(process.ExitCode, strStdout, strStderr, + FormatRunFailed(startInfo.FileName, startInfo.Arguments, $"{process.ExitCode}", strStdout, strStderr)); + } + + return (strStdout, process.ExitCode); + } + + public class NonZeroExitCodeException : Exception + { + public int ExitCode { get; } + public string Output { get; } + public string Error { get; } + + internal NonZeroExitCodeException(int exitCode, string output, string error, string message) : base(message) + { + ExitCode = exitCode; + Output = output; + Error = error; + } + } +} diff --git a/UiPath.FreeRdpClient/UiPath.SessionTools/UiPath.SessionTools.csproj b/UiPath.FreeRdpClient/UiPath.SessionTools/UiPath.SessionTools.csproj new file mode 100644 index 000000000000..9e2f1a6b9d8c --- /dev/null +++ b/UiPath.FreeRdpClient/UiPath.SessionTools/UiPath.SessionTools.csproj @@ -0,0 +1,33 @@ + + + + net6.0 + enable + true + + UiPath + Windows Session Tools + true + true + $(NugetsOutDir) + $(AppVersion) + + + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers + + + + + + diff --git a/UiPath.FreeRdpClient/UiPath.SessionTools/WTS/CLIENT_DISPLAY.cs b/UiPath.FreeRdpClient/UiPath.SessionTools/WTS/CLIENT_DISPLAY.cs new file mode 100644 index 000000000000..ec2fc19e06f6 --- /dev/null +++ b/UiPath.FreeRdpClient/UiPath.SessionTools/WTS/CLIENT_DISPLAY.cs @@ -0,0 +1,15 @@ +namespace Windows.Win32.System.RemoteDesktop; + +/// Contains information about the display of a Remote Desktop Connection (RDC) client. +/// +/// Learn more about this API from docs.microsoft.com. +/// +public struct WTS_CLIENT_DISPLAY +{ + /// Horizontal dimension, in pixels, of the client's display. + public uint HorizontalResolution; + /// Vertical dimension, in pixels, of the client's display. + public uint VerticalResolution; + /// + public uint ColorDepth; +} diff --git a/UiPath.FreeRdpClient/UiPath.SessionTools/WTS/WTSINFOEX.cs b/UiPath.FreeRdpClient/UiPath.SessionTools/WTS/WTSINFOEX.cs new file mode 100644 index 000000000000..7c03394eafc1 --- /dev/null +++ b/UiPath.FreeRdpClient/UiPath.SessionTools/WTS/WTSINFOEX.cs @@ -0,0 +1,372 @@ +using System.Diagnostics.CodeAnalysis; +using System.Runtime.InteropServices; +using winmdroot = Windows.Win32; + +namespace Windows.Win32.System.RemoteDesktop; + +public struct WTSINFOEX +{ + public uint Level; + public WTSINFOEX_LEVEL1_W Data; +} + +/// Contains extended information about a Remote Desktop Services session. +/// +/// Learn more about this API from docs.microsoft.com. +/// +[global::System.CodeDom.Compiler.GeneratedCode("Microsoft.Windows.CsWin32", "0.2.63-beta+89e7e0c43f")] +public partial struct WTSINFOEX_LEVEL1_W +{ + /// The session identifier. + public uint SessionId; + /// A value of the WTS_CONNECTSTATE_CLASS enumeration type that specifies the connection state of a Remote Desktop Services session. + public winmdroot.System.RemoteDesktop.WTS_CONNECTSTATE_CLASS SessionState; + /// + public int SessionFlags; + /// A null-terminated string that contains the name of the window station for the session. + public __char_33 WinStationName; + /// A null-terminated string that contains the name of the user who owns the session. + public __char_21 UserName; + /// A null-terminated string that contains the name of the domain that the user belongs to. + public __char_18 DomainName; + /// The time that the user logged on to the session. This value is stored as a large integer that represents the number of 100-nanosecond intervals since January 1, 1601 Coordinated Universal Time (Greenwich Mean Time). + public long LogonTime; + /// The time of the most recent client connection to the session. This value is stored as a large integer that represents the number of 100-nanosecond intervals since January 1, 1601 Coordinated Universal Time. + public long ConnectTime; + /// The time of the most recent client disconnection to the session. This value is stored as a large integer that represents the number of 100-nanosecond intervals since January 1, 1601 Coordinated Universal Time. + public long DisconnectTime; + /// The time of the last user input in the session. This value is stored as a large integer that represents the number of 100-nanosecond intervals since January 1, 1601 Coordinated Universal Time. + public long LastInputTime; + /// The time that this structure was filled. This value is stored as a large integer that represents the number of 100-nanosecond intervals since January 1, 1601 Coordinated Universal Time. + public long CurrentTime; + /// The number of bytes of uncompressed Remote Desktop Protocol (RDP) data sent from the client to the server since the client connected. + public uint IncomingBytes; + /// The number of bytes of uncompressed RDP data sent from the server to the client since the client connected. + public uint OutgoingBytes; + /// The number of frames of RDP data sent from the client to the server since the client connected. + public uint IncomingFrames; + /// The number of frames of RDP data sent from the server to the client since the client connected. + public uint OutgoingFrames; + /// The number of bytes of compressed RDP data sent from the client to the server since the client connected. + public uint IncomingCompressedBytes; + /// The number of bytes of compressed RDP data sent from the server to the client since the client connected. + public uint OutgoingCompressedBytes; + + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + public partial struct __char_33 + { + public char _0, _1, _2, _3, _4, _5, _6, _7, _8, _9, _10, _11, _12, _13, _14, _15, _16, _17, _18, _19, _20, _21, _22, _23, _24, _25, _26, _27, _28, _29, _30, _31, _32; + + /// Always 33. + public readonly int Length => 33; + + /// + /// Gets a ref to an individual element of the inline array. + /// ⚠ Important ⚠: When this struct is on the stack, do not let the returned reference outlive the stack frame that defines it. + /// + [UnscopedRef] + public ref char this[int index] => ref AsSpan()[index]; + + /// + /// Gets this inline array as a span. + /// + /// + /// ⚠ Important ⚠: When this struct is on the stack, do not let the returned span outlive the stack frame that defines it. + /// + [UnscopedRef] + public Span AsSpan() => MemoryMarshal.CreateSpan(ref _0, 33); + + public unsafe readonly void CopyTo(Span target, int length = 33) + { + if (length > 33) throw new ArgumentOutOfRangeException("length"); + fixed (char* p0 = &_0) + { + for (int i = 0; i < length; i++) + { + target[i] = p0[i]; + } + } + } + + public readonly char[] ToArray(int length = 33) + { + if (length > 33) throw new ArgumentOutOfRangeException("length"); + char[] target = new char[length]; + CopyTo(target, length); + return target; + } + + public unsafe readonly bool Equals(ReadOnlySpan value) + { + fixed (char* p0 = &_0) + { + int commonLength = Math.Min(value.Length, 33); + for (int i = 0; i < commonLength; i++) + { + if (p0[i] != value[i]) + { + return false; + } + } + for (int i = commonLength; i < 33; i++) + { + if (p0[i] != default(char)) + { + return false; + } + } + } + return true; + } + + public readonly bool Equals(string value) => Equals(value.AsSpan()); + + /// + /// Copies the fixed array to a new string up to the specified length regardless of whether there are null terminating characters. + /// + /// + /// Thrown when is less than 0 or greater than . + /// + public unsafe readonly string ToString(int length) + { + if (length < 0 || length > Length) throw new ArgumentOutOfRangeException(nameof(length), length, "Length must be between 0 and the fixed array length."); + fixed (char* p0 = &_0) + return new string(p0, 0, length); + } + + /// + /// Copies the fixed array to a new string, stopping before the first null terminator character or at the end of the fixed array (whichever is shorter). + /// + public override readonly unsafe string ToString() + { + int length; + fixed (char* p = &_0) + { + char* pLastExclusive = p + Length; + char* pCh = p; + for (; pCh < pLastExclusive && *pCh != '\0'; pCh++) ; + length = checked((int)(pCh - p)); + } + return ToString(length); + } + public static implicit operator __char_33(string value) => value.AsSpan(); + public static implicit operator __char_33(ReadOnlySpan value) + { + __char_33 result = default(__char_33); + value.CopyTo(result.AsSpan()); + return result; + } + } + + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + public partial struct __char_21 + { + public char _0, _1, _2, _3, _4, _5, _6, _7, _8, _9, _10, _11, _12, _13, _14, _15, _16, _17, _18, _19, _20; + + /// Always 21. + public readonly int Length => 21; + + /// + /// Gets a ref to an individual element of the inline array. + /// ⚠ Important ⚠: When this struct is on the stack, do not let the returned reference outlive the stack frame that defines it. + /// + [UnscopedRef] + public ref char this[int index] => ref AsSpan()[index]; + + /// + /// Gets this inline array as a span. + /// + /// + /// ⚠ Important ⚠: When this struct is on the stack, do not let the returned span outlive the stack frame that defines it. + /// + [UnscopedRef] + public Span AsSpan() => MemoryMarshal.CreateSpan(ref _0, 21); + + public unsafe readonly void CopyTo(Span target, int length = 21) + { + if (length > 21) throw new ArgumentOutOfRangeException("length"); + fixed (char* p0 = &_0) + { + for (int i = 0; i < length; i++) + { + target[i] = p0[i]; + } + } + } + + public readonly char[] ToArray(int length = 21) + { + if (length > 21) throw new ArgumentOutOfRangeException("length"); + char[] target = new char[length]; + CopyTo(target, length); + return target; + } + + public unsafe readonly bool Equals(ReadOnlySpan value) + { + fixed (char* p0 = &_0) + { + int commonLength = Math.Min(value.Length, 21); + for (int i = 0; i < commonLength; i++) + { + if (p0[i] != value[i]) + { + return false; + } + } + for (int i = commonLength; i < 21; i++) + { + if (p0[i] != default(char)) + { + return false; + } + } + } + return true; + } + + public readonly bool Equals(string value) => Equals(value.AsSpan()); + + /// + /// Copies the fixed array to a new string up to the specified length regardless of whether there are null terminating characters. + /// + /// + /// Thrown when is less than 0 or greater than . + /// + public unsafe readonly string ToString(int length) + { + if (length < 0 || length > Length) throw new ArgumentOutOfRangeException(nameof(length), length, "Length must be between 0 and the fixed array length."); + fixed (char* p0 = &_0) + return new string(p0, 0, length); + } + + /// + /// Copies the fixed array to a new string, stopping before the first null terminator character or at the end of the fixed array (whichever is shorter). + /// + public override readonly unsafe string ToString() + { + int length; + fixed (char* p = &_0) + { + char* pLastExclusive = p + Length; + char* pCh = p; + for (; pCh < pLastExclusive && *pCh != '\0'; pCh++) ; + length = checked((int)(pCh - p)); + } + return ToString(length); + } + public static implicit operator __char_21(string value) => value.AsSpan(); + public static implicit operator __char_21(ReadOnlySpan value) + { + __char_21 result = default(__char_21); + value.CopyTo(result.AsSpan()); + return result; + } + } + + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + public partial struct __char_18 + { + public char _0, _1, _2, _3, _4, _5, _6, _7, _8, _9, _10, _11, _12, _13, _14, _15, _16, _17; + + /// Always 18. + public readonly int Length => 18; + + /// + /// Gets a ref to an individual element of the inline array. + /// ⚠ Important ⚠: When this struct is on the stack, do not let the returned reference outlive the stack frame that defines it. + /// + [UnscopedRef] + public ref char this[int index] => ref AsSpan()[index]; + + /// + /// Gets this inline array as a span. + /// + /// + /// ⚠ Important ⚠: When this struct is on the stack, do not let the returned span outlive the stack frame that defines it. + /// + [UnscopedRef] + public Span AsSpan() => MemoryMarshal.CreateSpan(ref _0, 18); + + public unsafe readonly void CopyTo(Span target, int length = 18) + { + if (length > 18) throw new ArgumentOutOfRangeException("length"); + fixed (char* p0 = &_0) + { + for (int i = 0; i < length; i++) + { + target[i] = p0[i]; + } + } + } + + public readonly char[] ToArray(int length = 18) + { + if (length > 18) throw new ArgumentOutOfRangeException("length"); + char[] target = new char[length]; + CopyTo(target, length); + return target; + } + + public unsafe readonly bool Equals(ReadOnlySpan value) + { + fixed (char* p0 = &_0) + { + int commonLength = Math.Min(value.Length, 18); + for (int i = 0; i < commonLength; i++) + { + if (p0[i] != value[i]) + { + return false; + } + } + for (int i = commonLength; i < 18; i++) + { + if (p0[i] != default(char)) + { + return false; + } + } + } + return true; + } + + public readonly bool Equals(string value) => Equals(value.AsSpan()); + + /// + /// Copies the fixed array to a new string up to the specified length regardless of whether there are null terminating characters. + /// + /// + /// Thrown when is less than 0 or greater than . + /// + public unsafe readonly string ToString(int length) + { + if (length < 0 || length > Length) throw new ArgumentOutOfRangeException(nameof(length), length, "Length must be between 0 and the fixed array length."); + fixed (char* p0 = &_0) + return new string(p0, 0, length); + } + + /// + /// Copies the fixed array to a new string, stopping before the first null terminator character or at the end of the fixed array (whichever is shorter). + /// + public override readonly unsafe string ToString() + { + int length; + fixed (char* p = &_0) + { + char* pLastExclusive = p + Length; + char* pCh = p; + for (; pCh < pLastExclusive && *pCh != '\0'; pCh++) ; + length = checked((int)(pCh - p)); + } + return ToString(length); + } + public static implicit operator __char_18(string value) => value.AsSpan(); + public static implicit operator __char_18(ReadOnlySpan value) + { + __char_18 result = default(__char_18); + value.CopyTo(result.AsSpan()); + return result; + } + } +} diff --git a/UiPath.FreeRdpClient/UiPath.SessionTools/WTS/WTS_CONNECTSTATE_CLASS.cs b/UiPath.FreeRdpClient/UiPath.SessionTools/WTS/WTS_CONNECTSTATE_CLASS.cs new file mode 100644 index 000000000000..1a4a9b99e1cf --- /dev/null +++ b/UiPath.FreeRdpClient/UiPath.SessionTools/WTS/WTS_CONNECTSTATE_CLASS.cs @@ -0,0 +1,29 @@ +namespace Windows.Win32.System.RemoteDesktop; + +/// Specifies the connection state of a Remote Desktop Services session. +/// +/// Learn more about this API from docs.microsoft.com. +/// +public enum WTS_CONNECTSTATE_CLASS +{ + /// A user is logged on to the WinStation. This state occurs when a user is signed in and actively connected to the device. + WTSActive = 0, + /// The WinStation is connected to the client. + WTSConnected = 1, + /// The WinStation is in the process of connecting to the client. + WTSConnectQuery = 2, + /// The WinStation is shadowing another WinStation. + WTSShadow = 3, + /// The WinStation is active but the client is disconnected. This state occurs when a user is signed in but not actively connected to the device, such as when the user has chosen to exit to the lock screen. + WTSDisconnected = 4, + /// The WinStation is waiting for a client to connect. + WTSIdle = 5, + /// The WinStation is listening for a connection. A listener session waits for requests for new client connections. No user is logged on a listener session. A listener session cannot be reset, shadowed, or changed to a regular client session. + WTSListen = 6, + /// The WinStation is being reset. + WTSReset = 7, + /// The WinStation is down due to an error. + WTSDown = 8, + /// The WinStation is initializing. + WTSInit = 9, +} diff --git a/UiPath.FreeRdpClient/UiPath.SessionTools/WTS/WTS_INFO_CLASS.cs b/UiPath.FreeRdpClient/UiPath.SessionTools/WTS/WTS_INFO_CLASS.cs new file mode 100644 index 000000000000..c344ca366a8f --- /dev/null +++ b/UiPath.FreeRdpClient/UiPath.SessionTools/WTS/WTS_INFO_CLASS.cs @@ -0,0 +1,117 @@ +namespace Windows.Win32.System.RemoteDesktop; + +/// Contains values that indicate the type of session information to retrieve in a call to the WTSQuerySessionInformation function. +/// +/// Learn more about this API from docs.microsoft.com. +/// +public enum WTS_INFO_CLASS +{ + /// + /// A null-terminated string that contains the name of the initial program that Remote Desktop Services runs when the user logs on. + /// Read more on docs.microsoft.com. + /// + WTSInitialProgram = 0, + /// + /// A null-terminated string that contains the published name of the application that the session is running. Windows Server 2008 R2, Windows 7, Windows Server 2008 and Windows Vista:  This value is not supported + /// Read more on docs.microsoft.com. + /// + WTSApplicationName = 1, + /// A null-terminated string that contains the default directory used when launching the initial program. + WTSWorkingDirectory = 2, + /// This value is not used. + WTSOEMId = 3, + /// A ULONG value that contains the session identifier. + WTSSessionId = 4, + /// A null-terminated string that contains the name of the user associated with the session. + WTSUserName = 5, + /// + /// A null-terminated string that contains the name of the Remote Desktop Services session.
Note  Despite its name, specifying this type does not return the window station name. Rather, it returns the name of the Remote Desktop Services session. Each Remote Desktop Services session is associated with an interactive window station. Because the only supported window station name for an interactive window station is "WinSta0", each session is associated with its own "WinSta0" window station. For more information, see Window Stations.
 
+ /// Read more on docs.microsoft.com. + ///
+ WTSWinStationName = 6, + /// A null-terminated string that contains the name of the domain to which the logged-on user belongs. + WTSDomainName = 7, + /// + /// The session's current connection state. For more information, see WTS_CONNECTSTATE_CLASS. + /// Read more on docs.microsoft.com. + /// + WTSConnectState = 8, + /// A ULONG value that contains the build number of the client. + WTSClientBuildNumber = 9, + /// A null-terminated string that contains the name of the client. + WTSClientName = 10, + /// A null-terminated string that contains the directory in which the client is installed. + WTSClientDirectory = 11, + /// A USHORT client-specific product identifier. + WTSClientProductId = 12, + /// A ULONG value that contains a client-specific hardware identifier. This option is reserved for future use. WTSQuerySessionInformation will always return a value of 0. + WTSClientHardwareId = 13, + /// + /// The network type and network address of the client. For more information, see WTS_CLIENT_ADDRESS. The IP address is offset by two bytes from the start of the Address member of the WTS_CLIENT_ADDRESS structure. + /// Read more on docs.microsoft.com. + /// + WTSClientAddress = 14, + /// + /// Information about the display resolution of the client. For more information, see WTS_CLIENT_DISPLAY. + /// Read more on docs.microsoft.com. + /// + WTSClientDisplay = 15, + /// A USHORT value that specifies information about the protocol type for the + WTSClientProtocolType = 16, + /// + /// This value returns FALSE. If you call GetLastError to get extended error information, GetLastError returns ERROR_NOT_SUPPORTED. Windows Server 2008 and Windows Vista:  This value is not used. + /// Read more on docs.microsoft.com. + /// + WTSIdleTime = 17, + /// + /// This value returns FALSE. If you call GetLastError to get extended error information, GetLastError returns ERROR_NOT_SUPPORTED. Windows Server 2008 and Windows Vista:  This value is not used. + /// Read more on docs.microsoft.com. + /// + WTSLogonTime = 18, + /// + /// This value returns FALSE. If you call GetLastError to get extended error information, GetLastError returns ERROR_NOT_SUPPORTED. Windows Server 2008 and Windows Vista:  This value is not used. + /// Read more on docs.microsoft.com. + /// + WTSIncomingBytes = 19, + /// + /// This value returns FALSE. If you call GetLastError to get extended error information, GetLastError returns ERROR_NOT_SUPPORTED. Windows Server 2008 and Windows Vista:  This value is not used. + /// Read more on docs.microsoft.com. + /// + WTSOutgoingBytes = 20, + /// + /// This value returns FALSE. If you call GetLastError to get extended error information, GetLastError returns ERROR_NOT_SUPPORTED. Windows Server 2008 and Windows Vista:  This value is not used. + /// Read more on docs.microsoft.com. + /// + WTSIncomingFrames = 21, + /// + /// This value returns FALSE. If you call GetLastError to get extended error information, GetLastError returns ERROR_NOT_SUPPORTED. Windows Server 2008 and Windows Vista:  This value is not used. + /// Read more on docs.microsoft.com. + /// + WTSOutgoingFrames = 22, + /// Information about a Remote Desktop Connection (RDC) client. For more information, see WTSCLIENT. + WTSClientInfo = 23, + /// Information about a client session on a RD Session Host server. For more information, see WTSINFO. + WTSSessionInfo = 24, + /// + /// Extended information about a session on a RD Session Host server. For more information, see WTSINFOEX. Windows Server 2008 and Windows Vista:  This value is not supported. + /// Read more on docs.microsoft.com. + /// + WTSSessionInfoEx = 25, + /// + /// A WTSCONFIGINFO structure that contains information about the configuration of a RD Session Host server. Windows Server 2008 and Windows Vista:  This value is not supported. + /// Read more on docs.microsoft.com. + /// + WTSConfigInfo = 26, + /// This value is not supported. + WTSValidationInfo = 27, + /// + /// A WTS_SESSION_ADDRESS structure that contains the IPv4 address assigned to the session. If the session does not have a virtual IP address, the WTSQuerySessionInformation function returns ERROR_NOT_SUPPORTED. Windows Server 2008 and Windows Vista:  This value is not supported. + /// Read more on docs.microsoft.com. + /// + WTSSessionAddressV4 = 28, + /// + /// Determines whether the current session is a remote session. The WTSQuerySessionInformation function returns a value of TRUE to indicate that the current session is a remote session, and FALSE to indicate that the current session is a local session. This value can only be used for the local machine, so the hServer parameter of the WTSQuerySessionInformation function must contain WTS_CURRENT_SERVER_HANDLE. Windows Server 2008 and Windows Vista:  This value is not supported. + /// Read more on docs.microsoft.com. + /// + WTSIsRemoteSession = 29, +} diff --git a/UiPath.FreeRdpClient/UiPath.SessionTools/WTS/Wts.InformationProvider.cs b/UiPath.FreeRdpClient/UiPath.SessionTools/WTS/Wts.InformationProvider.cs new file mode 100644 index 000000000000..878a77fab0d3 --- /dev/null +++ b/UiPath.FreeRdpClient/UiPath.SessionTools/WTS/Wts.InformationProvider.cs @@ -0,0 +1,71 @@ +using Nito.Disposables; +using System.Diagnostics; +using System.Runtime.InteropServices; +using Windows.Win32; +using Windows.Win32.System.RemoteDesktop; + +namespace UiPath.SessionTools; + +partial class Wts +{ + public record InfoProvider(int SessionId) + { + public virtual string? QueryString(WTS_INFO_CLASS infoClass) + { + using var _ = Query(infoClass, out var pInfo); + + if (pInfo == default) + { + return null; + } + + return Marshal.PtrToStringAuto(pInfo); + } + + public virtual unsafe T QueryStructure(WTS_INFO_CLASS infoClass) + where T : unmanaged + { + using var _ = Query(infoClass, out var pInfo); + if (pInfo == default) + { + return default; + } + return *(T*)pInfo.ToPointer(); + } + + private IDisposable? Query(WTS_INFO_CLASS infoClass, out IntPtr pInfo) + { + bool pInvokeResult = PInvoke.WTSQuerySessionInformation( + hServer: default, + SessionId: (uint)SessionId, + WTSInfoClass: infoClass, + ppBuffer: out IntPtr localPtr, + pBytesReturned: out _); + + pInfo = localPtr; + if (pInfo == default) + { + return null; + } + + return new Disposable(() => + { + PInvoke.WTSFreeMemory(localPtr); + }); + } + } +} + +public static class WtsInfoProviderExtensions +{ + public static string? DomainName(this Wts.InfoProvider reader) => reader.QueryString(WTS_INFO_CLASS.WTSDomainName); + + public static string UserName(this Wts.InfoProvider reader) + => reader.QueryString(WTS_INFO_CLASS.WTSUserName) + ?? throw new UnreachableException($"{nameof(PInvoke.WTSQuerySessionInformation)} did not fail but yielded a null buffer for {nameof(WTS_INFO_CLASS)}.{WTS_INFO_CLASS.WTSUserName}."); + + public static string? ClientName(this Wts.InfoProvider reader) => reader.QueryString(WTS_INFO_CLASS.WTSClientName); + public static WTS_CLIENT_DISPLAY ClientDisplay(this Wts.InfoProvider reader) => reader.QueryStructure(WTS_INFO_CLASS.WTSClientDisplay); + public static WTS_CONNECTSTATE_CLASS ConnectState(this Wts.InfoProvider reader) => reader.QueryStructure(WTS_INFO_CLASS.WTSConnectState); + public static WTSINFOEX SessionInfo(this Wts.InfoProvider reader) => reader.QueryStructure(WTS_INFO_CLASS.WTSSessionInfoEx); +} diff --git a/UiPath.FreeRdpClient/UiPath.SessionTools/WTS/Wts.cs b/UiPath.FreeRdpClient/UiPath.SessionTools/WTS/Wts.cs new file mode 100644 index 000000000000..9a1403b2ebce --- /dev/null +++ b/UiPath.FreeRdpClient/UiPath.SessionTools/WTS/Wts.cs @@ -0,0 +1,50 @@ +using System.Runtime.InteropServices; +using Windows.Win32; + +namespace UiPath.SessionTools; + +public partial class Wts +{ + public virtual InfoProvider QuerySessionInformation(int sessionId) + => new(sessionId); + + public virtual bool DisconnectSession(SafeHandle? server, int sessionId, bool wait) + => PInvoke.WTSDisconnectSession(server, (uint)sessionId, wait); + + public virtual bool ConnectSession(int logonId, int targetLogonId, string password, bool wait) + => PInvoke.WTSConnectSession((uint)logonId, (uint)targetLogonId, password, wait); + + public virtual int GetActiveConsoleSessionId() + => (int)PInvoke.WTSGetActiveConsoleSessionId(); + + public virtual bool LogoffSession(SafeHandle? server, int sessionId, bool wait) + => PInvoke.WTSLogoffSession(server, (uint)sessionId, wait); + + public unsafe virtual IReadOnlyList GetSessionIdList() + { + PInvoke.WTSEnumerateSessions( + hServer: null, + Reserved: 0, + Version: 1, + ppSessionInfo: out var sessionInfoArray, + pCount: out uint uCount) + .ThrowOnLastError(api: nameof(PInvoke.WTSEnumerateSessions)); + + try + { + int count = (int)uCount; + var sessionIdArray = new int[count]; + + for (int i = 0; i < count; i++) + { + sessionIdArray[i] = (int)sessionInfoArray[i].SessionId; + } + + return sessionIdArray; + } + finally + { + PInvoke.WTSFreeMemory(sessionInfoArray); + } + } +} \ No newline at end of file diff --git a/UiPath.FreeRdpClient/global.json b/UiPath.FreeRdpClient/global.json new file mode 100644 index 000000000000..226ed8d9a6e6 --- /dev/null +++ b/UiPath.FreeRdpClient/global.json @@ -0,0 +1,5 @@ +{ + "msbuild-sdks": { + "Microsoft.Build.NoTargets": "3.5.6" + } +} diff --git a/UiPath.FreeRdpClient/testEnvironments.json b/UiPath.FreeRdpClient/testEnvironments.json new file mode 100644 index 000000000000..40cb6347ae4e --- /dev/null +++ b/UiPath.FreeRdpClient/testEnvironments.json @@ -0,0 +1,29 @@ +{ + "version": "1", + "environments": [ + { + "name": "winS2022 local", + "type": "ssh", + "remoteUri": "ssh://Administrator@WinS2022Tests:22", + "localRoot": "..\\..\\" + }, + { + "name": "winS2019 local", + "type": "ssh", + "remoteUri": "ssh://User@WinS2019Tests:22", + "localRoot": "..\\..\\" + }, + { + "name": "win11 local", + "type": "ssh", + "remoteUri": "ssh://User@Win11Tests:22", + "localRoot": "..\\..\\" + }, + { + "name": "win10 x86 local", + "type": "ssh", + "remoteUri": "ssh://User@Win10-x86Tests:22", + "localRoot": "..\\..\\" + } + ] +} \ No newline at end of file diff --git a/libfreerdp/codec/xcrush.c b/libfreerdp/codec/xcrush.c index 56bf649f5560..154bbe66391e 100644 --- a/libfreerdp/codec/xcrush.c +++ b/libfreerdp/codec/xcrush.c @@ -29,7 +29,7 @@ #include #include - +#include #define TAG FREERDP_TAG("codec") #pragma pack(push, 1) @@ -739,7 +739,7 @@ static INLINE size_t xcrush_copy_bytes(BYTE* dst, const BYTE* src, size_t num) { memcpy(dst, src, num); } - else + else if (src != dst) { // src and dst overlaps // we should copy the area that doesn't overlap repeatly @@ -754,6 +754,10 @@ static INLINE size_t xcrush_copy_bytes(BYTE* dst, const BYTE* src, size_t num) if (rest != 0) memcpy(&dst[end], &src[end], rest); } + else + { + WLog_WARN(TAG, "xcrush_copy_bytes overlap (src==dst) num = %d, diff = %d", num, src - dst); + } return num; } diff --git a/libfreerdp/core/graphics.c b/libfreerdp/core/graphics.c index b25ac9232af3..a1e637ba5709 100644 --- a/libfreerdp/core/graphics.c +++ b/libfreerdp/core/graphics.c @@ -106,13 +106,24 @@ rdpPointer* Pointer_Alloc(rdpContext* context) } static BOOL Pointer_New(rdpContext* context, rdpPointer* pointer) -{ +{ rdpPointer* proto; if (!context || !context->graphics || !context->graphics->Pointer_Prototype) return FALSE; proto = context->graphics->Pointer_Prototype; + if (pointer->xorMaskData) + { + free(pointer->xorMaskData); + pointer->xorMaskData = NULL; + } + + if (pointer->andMaskData) + { + free(pointer->andMaskData); + pointer->andMaskData = NULL; + } *pointer = *proto; return TRUE; }