From 34c94bdb4d7a25893648e599ea5d8bebc43523fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Thu, 28 May 2026 22:57:35 +0900 Subject: [PATCH] feat(agent): add ironrdp-agent CLI + daemon for agentic / LLM control `ironrdp-agent` is a new headless client built on top of `ironrdp-client`. It's designed to be driven by an LLM or a test harness rather than a human, and is provably free of the viewer's heavy deps (cpal, native cliprdr, ironrdp-mstsgu, reqwest, winit, softbuffer). --- .github/agentic-rdp/Connect-AgentSession.ps1 | 121 ++ .github/agentic-rdp/Enable-LocalRdp.ps1 | 172 +++ .../Invoke-AgenticDesktopScenario.ps1 | 116 ++ .github/agentic-rdp/Invoke-AgenticRdpTest.ps1 | 223 ++++ .../agentic-rdp/Invoke-InteractiveCommand.ps1 | 52 + .github/agentic-rdp/Start-AgentDaemon.ps1 | 60 + .../Start-InteractivePSHostServer.ps1 | 150 +++ .github/workflows/agentic-rdp.yml | 62 + Cargo.lock | 18 + crates/ironrdp-agent/Cargo.toml | 48 + crates/ironrdp-agent/src/cli.rs | 401 +++++++ crates/ironrdp-agent/src/descriptions.rs | 40 + crates/ironrdp-agent/src/help.rs | 74 ++ crates/ironrdp-agent/src/ipc.rs | 1042 +++++++++++++++++ crates/ironrdp-agent/src/lib.rs | 9 + crates/ironrdp-agent/src/main.rs | 977 ++++++++++++++++ crates/ironrdp-agent/src/redact.rs | 113 ++ crates/ironrdp-agent/tests/ipc.rs | 191 +++ 18 files changed, 3869 insertions(+) create mode 100644 .github/agentic-rdp/Connect-AgentSession.ps1 create mode 100644 .github/agentic-rdp/Enable-LocalRdp.ps1 create mode 100644 .github/agentic-rdp/Invoke-AgenticDesktopScenario.ps1 create mode 100644 .github/agentic-rdp/Invoke-AgenticRdpTest.ps1 create mode 100644 .github/agentic-rdp/Invoke-InteractiveCommand.ps1 create mode 100644 .github/agentic-rdp/Start-AgentDaemon.ps1 create mode 100644 .github/agentic-rdp/Start-InteractivePSHostServer.ps1 create mode 100644 .github/workflows/agentic-rdp.yml create mode 100644 crates/ironrdp-agent/Cargo.toml create mode 100644 crates/ironrdp-agent/src/cli.rs create mode 100644 crates/ironrdp-agent/src/descriptions.rs create mode 100644 crates/ironrdp-agent/src/help.rs create mode 100644 crates/ironrdp-agent/src/ipc.rs create mode 100644 crates/ironrdp-agent/src/lib.rs create mode 100644 crates/ironrdp-agent/src/main.rs create mode 100644 crates/ironrdp-agent/src/redact.rs create mode 100644 crates/ironrdp-agent/tests/ipc.rs diff --git a/.github/agentic-rdp/Connect-AgentSession.ps1 b/.github/agentic-rdp/Connect-AgentSession.ps1 new file mode 100644 index 000000000..e3a67cab2 --- /dev/null +++ b/.github/agentic-rdp/Connect-AgentSession.ps1 @@ -0,0 +1,121 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory)] + [string] $UserName, + + [Parameter(Mandatory)] + [string] $Password, + + [string] $HostName = '127.0.0.1', + + [int] $Port = 3389, + + [string] $DesktopSize = '1920x1080', + + [string] $AgentPath = (Join-Path $env:GITHUB_WORKSPACE 'target\release\ironrdp-agent.exe'), + + [string] $Endpoint = "pipe:ironrdp-agent-ci-$PID", + + [string] $ArtifactsDir = (Join-Path $env:GITHUB_WORKSPACE 'artifacts\agentic-rdp'), + + [string] $StatePath = (Join-Path $ArtifactsDir 'agent-session.json') +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +function Invoke-Agent { + param( + [Parameter(ValueFromRemainingArguments)] + [string[]] $Arguments + ) + + & $AgentPath --endpoint $Endpoint --no-spawn-daemon @Arguments +} + +function Get-SessionStatus { + param( + [Parameter(Mandatory)] + [string] $SessionId + ) + + $statusJson = Invoke-Agent status --session $SessionId + $statusJson | Set-Content -Path (Join-Path $ArtifactsDir 'agent-session-status.json') -Encoding utf8NoBOM + return ($statusJson | ConvertFrom-Json) +} + +function Assert-DesktopSize { + param( + [Parameter(Mandatory)] + [object] $Status, + + [Parameter(Mandatory)] + [int] $ExpectedWidth, + + [Parameter(Mandatory)] + [int] $ExpectedHeight + ) + + if ([int] $Status.width -ne $ExpectedWidth -or [int] $Status.height -ne $ExpectedHeight) { + throw "RDP framebuffer is $($Status.width)x$($Status.height), expected ${ExpectedWidth}x${ExpectedHeight}" + } +} + +New-Item -Path $ArtifactsDir -ItemType Directory -Force | Out-Null + +if ($DesktopSize -notmatch '^(?[1-9][0-9]*)[xX](?[1-9][0-9]*)$') { + throw "DesktopSize must use WxH format, got '$DesktopSize'" +} + +$expectedWidth = [int] $Matches.width +$expectedHeight = [int] $Matches.height +$passwordEnvironmentVariable = 'IRONRDP_AGENT_LOCAL_RDP_PASSWORD' +$env:IRONRDP_AGENT_LOCAL_RDP_PASSWORD = $Password + +try { + $destination = "${HostName}:$Port" + $connectJson = Invoke-Agent connect $destination ` + --username $UserName ` + --password-env $passwordEnvironmentVariable ` + --desktop-size $DesktopSize ` + --no-credssp ` + --autologon ` + --compression-enabled false ` + --color-depth 16 ` + --no-server-pointer + $connectJson | Set-Content -Path (Join-Path $ArtifactsDir 'agent-connect.json') -Encoding utf8NoBOM + + $connect = $connectJson | ConvertFrom-Json + $sessionId = [string] $connect.session_id + + Invoke-Agent wait-frame --session $sessionId --timeout-ms 120000 | Out-Null + $status = Get-SessionStatus -SessionId $sessionId + + if ([int] $status.width -ne $expectedWidth -or [int] $status.height -ne $expectedHeight) { + $beforeResizeFrame = [uint64] $status.frame_sequence + Invoke-Agent resize --session $sessionId --width $expectedWidth --height $expectedHeight --scale 100 | Out-Null + Invoke-Agent wait-frame --session $sessionId --timeout-ms 60000 --after-frame $beforeResizeFrame | Out-Null + $status = Get-SessionStatus -SessionId $sessionId + } + + Assert-DesktopSize -Status $status -ExpectedWidth $expectedWidth -ExpectedHeight $expectedHeight + + $state = [pscustomobject]@{ + SessionId = $sessionId + Endpoint = $Endpoint + HostName = $HostName + Port = $Port + UserName = $UserName + RequestedDesktopSize = $DesktopSize + Width = $status.width + Height = $status.height + FrameSequence = $status.frame_sequence + StatePath = $StatePath + } + + $state | ConvertTo-Json -Depth 6 | Set-Content -Path $StatePath -Encoding utf8NoBOM + $state | ConvertTo-Json -Compress +} +finally { + Remove-Item Env:\IRONRDP_AGENT_LOCAL_RDP_PASSWORD -ErrorAction SilentlyContinue +} diff --git a/.github/agentic-rdp/Enable-LocalRdp.ps1 b/.github/agentic-rdp/Enable-LocalRdp.ps1 new file mode 100644 index 000000000..50f6f3d1a --- /dev/null +++ b/.github/agentic-rdp/Enable-LocalRdp.ps1 @@ -0,0 +1,172 @@ +[CmdletBinding(DefaultParameterSetName = 'Enable')] +param( + [Parameter(ParameterSetName = 'Enable')] + [Parameter(ParameterSetName = 'Cleanup')] + [string] $StatePath = (Join-Path $env:RUNNER_TEMP 'ironrdp-agentic-rdp-state.json'), + + [Parameter(ParameterSetName = 'Cleanup')] + [switch] $Cleanup +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$terminalServerPath = 'HKLM:\System\CurrentControlSet\Control\Terminal Server' +$rdpTcpPath = Join-Path $terminalServerPath 'WinStations\RDP-Tcp' +$rdpGroupName = 'Remote Desktop Users' + +function Get-RegistryValue { + param( + [Parameter(Mandatory)] + [string] $Path, + + [Parameter(Mandatory)] + [string] $Name + ) + + $property = Get-ItemProperty -Path $Path -Name $Name -ErrorAction SilentlyContinue + if ($null -eq $property) { + return $null + } + + return $property.$Name +} + +function Write-JsonFile { + param( + [Parameter(Mandatory)] + [string] $Path, + + [Parameter(Mandatory)] + [object] $Value + ) + + $directory = Split-Path -Path $Path -Parent + New-Item -Path $directory -ItemType Directory -Force | Out-Null + $Value | ConvertTo-Json -Depth 8 | Set-Content -Path $Path -Encoding utf8NoBOM +} + +function Test-TcpPort { + param( + [Parameter(Mandatory)] + [string] $HostName, + + [Parameter(Mandatory)] + [int] $Port + ) + + $client = [System.Net.Sockets.TcpClient]::new() + try { + $connect = $client.BeginConnect($HostName, $Port, $null, $null) + if (-not $connect.AsyncWaitHandle.WaitOne([TimeSpan]::FromSeconds(1))) { + return $false + } + + $client.EndConnect($connect) + return $true + } + catch { + return $false + } + finally { + $client.Dispose() + } +} + +function Wait-TcpPort { + param( + [Parameter(Mandatory)] + [string] $HostName, + + [Parameter(Mandatory)] + [int] $Port, + + [int] $TimeoutSeconds = 30 + ) + + $deadline = (Get-Date).AddSeconds($TimeoutSeconds) + do { + if (Test-TcpPort -HostName $HostName -Port $Port) { + return + } + + Start-Sleep -Seconds 1 + } while ((Get-Date) -lt $deadline) + + throw "Timed out waiting for $HostName`:$Port to accept TCP connections" +} + +if ($Cleanup) { + if (-not (Test-Path $StatePath)) { + return + } + + $state = Get-Content -Path $StatePath -Raw | ConvertFrom-Json + + if ($null -ne $state.fDenyTSConnections) { + Set-ItemProperty -Path $terminalServerPath -Name 'fDenyTSConnections' -Value ([int] $state.fDenyTSConnections) + } + + if ($null -ne $state.UserAuthentication) { + Set-ItemProperty -Path $rdpTcpPath -Name 'UserAuthentication' -Value ([int] $state.UserAuthentication) + } + + foreach ($rule in @($state.FirewallRules)) { + if ($null -ne $rule.Name -and $null -ne $rule.Enabled) { + Set-NetFirewallRule -Name $rule.Name -Enabled $rule.Enabled -ErrorAction SilentlyContinue + } + } + + if ($state.AddedToRemoteDesktopUsers) { + Remove-LocalGroupMember -Group $rdpGroupName -Member $state.LocalUserName -ErrorAction SilentlyContinue + } + + Remove-Item -Path $StatePath -Force -ErrorAction SilentlyContinue + return +} + +$localUserName = $env:USERNAME +if ([string]::IsNullOrWhiteSpace($localUserName)) { + throw 'USERNAME is not set; cannot configure a local RDP user' +} + +$passwordBytes = [System.Security.Cryptography.RandomNumberGenerator]::GetBytes(24) +$temporaryPassword = 'RdpAgent!' + [Convert]::ToBase64String($passwordBytes) + 'aA1!' +Write-Host "::add-mask::$temporaryPassword" + +$currentMembers = @(Get-LocalGroupMember -Group $rdpGroupName -ErrorAction SilentlyContinue | ForEach-Object { $_.Name }) +$memberNames = @($localUserName, "$env:COMPUTERNAME\$localUserName") +$wasRdpMember = [bool]($currentMembers | Where-Object { $memberNames -contains $_ } | Select-Object -First 1) + +$state = [pscustomobject]@{ + LocalUserName = $localUserName + DomainUserName = "$env:COMPUTERNAME\$localUserName" + fDenyTSConnections = Get-RegistryValue -Path $terminalServerPath -Name 'fDenyTSConnections' + UserAuthentication = Get-RegistryValue -Path $rdpTcpPath -Name 'UserAuthentication' + FirewallRules = @(Get-NetFirewallRule -DisplayGroup 'Remote Desktop' -ErrorAction SilentlyContinue | Select-Object -Property Name, Enabled) + AddedToRemoteDesktopUsers = (-not $wasRdpMember) +} +Write-JsonFile -Path $StatePath -Value $state + +$securePassword = ConvertTo-SecureString -String $temporaryPassword -AsPlainText -Force +Set-LocalUser -Name $localUserName -Password $securePassword + +if (-not $wasRdpMember) { + Add-LocalGroupMember -Group $rdpGroupName -Member $localUserName +} + +Set-ItemProperty -Path $terminalServerPath -Name 'fDenyTSConnections' -Value 0 +Set-ItemProperty -Path $rdpTcpPath -Name 'UserAuthentication' -Value 0 +Set-Service -Name TermService -StartupType Automatic +Start-Service -Name TermService +Enable-NetFirewallRule -DisplayGroup 'Remote Desktop' | Out-Null +Wait-TcpPort -HostName '127.0.0.1' -Port 3389 + +[pscustomobject]@{ + UserName = $localUserName + DomainUserName = "$env:COMPUTERNAME\$localUserName" + Password = $temporaryPassword + HostName = '127.0.0.1' + Port = 3389 + StatePath = $StatePath +} | ConvertTo-Json -Compress diff --git a/.github/agentic-rdp/Invoke-AgenticDesktopScenario.ps1 b/.github/agentic-rdp/Invoke-AgenticDesktopScenario.ps1 new file mode 100644 index 000000000..74016c8a0 --- /dev/null +++ b/.github/agentic-rdp/Invoke-AgenticDesktopScenario.ps1 @@ -0,0 +1,116 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory)] + [string] $SessionId, + + [string] $AgentPath = (Join-Path $env:GITHUB_WORKSPACE 'target\release\ironrdp-agent.exe'), + + [string] $Endpoint = "pipe:ironrdp-agent-ci-$PID", + + [string] $ArtifactsDir = (Join-Path $env:GITHUB_WORKSPACE 'artifacts\agentic-rdp'), + + [string] $DesktopSize = '1920x1080' +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +function Invoke-Agent { + param( + [Parameter(ValueFromRemainingArguments)] + [string[]] $Arguments + ) + + & $AgentPath --endpoint $Endpoint --no-spawn-daemon @Arguments +} + +function Get-AgentStatus { + $statusJson = Invoke-Agent status --session $SessionId + return ($statusJson | ConvertFrom-Json) +} + +function Test-PngScreenshot { + param( + [Parameter(Mandatory)] + [string] $Path, + + [Parameter(Mandatory)] + [int] $ExpectedWidth, + + [Parameter(Mandatory)] + [int] $ExpectedHeight + ) + + Add-Type -AssemblyName System.Drawing + $bitmap = [System.Drawing.Bitmap]::new($Path) + try { + if ($bitmap.Width -ne $ExpectedWidth -or $bitmap.Height -ne $ExpectedHeight) { + throw "Screenshot is $($bitmap.Width)x$($bitmap.Height), expected ${ExpectedWidth}x${ExpectedHeight}" + } + + $firstPixel = $bitmap.GetPixel(0, 0).ToArgb() + $hasDifferentPixel = $false + $stepX = [Math]::Max(1, [Math]::Floor($bitmap.Width / 64)) + $stepY = [Math]::Max(1, [Math]::Floor($bitmap.Height / 64)) + + for ($y = 0; $y -lt $bitmap.Height; $y += $stepY) { + for ($x = 0; $x -lt $bitmap.Width; $x += $stepX) { + if ($bitmap.GetPixel($x, $y).ToArgb() -ne $firstPixel) { + $hasDifferentPixel = $true + break + } + } + + if ($hasDifferentPixel) { + break + } + } + + if (-not $hasDifferentPixel) { + throw 'Screenshot appears uniform; the framebuffer is likely blank' + } + } + finally { + $bitmap.Dispose() + } +} + +New-Item -Path $ArtifactsDir -ItemType Directory -Force | Out-Null + +if ($DesktopSize -notmatch '^(?[1-9][0-9]*)[xX](?[1-9][0-9]*)$') { + throw "DesktopSize must use WxH format, got '$DesktopSize'" +} + +$expectedWidth = [int] $Matches.width +$expectedHeight = [int] $Matches.height + +$initialStatus = Get-AgentStatus +$initialFrameSequence = [uint64] $initialStatus.frame_sequence + +Invoke-Agent mouse --session $SessionId move --x 200 --y 200 | Out-Null +Invoke-Agent mouse --session $SessionId click --button left | Out-Null +Invoke-Agent keyboard --session $SessionId shortcut --scancodes '0xE05B,0x13' | Out-Null +Start-Sleep -Seconds 1 +Invoke-Agent keyboard --session $SessionId text --text 'msedge.exe about:blank' | Out-Null +Invoke-Agent keyboard --session $SessionId key --scancode 0x1c | Out-Null +Invoke-Agent keyboard --session $SessionId key --scancode 0x1c --release | Out-Null + +Start-Sleep -Seconds 8 +Invoke-Agent wait-frame --session $SessionId --timeout-ms 60000 --after-frame $initialFrameSequence | Out-Null + +$screenshotPath = Join-Path $ArtifactsDir 'agent-desktop.png' +Invoke-Agent screenshot --session $SessionId --output $screenshotPath | Out-Null +Test-PngScreenshot -Path $screenshotPath -ExpectedWidth $expectedWidth -ExpectedHeight $expectedHeight + +$finalStatus = Get-AgentStatus +$result = [pscustomobject]@{ + SessionId = $SessionId + InitialFrameSequence = $initialFrameSequence + FinalFrameSequence = $finalStatus.frame_sequence + Width = $finalStatus.width + Height = $finalStatus.height + ScreenshotPath = $screenshotPath +} + +$result | ConvertTo-Json -Depth 6 | Set-Content -Path (Join-Path $ArtifactsDir 'agentic-desktop-scenario.json') -Encoding utf8NoBOM +$result | ConvertTo-Json -Compress diff --git a/.github/agentic-rdp/Invoke-AgenticRdpTest.ps1 b/.github/agentic-rdp/Invoke-AgenticRdpTest.ps1 new file mode 100644 index 000000000..d0dbd53a1 --- /dev/null +++ b/.github/agentic-rdp/Invoke-AgenticRdpTest.ps1 @@ -0,0 +1,223 @@ +[CmdletBinding()] +param( + [ValidateSet('rdp', 'daemon', 'connect', 'remoting-server', 'remote-command', 'desktop-scenario')] + [string] $MaxStage = 'desktop-scenario', + + [string] $DesktopSize = '1920x1080', + + [string] $AgentPath = (Join-Path $env:GITHUB_WORKSPACE 'target\release\ironrdp-agent.exe'), + + [string] $ArtifactsDir = (Join-Path $env:GITHUB_WORKSPACE 'artifacts\agentic-rdp'), + + [switch] $CleanupOnly +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$stageOrder = @('rdp', 'daemon', 'connect', 'remoting-server', 'remote-command', 'desktop-scenario') +$maxStageIndex = [array]::IndexOf($stageOrder, $MaxStage) +$scriptsRoot = $PSScriptRoot +$endpoint = "pipe:ironrdp-agent-ci-$PID" +$rdpStatePath = Join-Path $ArtifactsDir 'rdp-state.json' +$daemonStatePath = Join-Path $ArtifactsDir 'agent-daemon.json' +$sessionStatePath = Join-Path $ArtifactsDir 'agent-session.json' +$psHostEndpointPath = Join-Path $ArtifactsDir 'pshost-endpoint.json' +$taskName = 'IronRdpAgenticPSHost' +$psHostPort = 45985 + +function Test-ShouldRunStage { + param( + [Parameter(Mandatory)] + [string] $Stage + ) + + return ([array]::IndexOf($stageOrder, $Stage) -le $maxStageIndex) +} + +function Stop-ProcessFromState { + param( + [Parameter(Mandatory)] + [string] $Path + ) + + if (-not (Test-Path $Path)) { + return + } + + $state = Get-Content -Path $Path -Raw | ConvertFrom-Json + if ($null -eq $state.ProcessId) { + return + } + + $process = Get-Process -Id ([int] $state.ProcessId) -ErrorAction SilentlyContinue + if ($null -ne $process) { + Stop-Process -Id $process.Id -Force + } +} + +function Invoke-Agent { + param( + [Parameter(ValueFromRemainingArguments)] + [string[]] $Arguments + ) + + & $AgentPath --endpoint $endpoint --no-spawn-daemon @Arguments +} + +function Invoke-Cleanup { + Unregister-ScheduledTask -TaskName $taskName -Confirm:$false -ErrorAction SilentlyContinue + + if (Test-Path $sessionStatePath) { + $sessionState = Get-Content -Path $sessionStatePath -Raw | ConvertFrom-Json + if ($null -ne $sessionState.SessionId) { + try { + Invoke-Agent disconnect --session $sessionState.SessionId | Out-Null + } + catch { + Write-Warning "Could not disconnect agent session $($sessionState.SessionId): $($_.Exception.Message)" + } + } + } + + try { + Stop-ProcessFromState -Path $daemonStatePath + } + catch { + Write-Warning "Could not stop agent daemon: $($_.Exception.Message)" + } + + if (Test-Path $psHostEndpointPath) { + $endpointInfo = Get-Content -Path $psHostEndpointPath -Raw | ConvertFrom-Json + if ($endpointInfo.PSObject.Properties.Name -contains 'ProcessId') { + $process = Get-Process -Id ([int] $endpointInfo.ProcessId) -ErrorAction SilentlyContinue + if ($null -ne $process) { + Stop-Process -Id $process.Id -Force + } + } + } + + try { + & (Join-Path $scriptsRoot 'Enable-LocalRdp.ps1') -Cleanup -StatePath $rdpStatePath + } + catch { + Write-Warning "Could not restore local RDP settings: $($_.Exception.Message)" + } +} + +if ($CleanupOnly) { + Invoke-Cleanup + return +} + +New-Item -Path $ArtifactsDir -ItemType Directory -Force | Out-Null +$rdpInfo = $null +$sessionInfo = $null + +try { + if (Test-ShouldRunStage -Stage 'rdp') { + Write-Host '::group::Enable local RDP' + $rdpInfoJson = & (Join-Path $scriptsRoot 'Enable-LocalRdp.ps1') -StatePath $rdpStatePath + $rdpInfo = $rdpInfoJson | ConvertFrom-Json + Write-Host '::endgroup::' + } + + if (Test-ShouldRunStage -Stage 'daemon') { + Write-Host '::group::Start ironrdp-agent daemon' + & (Join-Path $scriptsRoot 'Start-AgentDaemon.ps1') ` + -AgentPath $AgentPath ` + -Endpoint $endpoint ` + -ArtifactsDir $ArtifactsDir ` + -StatePath $daemonStatePath | Write-Host + Write-Host '::endgroup::' + } + + if (Test-ShouldRunStage -Stage 'remoting-server') { + Write-Host '::group::Register interactive PSHostServer' + & (Join-Path $scriptsRoot 'Start-InteractivePSHostServer.ps1') ` + -Register ` + -EndpointPath $psHostEndpointPath ` + -Port $psHostPort ` + -ArtifactsDir $ArtifactsDir ` + -TaskName $taskName | Write-Host + Write-Host '::endgroup::' + } + + if (Test-ShouldRunStage -Stage 'connect') { + if ($null -eq $rdpInfo) { + throw 'RDP stage did not produce connection information' + } + + Write-Host '::group::Connect ironrdp-agent session' + $sessionJson = & (Join-Path $scriptsRoot 'Connect-AgentSession.ps1') ` + -AgentPath $AgentPath ` + -Endpoint $endpoint ` + -UserName $rdpInfo.DomainUserName ` + -Password $rdpInfo.Password ` + -HostName $rdpInfo.HostName ` + -Port ([int] $rdpInfo.Port) ` + -DesktopSize $DesktopSize ` + -ArtifactsDir $ArtifactsDir ` + -StatePath $sessionStatePath + $sessionInfo = $sessionJson | ConvertFrom-Json + Write-Host $sessionJson + Write-Host '::endgroup::' + } + + if (Test-ShouldRunStage -Stage 'remoting-server') { + Write-Host '::group::Start interactive PSHostServer task' + Start-ScheduledTask -TaskName $taskName + Start-Sleep -Seconds 5 + Get-ScheduledTask -TaskName $taskName | Get-ScheduledTaskInfo | Format-List | Out-String | Write-Host + Write-Host '::endgroup::' + + Write-Host '::group::Wait for interactive PSHostServer' + & (Join-Path $scriptsRoot 'Start-InteractivePSHostServer.ps1') ` + -Wait ` + -EndpointPath $psHostEndpointPath ` + -TimeoutSeconds 180 | Write-Host + Write-Host '::endgroup::' + } + + if (Test-ShouldRunStage -Stage 'remote-command') { + Write-Host '::group::Verify interactive remoting' + & (Join-Path $scriptsRoot 'Invoke-InteractiveCommand.ps1') ` + -Mode Verify ` + -EndpointPath $psHostEndpointPath ` + -ArtifactsDir $ArtifactsDir | Write-Host + Write-Host '::endgroup::' + } + + if (Test-ShouldRunStage -Stage 'desktop-scenario') { + if ($null -eq $sessionInfo) { + throw 'Connect stage did not produce session information' + } + + Write-Host '::group::Run agentic desktop scenario' + & (Join-Path $scriptsRoot 'Invoke-AgenticDesktopScenario.ps1') ` + -AgentPath $AgentPath ` + -Endpoint $endpoint ` + -SessionId $sessionInfo.SessionId ` + -ArtifactsDir $ArtifactsDir ` + -DesktopSize $DesktopSize | Write-Host + Write-Host '::endgroup::' + } +} +catch { + Write-Host '::error::Agentic RDP test failed' + if ($null -ne $sessionInfo -and $null -ne $sessionInfo.SessionId) { + try { + $failureScreenshotPath = Join-Path $ArtifactsDir 'agent-failure.png' + Invoke-Agent screenshot --session $sessionInfo.SessionId --output $failureScreenshotPath | Out-Null + Write-Warning "Captured failure screenshot at $failureScreenshotPath" + } + catch { + Write-Warning "Could not capture failure screenshot: $($_.Exception.Message)" + } + } + + throw +} +finally { + Invoke-Cleanup +} diff --git a/.github/agentic-rdp/Invoke-InteractiveCommand.ps1 b/.github/agentic-rdp/Invoke-InteractiveCommand.ps1 new file mode 100644 index 000000000..7a0204362 --- /dev/null +++ b/.github/agentic-rdp/Invoke-InteractiveCommand.ps1 @@ -0,0 +1,52 @@ +[CmdletBinding()] +param( + [ValidateSet('Verify')] + [string] $Mode = 'Verify', + + [string] $EndpointPath = (Join-Path $env:GITHUB_WORKSPACE 'artifacts\agentic-rdp\pshost-endpoint.json'), + + [string] $ArtifactsDir = (Join-Path $env:GITHUB_WORKSPACE 'artifacts\agentic-rdp') +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +if (-not (Test-Path $EndpointPath)) { + throw "Endpoint marker was not found: $EndpointPath" +} + +New-Item -Path $ArtifactsDir -ItemType Directory -Force | Out-Null +Import-Module AwakeCoding.PSRemoting -ErrorAction Stop + +$endpoint = Get-Content -Path $EndpointPath -Raw | ConvertFrom-Json +$session = New-PSHostSession -HostName $endpoint.HostName -Port ([int] $endpoint.Port) + +try { + $result = Invoke-Command -Session $session -ScriptBlock { + $process = Get-Process -Id $PID + $notepad = Start-Process -FilePath (Join-Path $env:WINDIR 'System32\notepad.exe') -PassThru + Start-Sleep -Seconds 3 + $notepadProcess = Get-Process -Id $notepad.Id -ErrorAction Stop + Stop-Process -Id $notepad.Id -Force + + [pscustomobject]@{ + ProcessId = $PID + SessionId = $process.SessionId + UserName = [System.Security.Principal.WindowsIdentity]::GetCurrent().Name + UserInteractive = [Environment]::UserInteractive + Desktop = $env:USERPROFILE + GuiProbeProcessId = $notepadProcess.Id + GuiProbeSessionId = $notepadProcess.SessionId + } + } + + if (-not $result.UserInteractive) { + throw 'Remote command did not report an interactive user session' + } + + $result | ConvertTo-Json -Depth 6 | Set-Content -Path (Join-Path $ArtifactsDir 'remote-command-verification.json') -Encoding utf8NoBOM + $result | ConvertTo-Json -Compress +} +finally { + Remove-PSSession -Session $session -ErrorAction SilentlyContinue +} diff --git a/.github/agentic-rdp/Start-AgentDaemon.ps1 b/.github/agentic-rdp/Start-AgentDaemon.ps1 new file mode 100644 index 000000000..2f7153640 --- /dev/null +++ b/.github/agentic-rdp/Start-AgentDaemon.ps1 @@ -0,0 +1,60 @@ +[CmdletBinding()] +param( + [string] $AgentPath = (Join-Path $env:GITHUB_WORKSPACE 'target\release\ironrdp-agent.exe'), + + [string] $Endpoint = "pipe:ironrdp-agent-ci-$PID", + + [string] $ArtifactsDir = (Join-Path $env:GITHUB_WORKSPACE 'artifacts\agentic-rdp'), + + [string] $StatePath = (Join-Path $ArtifactsDir 'agent-daemon.json'), + + [string] $LogLevel = 'debug' +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +function Invoke-Agent { + param( + [Parameter(ValueFromRemainingArguments)] + [string[]] $Arguments + ) + + & $AgentPath --endpoint $Endpoint --no-spawn-daemon @Arguments +} + +New-Item -Path $ArtifactsDir -ItemType Directory -Force | Out-Null + +$logPath = Join-Path $ArtifactsDir 'ironrdp-agent.log' +$stdoutPath = Join-Path $ArtifactsDir 'ironrdp-agent.stdout.log' +$stderrPath = Join-Path $ArtifactsDir 'ironrdp-agent.stderr.log' + +$process = Start-Process ` + -FilePath $AgentPath ` + -ArgumentList @('--endpoint', $Endpoint, '--log-level', $LogLevel, '--log-file', $logPath, '--no-spawn-daemon', 'daemon') ` + -RedirectStandardOutput $stdoutPath ` + -RedirectStandardError $stderrPath ` + -PassThru + +$deadline = (Get-Date).AddSeconds(30) +do { + try { + Invoke-Agent status | Out-Null + $state = [pscustomobject]@{ + ProcessId = $process.Id + Endpoint = $Endpoint + AgentPath = $AgentPath + LogPath = $logPath + StandardOutputPath = $stdoutPath + StandardErrorPath = $stderrPath + } + $state | ConvertTo-Json -Depth 4 | Set-Content -Path $StatePath -Encoding utf8NoBOM + $state | ConvertTo-Json -Compress + return + } + catch { + Start-Sleep -Milliseconds 250 + } +} while ((Get-Date) -lt $deadline) + +throw "Timed out waiting for ironrdp-agent daemon on $Endpoint" diff --git a/.github/agentic-rdp/Start-InteractivePSHostServer.ps1 b/.github/agentic-rdp/Start-InteractivePSHostServer.ps1 new file mode 100644 index 000000000..2601d20e7 --- /dev/null +++ b/.github/agentic-rdp/Start-InteractivePSHostServer.ps1 @@ -0,0 +1,150 @@ +[CmdletBinding(DefaultParameterSetName = 'Register')] +param( + [Parameter(ParameterSetName = 'Register')] + [switch] $Register, + + [Parameter(ParameterSetName = 'RunServer')] + [switch] $RunServer, + + [Parameter(ParameterSetName = 'Wait')] + [switch] $Wait, + + [Parameter(ParameterSetName = 'Register')] + [Parameter(ParameterSetName = 'RunServer')] + [Parameter(ParameterSetName = 'Wait')] + [string] $EndpointPath = (Join-Path $env:GITHUB_WORKSPACE 'artifacts\agentic-rdp\pshost-endpoint.json'), + + [Parameter(ParameterSetName = 'Register')] + [Parameter(ParameterSetName = 'RunServer')] + [int] $Port = 45985, + + [Parameter(ParameterSetName = 'Register')] + [Parameter(ParameterSetName = 'RunServer')] + [string] $ArtifactsDir = (Join-Path $env:GITHUB_WORKSPACE 'artifacts\agentic-rdp'), + + [Parameter(ParameterSetName = 'Register')] + [string] $TaskName = 'IronRdpAgenticPSHost', + + [Parameter(ParameterSetName = 'Wait')] + [int] $TimeoutSeconds = 120 +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +function Ensure-AwakeCodingModule { + if (Get-Module -ListAvailable -Name AwakeCoding.PSRemoting) { + return + } + + $repository = Get-PSRepository -Name PSGallery -ErrorAction SilentlyContinue + if ($null -ne $repository -and $repository.InstallationPolicy -ne 'Trusted') { + Set-PSRepository -Name PSGallery -InstallationPolicy Trusted + } + + $installModuleParameters = @{ + Name = 'AwakeCoding.PSRemoting' + Scope = 'CurrentUser' + Repository = 'PSGallery' + Force = $true + AllowClobber = $true + } + + if ((Get-Command Install-Module).Parameters.ContainsKey('AcceptLicense')) { + $installModuleParameters['AcceptLicense'] = $true + } + + Install-Module @installModuleParameters +} + +if ($Register) { + New-Item -Path $ArtifactsDir -ItemType Directory -Force | Out-Null + Remove-Item -Path $EndpointPath -Force -ErrorAction SilentlyContinue + Ensure-AwakeCodingModule + + $pwshPath = (Get-Command pwsh -ErrorAction Stop).Source + $actionArguments = @( + '-NoLogo', + '-NoProfile', + '-ExecutionPolicy', 'Bypass', + '-File', "`"$PSCommandPath`"", + '-RunServer', + '-EndpointPath', "`"$EndpointPath`"", + '-Port', $Port, + '-ArtifactsDir', "`"$ArtifactsDir`"" + ) -join ' ' + + $taskAction = New-ScheduledTaskAction -Execute $pwshPath -Argument $actionArguments + $taskTrigger = New-ScheduledTaskTrigger -AtLogOn -User "$env:COMPUTERNAME\$env:USERNAME" + $taskPrincipal = New-ScheduledTaskPrincipal -UserId "$env:COMPUTERNAME\$env:USERNAME" -LogonType Interactive -RunLevel Highest + $taskSettings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -ExecutionTimeLimit ([TimeSpan]::Zero) + Register-ScheduledTask -TaskName $TaskName -Action $taskAction -Trigger $taskTrigger -Principal $taskPrincipal -Settings $taskSettings -Force | Out-Null + + [pscustomobject]@{ + TaskName = $TaskName + EndpointPath = $EndpointPath + Port = $Port + } | ConvertTo-Json -Compress + return +} + +if ($RunServer) { + New-Item -Path $ArtifactsDir -ItemType Directory -Force | Out-Null + $serverLogPath = Join-Path $ArtifactsDir 'pshost-server.log' + + try { + Start-Transcript -Path $serverLogPath -Force | Out-Null + Import-Module AwakeCoding.PSRemoting -ErrorAction Stop + $server = Start-PSHostServer -TransportType TCP -Port $Port + $process = Get-Process -Id $PID + + [pscustomobject]@{ + Transport = 'TCP' + HostName = '127.0.0.1' + Port = $Port + ProcessId = $PID + SessionId = $process.SessionId + UserName = [System.Security.Principal.WindowsIdentity]::GetCurrent().Name + State = [string] $server.State + StartedAt = (Get-Date).ToString('o') + } | ConvertTo-Json -Depth 4 | Set-Content -Path $EndpointPath -Encoding utf8NoBOM + + while ($true) { + Start-Sleep -Seconds 5 + $current = Get-PSHostServer -Port $Port -ErrorAction SilentlyContinue + if ($null -eq $current -or [string] $current.State -ne 'Running') { + throw "PSHostServer on port $Port is no longer running" + } + } + } + catch { + [pscustomobject]@{ + Error = $_.Exception.Message + ProcessId = $PID + FailedAt = (Get-Date).ToString('o') + } | ConvertTo-Json -Depth 4 | Set-Content -Path $EndpointPath -Encoding utf8NoBOM + throw + } + finally { + Stop-Transcript -ErrorAction SilentlyContinue | Out-Null + } +} + +if ($Wait) { + $deadline = (Get-Date).AddSeconds($TimeoutSeconds) + do { + if (Test-Path $EndpointPath) { + $endpoint = Get-Content -Path $EndpointPath -Raw | ConvertFrom-Json + if ($endpoint.PSObject.Properties.Name -contains 'Error') { + throw "Interactive PSHostServer failed: $($endpoint.Error)" + } + + $endpoint | ConvertTo-Json -Compress + return + } + + Start-Sleep -Seconds 2 + } while ((Get-Date) -lt $deadline) + + throw "Timed out waiting for interactive PSHostServer endpoint marker: $EndpointPath" +} diff --git a/.github/workflows/agentic-rdp.yml b/.github/workflows/agentic-rdp.yml new file mode 100644 index 000000000..40adc1438 --- /dev/null +++ b/.github/workflows/agentic-rdp.yml @@ -0,0 +1,62 @@ +name: Agentic RDP + +on: + push: + workflow_dispatch: + inputs: + desktop_size: + description: RDP desktop size requested through ironrdp-agent + required: true + default: 1920x1080 + +env: + CARGO_INCREMENTAL: 0 + CARGO_NET_RETRY: 10 + RUSTUP_MAX_RETRIES: 10 + RUST_BACKTRACE: short + CARGO_REGISTRIES_CRATES_IO_PROTOCOL: sparse + CARGO_PROFILE_DEV_DEBUG: 0 + +jobs: + localhost-rdp: + name: Localhost RDP automation + runs-on: windows-latest + timeout-minutes: 60 + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Rust cache + uses: Swatinem/rust-cache@v2.7.3 + + - name: Build ironrdp-agent + run: cargo build -p ironrdp-agent --release + + - name: Run agentic RDP scenario + shell: pwsh + run: | + $desktopSize = '${{ github.event.inputs.desktop_size }}' + if ([string]::IsNullOrWhiteSpace($desktopSize)) { + $desktopSize = '1920x1080' + } + + .\.github\agentic-rdp\Invoke-AgenticRdpTest.ps1 ` + -DesktopSize $desktopSize ` + -ArtifactsDir (Join-Path $env:GITHUB_WORKSPACE 'artifacts\agentic-rdp') + + - name: Upload screenshots + if: ${{ always() }} + uses: actions/upload-artifact@v7 + with: + name: agentic-rdp-screenshots + path: artifacts\agentic-rdp\*.png + if-no-files-found: warn + + - name: Upload logs and metadata + if: ${{ always() }} + uses: actions/upload-artifact@v7 + with: + name: agentic-rdp-logs + path: artifacts\agentic-rdp + if-no-files-found: warn diff --git a/Cargo.lock b/Cargo.lock index 39d69b164..8cd89ae09 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2439,6 +2439,24 @@ dependencies = [ "tracing", ] +[[package]] +name = "ironrdp-agent" +version = "0.1.0" +dependencies = [ + "anyhow", + "clap", + "image", + "ironrdp", + "ironrdp-cfg", + "ironrdp-client", + "ironrdp-core", + "ironrdp-propertyset", + "ironrdp-rdpfile", + "tokio", + "tracing", + "tracing-subscriber", +] + [[package]] name = "ironrdp-ainput" version = "0.5.0" diff --git a/crates/ironrdp-agent/Cargo.toml b/crates/ironrdp-agent/Cargo.toml new file mode 100644 index 000000000..a74d40600 --- /dev/null +++ b/crates/ironrdp-agent/Cargo.toml @@ -0,0 +1,48 @@ +[package] +name = "ironrdp-agent" +version = "0.1.0" +edition.workspace = true +license.workspace = true +homepage.workspace = true +repository.workspace = true +authors.workspace = true +keywords.workspace = true +categories.workspace = true +description = "Agentic IronRDP client daemon and CLI" +publish = false + +[[bin]] +name = "ironrdp-agent" +path = "src/main.rs" +test = false + +[lib] +path = "src/lib.rs" +doctest = false + +[features] +default = ["rustls"] +rustls = ["ironrdp-client/rustls"] +native-tls = ["ironrdp-client/native-tls"] + +[dependencies] +ironrdp = { path = "../ironrdp", version = "0.14", features = ["input", "pdu"] } +ironrdp-client = { path = "../ironrdp-client", version = "0.1", default-features = false } +ironrdp-core = { path = "../ironrdp-core", version = "0.1" } +ironrdp-cfg = { path = "../ironrdp-cfg" } +ironrdp-propertyset = { path = "../ironrdp-propertyset" } +ironrdp-rdpfile = { path = "../ironrdp-rdpfile" } + +anyhow = "1" +clap = { version = "4.6", features = ["derive", "cargo"] } +tokio = { version = "1", features = ["full"] } +tracing = { version = "0.1", features = ["log"] } +tracing-subscriber = { version = "0.3", features = ["env-filter"] } +image = { version = "0.25", default-features = false, features = ["png"] } + +[lints] +workspace = true + +[dev-dependencies] +ironrdp-core = { path = "../ironrdp-core" } +tokio = { version = "1", features = ["full", "test-util"] } diff --git a/crates/ironrdp-agent/src/cli.rs b/crates/ironrdp-agent/src/cli.rs new file mode 100644 index 000000000..ee9d5081e --- /dev/null +++ b/crates/ironrdp-agent/src/cli.rs @@ -0,0 +1,401 @@ +//! CLI argument types for ironrdp-agent (binary). + +use core::str::FromStr; +use std::path::PathBuf; + +use clap::{Args, Parser, Subcommand, ValueEnum}; + +#[cfg(windows)] +pub const DEFAULT_ENDPOINT: &str = "pipe:ironrdp-agent"; +#[cfg(unix)] +pub const DEFAULT_ENDPOINT: &str = "unix:/tmp/ironrdp-agent.sock"; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Endpoint { + Pipe(String), + Unix(PathBuf), +} + +impl FromStr for Endpoint { + type Err = anyhow::Error; + + fn from_str(value: &str) -> anyhow::Result { + if let Some(name) = value.strip_prefix("pipe:") { + if name.is_empty() { + anyhow::bail!("pipe endpoint name is empty"); + } + Ok(Self::Pipe(name.to_owned())) + } else if let Some(path) = value.strip_prefix("unix:") { + if path.is_empty() { + anyhow::bail!("unix endpoint path is empty"); + } + Ok(Self::Unix(PathBuf::from(path))) + } else if cfg!(windows) { + Ok(Self::Pipe(value.to_owned())) + } else { + Ok(Self::Unix(PathBuf::from(value))) + } + } +} + +impl core::fmt::Display for Endpoint { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self { + Self::Pipe(name) => write!(f, "pipe:{name}"), + Self::Unix(path) => write!(f, "unix:{}", path.display()), + } + } +} + +#[derive(Clone, Copy, Debug, ValueEnum)] +pub enum LogLevel { + Error, + Warn, + Info, + Debug, + Trace, +} + +impl LogLevel { + pub fn as_level_filter(self) -> tracing::metadata::LevelFilter { + use tracing::metadata::LevelFilter; + match self { + Self::Error => LevelFilter::ERROR, + Self::Warn => LevelFilter::WARN, + Self::Info => LevelFilter::INFO, + Self::Debug => LevelFilter::DEBUG, + Self::Trace => LevelFilter::TRACE, + } + } +} + +#[derive(Parser, Debug)] +#[clap(author = "Devolutions", about = "Agentic IronRDP client daemon and CLI")] +#[clap(version, long_about = None)] +pub struct Cli { + /// IPC endpoint used by both daemon and client modes. + #[clap(long, default_value = DEFAULT_ENDPOINT)] + pub endpoint: Endpoint, + + /// Default logging level when IRONRDP_LOG and --log-filter are not set. + #[clap(long, value_enum, default_value_t = LogLevel::Warn)] + pub log_level: LogLevel, + + /// Tracing filter directives. Overrides --log-level and IRONRDP_LOG when set. + #[clap(long)] + pub log_filter: Option, + + /// Write logs to this file instead of stderr. + #[clap(long)] + pub log_file: Option, + + /// Do not spawn the daemon automatically when a client command cannot connect. + #[clap(long)] + pub no_spawn_daemon: bool, + + /// Print the agent's extended help text and exit. + #[clap(long)] + pub help_agent: bool, + + #[clap(subcommand)] + pub command: Option, +} + +#[derive(Subcommand, Debug)] +pub enum Command { + /// Run the IPC daemon in the foreground. + Daemon, + /// Connect an RDP session through the daemon. + Connect(Box), + /// List daemon sessions. + Sessions, + /// Get daemon or session status. + Status(SessionArg), + /// Disconnect a session. + Disconnect(RequiredSessionArg), + /// Send mouse input. + Mouse(MouseCommand), + /// Send keyboard input. + Keyboard(KeyboardCommand), + /// Resize a session. + Resize(ResizeCommand), + /// Wait for a framebuffer update. + WaitFrame(WaitFrameCommand), + /// Save the latest session framebuffer as PNG. + Screenshot(ScreenshotCommand), + /// Dump the session's live PropertySet with descriptions. + DumpProperties(RequiredSessionArg), + /// Update a single property on a live session. + SetProperty(SetPropertyCommand), +} + +#[derive(Args, Debug)] +pub struct SessionArg { + #[clap(long)] + pub session: Option, +} + +#[derive(Args, Debug)] +pub struct RequiredSessionArg { + #[clap(long)] + pub session: String, +} + +#[derive(Args, Debug)] +pub struct ConnectCommand { + /// RDP server host or host:port. + pub destination: Option, + + #[clap(short, long)] + pub username: Option, + #[clap(short, long)] + pub domain: Option, + #[clap(short, long, conflicts_with = "password_env")] + pub password: Option, + /// Environment variable containing the RDP password. + #[clap(long)] + pub password_env: Option, + + #[clap(long)] + pub gw_endpoint: Option, + #[clap(long)] + pub gw_user: Option, + #[clap(long)] + pub gw_pass: Option, + + /// Path to a `.rdp` file used as the base property set. + #[clap(long)] + pub rdp_file: Option, + + /// Optional human-readable label associated with the session. + #[clap(long)] + pub label: Option, + + #[clap(long, value_parser = clap::value_parser!(u16).range(1..=8192))] + pub desktop_width: Option, + #[clap(long, value_parser = clap::value_parser!(u16).range(1..=8192))] + pub desktop_height: Option, + #[clap(long, value_parser = clap::value_parser!(u32).range(100..=500))] + pub scale_desktop: Option, + #[clap(long)] + pub color_depth: Option, + #[clap(long)] + pub no_tls: bool, + #[clap(long, alias = "no-nla")] + pub no_credssp: bool, + #[clap(long, action = clap::ArgAction::Set)] + pub compression_enabled: Option, +} + +#[derive(Args, Debug)] +pub struct MouseCommand { + #[clap(long)] + pub session: String, + #[clap(subcommand)] + pub action: MouseAction, +} + +#[derive(Subcommand, Debug)] +pub enum MouseAction { + Move { + #[clap(long)] + x: u16, + #[clap(long)] + y: u16, + }, + Click { + #[clap(long, value_enum)] + button: MouseButton, + #[clap(long)] + x: Option, + #[clap(long)] + y: Option, + }, + Down { + #[clap(long, value_enum)] + button: MouseButton, + }, + Up { + #[clap(long, value_enum)] + button: MouseButton, + }, + Wheel { + #[clap(long)] + units: i16, + #[clap(long)] + horizontal: bool, + }, + Position, +} + +#[derive(Clone, Copy, Debug, ValueEnum)] +pub enum MouseButton { + Left, + Middle, + Right, + X1, + X2, +} + +impl From for crate::ipc::MouseButton { + fn from(value: MouseButton) -> Self { + match value { + MouseButton::Left => Self::Left, + MouseButton::Middle => Self::Middle, + MouseButton::Right => Self::Right, + MouseButton::X1 => Self::X1, + MouseButton::X2 => Self::X2, + } + } +} + +#[derive(Args, Debug)] +pub struct KeyboardCommand { + #[clap(long)] + pub session: String, + #[clap(subcommand)] + pub action: KeyboardAction, +} + +#[derive(Subcommand, Debug)] +pub enum KeyboardAction { + Key { + #[clap(long, value_parser = parse_scancode)] + scancode: u16, + #[clap(long)] + release: bool, + }, + Text { + #[clap(long)] + text: String, + }, + Shortcut { + #[clap(long, value_delimiter = ',', value_parser = parse_scancode)] + scancodes: Vec, + }, + ReleaseAll, +} + +pub fn parse_scancode(input: &str) -> Result { + if let Some(hex) = input.strip_prefix("0x") { + u16::from_str_radix(hex, 16).map_err(|e| e.to_string()) + } else { + input.parse::().map_err(|e| e.to_string()) + } +} + +#[derive(Args, Debug)] +pub struct ResizeCommand { + #[clap(long)] + pub session: String, + #[clap(long)] + pub width: u16, + #[clap(long)] + pub height: u16, + #[clap(long, default_value_t = 100)] + pub scale: u32, +} + +#[derive(Args, Debug)] +pub struct WaitFrameCommand { + #[clap(long)] + pub session: String, + #[clap(long, default_value_t = 30_000)] + pub timeout_ms: u64, + #[clap(long)] + pub after_frame: Option, +} + +#[derive(Args, Debug)] +pub struct ScreenshotCommand { + #[clap(long)] + pub session: String, + #[clap(long)] + pub output: PathBuf, +} + +#[derive(Args, Debug)] +pub struct SetPropertyCommand { + #[clap(long)] + pub session: String, + #[clap(long)] + pub key: String, + #[clap(long)] + pub value: String, +} + +impl ConnectCommand { + /// Render the connect command into a synthetic `.rdp` text payload, + /// combining `--rdp-file` (if any) with CLI overrides. + pub fn to_rdp_content(&self) -> anyhow::Result { + use anyhow::Context as _; + + let mut props = ironrdp_propertyset::PropertySet::new(); + if let Some(rdp_file) = &self.rdp_file { + let input = + std::fs::read_to_string(rdp_file).with_context(|| format!("failed to read {}", rdp_file.display()))?; + if let Err(errors) = ironrdp_rdpfile::load(&mut props, &input) { + for error in &errors { + tracing::warn!(%error, file = %rdp_file.display(), "Ignored .rdp entry"); + } + } + } + + if let Some(dest) = &self.destination { + props.insert("full address", dest.as_str()); + } + if let Some(username) = &self.username { + props.insert("username", username.as_str()); + } + match (&self.password, &self.password_env) { + (Some(p), None) => { + props.insert("ClearTextPassword", p.as_str()); + } + (None, Some(env)) => { + let p = std::env::var(env).with_context(|| format!("failed to read password from {env}"))?; + props.insert("ClearTextPassword", p); + } + (None, None) => {} + (Some(_), Some(_)) => anyhow::bail!("--password and --password-env are mutually exclusive"), + } + if let Some(domain) = &self.domain { + props.insert("domain", domain.as_str()); + } + if let Some(gw) = &self.gw_endpoint { + props.insert("gatewayhostname", gw.as_str()); + props.insert( + "gatewayusagemethod", + ironrdp_cfg::GatewayUsageMethod::UseAlways.as_i64(), + ); + } + if let Some(u) = &self.gw_user { + props.insert("gatewayusername", u.as_str()); + } + if let Some(p) = &self.gw_pass { + props.insert("GatewayPassword", p.as_str()); + } + if let Some(w) = self.desktop_width { + props.insert("desktopwidth", i64::from(w)); + } + if let Some(h) = self.desktop_height { + props.insert("desktopheight", i64::from(h)); + } + if let Some(s) = self.scale_desktop { + props.insert("desktopscalefactor", i64::from(s)); + } + if let Some(cd) = self.color_depth { + props.insert("session bpp", i64::from(cd)); + } + if self.no_credssp { + props.insert("enablecredsspsupport", 0i64); + } + if let Some(enabled) = self.compression_enabled { + props.insert("compression", enabled); + } + if self.no_tls { + props.insert("agent:no_tls", true); + } + + Ok(ironrdp_rdpfile::write(&props)) + } +} diff --git a/crates/ironrdp-agent/src/descriptions.rs b/crates/ironrdp-agent/src/descriptions.rs new file mode 100644 index 000000000..285c757a4 --- /dev/null +++ b/crates/ironrdp-agent/src/descriptions.rs @@ -0,0 +1,40 @@ +//! Human-readable descriptions for canonical `.rdp` properties surfaced by the +//! `dump-properties` IPC call. The list is intentionally curated rather than +//! exhaustive; unknown keys fall back to the generic description. + +pub fn property_description(key: &str) -> &'static str { + match key { + "full address" => "Server address (host:port)", + "alternate full address" => "Alternate server address used by some Microsoft clients", + "username" => "Username used for the connection", + "ClearTextPassword" => "Plain-text password (live-only; never persisted)", + "domain" => "Active Directory or local domain", + "desktopwidth" => "Initial desktop width in pixels", + "desktopheight" => "Initial desktop height in pixels", + "desktopscalefactor" => "DPI scale factor (100, 125, 150, 200)", + "session bpp" => "Desktop colour depth in bits per pixel", + "compression" => "Whether bulk compression is enabled (0/1)", + "audiomode" => "Audio redirection mode", + "redirectclipboard" => "Whether the clipboard is redirected", + "redirectprinters" => "Whether printers are redirected", + "redirectsmartcards" => "Whether smart cards are redirected", + "enablecredsspsupport" => "Whether CredSSP/NLA is enabled", + "negotiate security layer" => "Whether security negotiation is enabled", + "gatewayhostname" => "RDP gateway host name", + "gatewayusername" => "RDP gateway username", + "GatewayPassword" => "RDP gateway password (live-only)", + "gatewayusagemethod" => "Gateway usage method (0=never, 1=direct, 2=detect, 4=default)", + "kdcproxyname" => "KDC proxy URL for Kerberos over HTTPS", + "drivestoredirect" => "Comma-separated drive paths to redirect", + "autoreconnection enabled" => "Whether auto-reconnect on transient failures is enabled", + "prompt for credentials" => "Whether the client should prompt for credentials", + "use multimon" => "Whether multimon span is enabled", + "agent:state" => "Current session state (connecting, connected, failed, disconnected)", + "agent:last_error" => "Last fatal error reported by the session, if any", + "agent:current_width" => "Current desktop width as last reported by the server", + "agent:current_height" => "Current desktop height as last reported by the server", + "agent:label" => "Human-readable label for the session", + "agent:frame_sequence" => "Monotonically increasing framebuffer sequence number", + _ => "(custom or undocumented property)", + } +} diff --git a/crates/ironrdp-agent/src/help.rs b/crates/ironrdp-agent/src/help.rs new file mode 100644 index 000000000..606d8bd59 --- /dev/null +++ b/crates/ironrdp-agent/src/help.rs @@ -0,0 +1,74 @@ +//! Static long-form help text emitted by `--help-agent`. + +pub const HELP_AGENT: &str = r#" +ironrdp-agent - daemon and CLI for driving RDP sessions programmatically + +OVERVIEW + + ironrdp-agent is split into a long-running daemon that hosts one or more + RDP sessions and a thin client mode that issues IPC requests against it. + The transport is a binary length-prefixed framing format using ironrdp-core's + Encode/Decode traits; no HTTP, JSON, or serde are involved. + +ENDPOINTS + + By default the daemon listens on a platform-appropriate endpoint: + + Windows: pipe:ironrdp-agent + Unix: unix:/tmp/ironrdp-agent.sock + + Use `--endpoint pipe:NAME` or `--endpoint unix:/path` to override. + +SUBCOMMANDS + + daemon Run the IPC daemon in the foreground. + connect Open a new RDP session through the daemon. + sessions List all active sessions. + status [--session ID] Print daemon health, or the status of one session. + disconnect --session ID Close a session and disconnect cleanly. + mouse Send mouse input (move/click/down/up/wheel/position). + keyboard Send keyboard input (key/text/shortcut/release-all). + resize Re-issue display-control with new dimensions. + wait-frame Block until a new framebuffer has arrived. + screenshot Save the latest framebuffer to a PNG file. + dump-properties Print the live PropertySet for a session. + +LIVE PROPERTY SET + + Every session keeps a live `PropertySet` that combines the original `.rdp` + content with synthetic `agent:*` properties: + + agent:state connecting | connected | failed | disconnected + agent:last_error last fatal error, if any + agent:current_width current desktop width + agent:current_height current desktop height + agent:label user-provided label + agent:frame_sequence monotonic framebuffer counter + + `dump-properties` returns all entries with human-readable descriptions. + `set-property` can adjust a small set of mutable runtime properties. + +CONNECT PAYLOAD + + Unlike PR #1289, the agent does not forward argv strings. Instead the + client serialises its CLI choices into a `.rdp` text file (the same format + the viewer's --dump-rdp emits) and sends that to the daemon over IPC. The + daemon parses it back into a `PropertySet` and feeds the result through + `ironrdp_client::config::ConfigBuilder::from_property_set(...)`. + +EXAMPLES + + ironrdp-agent daemon + ironrdp-agent connect host.example -u user -p secret --label primary + ironrdp-agent sessions + ironrdp-agent mouse --session $ID move --x 100 --y 200 + ironrdp-agent keyboard --session $ID text --text "hello" + ironrdp-agent screenshot --session $ID --output ./shot.png + ironrdp-agent dump-properties --session $ID + +EXIT CODES + + 0 Success. + 1 Generic failure (IPC error, daemon refusal). + 2 CLI parsing error (clap default). +"#; diff --git a/crates/ironrdp-agent/src/ipc.rs b/crates/ironrdp-agent/src/ipc.rs new file mode 100644 index 000000000..bf8a8aef2 --- /dev/null +++ b/crates/ironrdp-agent/src/ipc.rs @@ -0,0 +1,1042 @@ +//! Binary IPC framing for ironrdp-agent. +//! +//! Wire format: one frame is a `u32` big-endian length followed by an encoded +//! [`Request`] or [`Response`] PDU. Each PDU starts with a `u8` tag identifying +//! the variant, followed by a `u32` request id (for correlation), followed by +//! the variant payload. + +use core::time::Duration; + +use anyhow::Context as _; +use ironrdp_core::{Decode, DecodeResult, Encode, EncodeResult, ReadCursor, WriteCursor, ensure_size}; +use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _}; + +const MAX_FRAME_LEN: u32 = 32 * 1024 * 1024; + +// --- helpers ----------------------------------------------------------------- + +fn write_string(dst: &mut WriteCursor<'_>, value: &str) -> EncodeResult<()> { + ensure_size!(in: dst, size: 4 + value.len()); + dst.write_u32_be(u32::try_from(value.len()).map_err(|_| { + ironrdp_core::invalid_field_err::("string", "length", "exceeds u32::MAX") + })?); + dst.write_slice(value.as_bytes()); + Ok(()) +} + +fn string_size(value: &str) -> usize { + 4 + value.len() +} + +fn read_string(src: &mut ReadCursor<'_>) -> DecodeResult { + ensure_size!(in: src, size: 4); + let len = usize::try_from(src.read_u32_be()).map_err(|_| { + ironrdp_core::invalid_field_err::("string", "length", "exceeds usize") + })?; + ensure_size!(in: src, size: len); + let bytes = src.read_slice(len); + String::from_utf8(bytes.to_vec()) + .map_err(|_| ironrdp_core::invalid_field_err::("string", "utf8", "invalid utf-8")) +} + +fn write_opt_string(dst: &mut WriteCursor<'_>, value: Option<&str>) -> EncodeResult<()> { + ensure_size!(in: dst, size: 1); + if let Some(v) = value { + dst.write_u8(1); + write_string(dst, v) + } else { + dst.write_u8(0); + Ok(()) + } +} + +fn opt_string_size(value: Option<&str>) -> usize { + 1 + value.map_or(0, string_size) +} + +fn read_opt_string(src: &mut ReadCursor<'_>) -> DecodeResult> { + ensure_size!(in: src, size: 1); + match src.read_u8() { + 0 => Ok(None), + 1 => Ok(Some(read_string(src)?)), + _ => Err(ironrdp_core::invalid_field_err::( + "option", + "tag", + "invalid tag", + )), + } +} + +fn write_bytes(dst: &mut WriteCursor<'_>, bytes: &[u8]) -> EncodeResult<()> { + ensure_size!(in: dst, size: 4 + bytes.len()); + dst.write_u32_be(u32::try_from(bytes.len()).map_err(|_| { + ironrdp_core::invalid_field_err::("bytes", "length", "exceeds u32::MAX") + })?); + dst.write_slice(bytes); + Ok(()) +} + +fn bytes_size(bytes: &[u8]) -> usize { + 4 + bytes.len() +} + +fn read_bytes(src: &mut ReadCursor<'_>) -> DecodeResult> { + ensure_size!(in: src, size: 4); + let len = usize::try_from(src.read_u32_be()).map_err(|_| { + ironrdp_core::invalid_field_err::("bytes", "length", "exceeds usize") + })?; + ensure_size!(in: src, size: len); + Ok(src.read_slice(len).to_vec()) +} + +// --- mouse / keyboard -------------------------------------------------------- + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[repr(u8)] +pub enum MouseButton { + Left = 0, + Middle = 1, + Right = 2, + X1 = 3, + X2 = 4, +} + +impl MouseButton { + fn to_u8(self) -> u8 { + match self { + Self::Left => 0, + Self::Middle => 1, + Self::Right => 2, + Self::X1 => 3, + Self::X2 => 4, + } + } + + fn from_u8(v: u8) -> DecodeResult { + Ok(match v { + 0 => Self::Left, + 1 => Self::Middle, + 2 => Self::Right, + 3 => Self::X1, + 4 => Self::X2, + _other => { + return Err(ironrdp_core::invalid_field_err::( + "MouseButton", + "tag", + "unknown tag", + )); + } + }) + } +} + +impl From for ironrdp::input::MouseButton { + fn from(b: MouseButton) -> Self { + match b { + MouseButton::Left => Self::Left, + MouseButton::Middle => Self::Middle, + MouseButton::Right => Self::Right, + MouseButton::X1 => Self::X1, + MouseButton::X2 => Self::X2, + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum MouseAction { + Move { + x: u16, + y: u16, + }, + Click { + button: MouseButton, + x: Option, + y: Option, + }, + Down { + button: MouseButton, + }, + Up { + button: MouseButton, + }, + Wheel { + units: i16, + horizontal: bool, + }, + Position, +} + +impl MouseAction { + fn size(&self) -> usize { + 1 + match self { + Self::Move { .. } => 4, + Self::Click { .. } => 1 + 1 + 2 + 1 + 2, // button + opt x + opt y (1+2 each) + Self::Down { .. } | Self::Up { .. } => 1, + Self::Wheel { .. } => 2 + 1, + Self::Position => 0, + } + } + + fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { + ensure_size!(in: dst, size: 1); + match self { + Self::Move { x, y } => { + dst.write_u8(0); + ensure_size!(in: dst, size: 4); + dst.write_u16_be(*x); + dst.write_u16_be(*y); + } + Self::Click { button, x, y } => { + dst.write_u8(1); + ensure_size!(in: dst, size: 1 + 3 + 3); + dst.write_u8(button.to_u8()); + dst.write_u8(if x.is_some() { 1 } else { 0 }); + dst.write_u16_be(x.unwrap_or(0)); + dst.write_u8(if y.is_some() { 1 } else { 0 }); + dst.write_u16_be(y.unwrap_or(0)); + } + Self::Down { button } => { + dst.write_u8(2); + ensure_size!(in: dst, size: 1); + dst.write_u8(button.to_u8()); + } + Self::Up { button } => { + dst.write_u8(3); + ensure_size!(in: dst, size: 1); + dst.write_u8(button.to_u8()); + } + Self::Wheel { units, horizontal } => { + dst.write_u8(4); + ensure_size!(in: dst, size: 3); + dst.write_i16_be(*units); + dst.write_u8(u8::from(*horizontal)); + } + Self::Position => { + dst.write_u8(5); + } + } + Ok(()) + } + + fn decode(src: &mut ReadCursor<'_>) -> DecodeResult { + ensure_size!(in: src, size: 1); + Ok(match src.read_u8() { + 0 => { + ensure_size!(in: src, size: 4); + Self::Move { + x: src.read_u16_be(), + y: src.read_u16_be(), + } + } + 1 => { + ensure_size!(in: src, size: 1 + 3 + 3); + let button = MouseButton::from_u8(src.read_u8())?; + let x_present = src.read_u8() != 0; + let x_val = src.read_u16_be(); + let y_present = src.read_u8() != 0; + let y_val = src.read_u16_be(); + Self::Click { + button, + x: x_present.then_some(x_val), + y: y_present.then_some(y_val), + } + } + 2 => { + ensure_size!(in: src, size: 1); + Self::Down { + button: MouseButton::from_u8(src.read_u8())?, + } + } + 3 => { + ensure_size!(in: src, size: 1); + Self::Up { + button: MouseButton::from_u8(src.read_u8())?, + } + } + 4 => { + ensure_size!(in: src, size: 3); + Self::Wheel { + units: src.read_i16_be(), + horizontal: src.read_u8() != 0, + } + } + 5 => Self::Position, + _other => { + return Err(ironrdp_core::invalid_field_err::( + "MouseAction", + "tag", + "unknown tag", + )); + } + }) + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum KeyboardAction { + Key { scancode: u16, release: bool }, + Text { text: String }, + Shortcut { scancodes: Vec }, + ReleaseAll, +} + +impl KeyboardAction { + fn size(&self) -> usize { + 1 + match self { + Self::Key { .. } => 2 + 1, + Self::Text { text } => string_size(text), + Self::Shortcut { scancodes } => 4 + scancodes.len() * 2, + Self::ReleaseAll => 0, + } + } + + fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { + ensure_size!(in: dst, size: 1); + match self { + Self::Key { scancode, release } => { + dst.write_u8(0); + ensure_size!(in: dst, size: 3); + dst.write_u16_be(*scancode); + dst.write_u8(u8::from(*release)); + } + Self::Text { text } => { + dst.write_u8(1); + write_string(dst, text)?; + } + Self::Shortcut { scancodes } => { + dst.write_u8(2); + ensure_size!(in: dst, size: 4 + scancodes.len() * 2); + dst.write_u32_be(u32::try_from(scancodes.len()).map_err(|_| { + ironrdp_core::invalid_field_err::( + "Shortcut", + "length", + "exceeds u32::MAX", + ) + })?); + for s in scancodes { + dst.write_u16_be(*s); + } + } + Self::ReleaseAll => { + dst.write_u8(3); + } + } + Ok(()) + } + + fn decode(src: &mut ReadCursor<'_>) -> DecodeResult { + ensure_size!(in: src, size: 1); + Ok(match src.read_u8() { + 0 => { + ensure_size!(in: src, size: 3); + Self::Key { + scancode: src.read_u16_be(), + release: src.read_u8() != 0, + } + } + 1 => Self::Text { + text: read_string(src)?, + }, + 2 => { + ensure_size!(in: src, size: 4); + let n = usize::try_from(src.read_u32_be()).map_err(|_| { + ironrdp_core::invalid_field_err::("Shortcut", "length", "exceeds usize") + })?; + ensure_size!(in: src, size: n * 2); + let mut v = Vec::with_capacity(n); + for _ in 0..n { + v.push(src.read_u16_be()); + } + Self::Shortcut { scancodes: v } + } + 3 => Self::ReleaseAll, + _other => { + return Err(ironrdp_core::invalid_field_err::( + "KeyboardAction", + "tag", + "unknown tag", + )); + } + }) + } +} + +// --- Request ----------------------------------------------------------------- + +#[derive(Clone, Debug)] +pub enum Request { + Health, + Connect { + rdp_content: String, + label: Option, + }, + Sessions, + Status { + session_id: Option, + }, + Disconnect { + session_id: String, + }, + Mouse { + session_id: String, + action: MouseAction, + }, + Keyboard { + session_id: String, + action: KeyboardAction, + }, + Resize { + session_id: String, + width: u16, + height: u16, + scale: u32, + }, + WaitFrame { + session_id: String, + timeout_ms: u64, + after_frame: Option, + }, + Screenshot { + session_id: String, + }, + MousePosition { + session_id: String, + }, + DumpProperties { + session_id: String, + }, + SetProperty { + session_id: String, + key: String, + value: String, + }, +} + +impl Request { + fn tag(&self) -> u8 { + match self { + Self::Health => 0, + Self::Connect { .. } => 1, + Self::Sessions => 2, + Self::Status { .. } => 3, + Self::Disconnect { .. } => 4, + Self::Mouse { .. } => 5, + Self::Keyboard { .. } => 6, + Self::Resize { .. } => 7, + Self::WaitFrame { .. } => 8, + Self::Screenshot { .. } => 9, + Self::MousePosition { .. } => 10, + Self::DumpProperties { .. } => 11, + Self::SetProperty { .. } => 12, + } + } +} + +impl Encode for Request { + fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { + ensure_size!(in: dst, size: 1); + dst.write_u8(self.tag()); + match self { + Self::Health | Self::Sessions => Ok(()), + Self::Connect { rdp_content, label } => { + write_string(dst, rdp_content)?; + write_opt_string(dst, label.as_deref()) + } + Self::Status { session_id } => write_opt_string(dst, session_id.as_deref()), + Self::Disconnect { session_id } + | Self::Screenshot { session_id } + | Self::MousePosition { session_id } + | Self::DumpProperties { session_id } => write_string(dst, session_id), + Self::Mouse { session_id, action } => { + write_string(dst, session_id)?; + action.encode(dst) + } + Self::Keyboard { session_id, action } => { + write_string(dst, session_id)?; + action.encode(dst) + } + Self::Resize { + session_id, + width, + height, + scale, + } => { + write_string(dst, session_id)?; + ensure_size!(in: dst, size: 8); + dst.write_u16_be(*width); + dst.write_u16_be(*height); + dst.write_u32_be(*scale); + Ok(()) + } + Self::WaitFrame { + session_id, + timeout_ms, + after_frame, + } => { + write_string(dst, session_id)?; + ensure_size!(in: dst, size: 8 + 1); + dst.write_u64_be(*timeout_ms); + if let Some(af) = after_frame { + dst.write_u8(1); + ensure_size!(in: dst, size: 8); + dst.write_u64_be(*af); + } else { + dst.write_u8(0); + } + Ok(()) + } + Self::SetProperty { session_id, key, value } => { + write_string(dst, session_id)?; + write_string(dst, key)?; + write_string(dst, value) + } + } + } + + fn name(&self) -> &'static str { + "AgentRequest" + } + + fn size(&self) -> usize { + 1 + match self { + Self::Health | Self::Sessions => 0, + Self::Connect { rdp_content, label } => string_size(rdp_content) + opt_string_size(label.as_deref()), + Self::Status { session_id } => opt_string_size(session_id.as_deref()), + Self::Disconnect { session_id } + | Self::Screenshot { session_id } + | Self::MousePosition { session_id } + | Self::DumpProperties { session_id } => string_size(session_id), + Self::Mouse { session_id, action } => string_size(session_id) + action.size(), + Self::Keyboard { session_id, action } => string_size(session_id) + action.size(), + Self::Resize { session_id, .. } => string_size(session_id) + 8, + Self::WaitFrame { + session_id, + after_frame, + .. + } => string_size(session_id) + 8 + 1 + if after_frame.is_some() { 8 } else { 0 }, + Self::SetProperty { session_id, key, value } => { + string_size(session_id) + string_size(key) + string_size(value) + } + } + } +} + +impl<'de> Decode<'de> for Request { + fn decode(src: &mut ReadCursor<'de>) -> DecodeResult { + ensure_size!(in: src, size: 1); + Ok(match src.read_u8() { + 0 => Self::Health, + 1 => Self::Connect { + rdp_content: read_string(src)?, + label: read_opt_string(src)?, + }, + 2 => Self::Sessions, + 3 => Self::Status { + session_id: read_opt_string(src)?, + }, + 4 => Self::Disconnect { + session_id: read_string(src)?, + }, + 5 => Self::Mouse { + session_id: read_string(src)?, + action: MouseAction::decode(src)?, + }, + 6 => Self::Keyboard { + session_id: read_string(src)?, + action: KeyboardAction::decode(src)?, + }, + 7 => { + let session_id = read_string(src)?; + ensure_size!(in: src, size: 8); + Self::Resize { + session_id, + width: src.read_u16_be(), + height: src.read_u16_be(), + scale: src.read_u32_be(), + } + } + 8 => { + let session_id = read_string(src)?; + ensure_size!(in: src, size: 9); + let timeout_ms = src.read_u64_be(); + let after_frame = if src.read_u8() != 0 { + ensure_size!(in: src, size: 8); + Some(src.read_u64_be()) + } else { + None + }; + Self::WaitFrame { + session_id, + timeout_ms, + after_frame, + } + } + 9 => Self::Screenshot { + session_id: read_string(src)?, + }, + 10 => Self::MousePosition { + session_id: read_string(src)?, + }, + 11 => Self::DumpProperties { + session_id: read_string(src)?, + }, + 12 => Self::SetProperty { + session_id: read_string(src)?, + key: read_string(src)?, + value: read_string(src)?, + }, + _other => { + return Err(ironrdp_core::invalid_field_err::( + "Request", + "tag", + "unknown tag", + )); + } + }) + } +} + +// --- session summary --------------------------------------------------------- + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[repr(u8)] +pub enum SessionStatus { + Connecting = 0, + Connected = 1, + Failed = 2, + Disconnected = 3, +} + +impl SessionStatus { + fn to_u8(self) -> u8 { + match self { + Self::Connecting => 0, + Self::Connected => 1, + Self::Failed => 2, + Self::Disconnected => 3, + } + } + + fn from_u8(v: u8) -> DecodeResult { + Ok(match v { + 0 => Self::Connecting, + 1 => Self::Connected, + 2 => Self::Failed, + 3 => Self::Disconnected, + _other => { + return Err(ironrdp_core::invalid_field_err::( + "SessionStatus", + "tag", + "unknown tag", + )); + } + }) + } + + pub fn as_str(self) -> &'static str { + match self { + Self::Connecting => "connecting", + Self::Connected => "connected", + Self::Failed => "failed", + Self::Disconnected => "disconnected", + } + } +} + +#[derive(Clone, Debug)] +pub struct SessionSummary { + pub session_id: String, + pub label: Option, + pub status: SessionStatus, + pub width: Option, + pub height: Option, + pub frame_sequence: u64, + pub mouse_x: u16, + pub mouse_y: u16, + pub last_error: Option, +} + +impl SessionSummary { + fn size(&self) -> usize { + string_size(&self.session_id) + + opt_string_size(self.label.as_deref()) + + 1 + + 1 + 2 // width opt + + 1 + 2 + + 8 + 2 + 2 + + opt_string_size(self.last_error.as_deref()) + } + + fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { + write_string(dst, &self.session_id)?; + write_opt_string(dst, self.label.as_deref())?; + ensure_size!(in: dst, size: 1 + 3 + 3 + 8 + 4); + dst.write_u8(self.status.to_u8()); + dst.write_u8(if self.width.is_some() { 1 } else { 0 }); + dst.write_u16_be(self.width.unwrap_or(0)); + dst.write_u8(if self.height.is_some() { 1 } else { 0 }); + dst.write_u16_be(self.height.unwrap_or(0)); + dst.write_u64_be(self.frame_sequence); + dst.write_u16_be(self.mouse_x); + dst.write_u16_be(self.mouse_y); + write_opt_string(dst, self.last_error.as_deref()) + } + + fn decode(src: &mut ReadCursor<'_>) -> DecodeResult { + let session_id = read_string(src)?; + let label = read_opt_string(src)?; + ensure_size!(in: src, size: 1 + 3 + 3 + 8 + 4); + let status = SessionStatus::from_u8(src.read_u8())?; + let w_present = src.read_u8() != 0; + let w = src.read_u16_be(); + let h_present = src.read_u8() != 0; + let h = src.read_u16_be(); + let frame_sequence = src.read_u64_be(); + let mouse_x = src.read_u16_be(); + let mouse_y = src.read_u16_be(); + let last_error = read_opt_string(src)?; + Ok(Self { + session_id, + label, + status, + width: w_present.then_some(w), + height: h_present.then_some(h), + frame_sequence, + mouse_x, + mouse_y, + last_error, + }) + } +} + +#[derive(Clone, Debug)] +pub struct PropertyEntry { + pub key: String, + pub value: String, + pub description: String, +} + +// --- Response ---------------------------------------------------------------- + +#[derive(Clone, Debug)] +pub enum Response { + Ok, + Error { message: String }, + Health, + Connect { session_id: String }, + Sessions { sessions: Vec }, + Status { summary: SessionSummary }, + MousePosition { x: u16, y: u16 }, + Screenshot { png: Vec }, + Properties { entries: Vec }, +} + +impl Response { + fn tag(&self) -> u8 { + match self { + Self::Ok => 0, + Self::Error { .. } => 1, + Self::Health => 2, + Self::Connect { .. } => 3, + Self::Sessions { .. } => 4, + Self::Status { .. } => 5, + Self::MousePosition { .. } => 6, + Self::Screenshot { .. } => 7, + Self::Properties { .. } => 8, + } + } +} + +impl Encode for Response { + fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { + ensure_size!(in: dst, size: 1); + dst.write_u8(self.tag()); + match self { + Self::Ok | Self::Health => Ok(()), + Self::Error { message } => write_string(dst, message), + Self::Connect { session_id } => write_string(dst, session_id), + Self::Sessions { sessions } => { + ensure_size!(in: dst, size: 4); + dst.write_u32_be(u32::try_from(sessions.len()).map_err(|_| { + ironrdp_core::invalid_field_err::("Sessions", "len", "exceeds u32::MAX") + })?); + for s in sessions { + s.encode(dst)?; + } + Ok(()) + } + Self::Status { summary } => summary.encode(dst), + Self::MousePosition { x, y } => { + ensure_size!(in: dst, size: 4); + dst.write_u16_be(*x); + dst.write_u16_be(*y); + Ok(()) + } + Self::Screenshot { png } => write_bytes(dst, png), + Self::Properties { entries } => { + ensure_size!(in: dst, size: 4); + dst.write_u32_be(u32::try_from(entries.len()).map_err(|_| { + ironrdp_core::invalid_field_err::( + "Properties", + "len", + "exceeds u32::MAX", + ) + })?); + for e in entries { + write_string(dst, &e.key)?; + write_string(dst, &e.value)?; + write_string(dst, &e.description)?; + } + Ok(()) + } + } + } + + fn name(&self) -> &'static str { + "AgentResponse" + } + + fn size(&self) -> usize { + 1 + match self { + Self::Ok | Self::Health => 0, + Self::Error { message } => string_size(message), + Self::Connect { session_id } => string_size(session_id), + Self::Sessions { sessions } => 4 + sessions.iter().map(SessionSummary::size).sum::(), + Self::Status { summary } => summary.size(), + Self::MousePosition { .. } => 4, + Self::Screenshot { png } => bytes_size(png), + Self::Properties { entries } => { + 4 + entries + .iter() + .map(|e| string_size(&e.key) + string_size(&e.value) + string_size(&e.description)) + .sum::() + } + } + } +} + +impl<'de> Decode<'de> for Response { + fn decode(src: &mut ReadCursor<'de>) -> DecodeResult { + ensure_size!(in: src, size: 1); + Ok(match src.read_u8() { + 0 => Self::Ok, + 1 => Self::Error { + message: read_string(src)?, + }, + 2 => Self::Health, + 3 => Self::Connect { + session_id: read_string(src)?, + }, + 4 => { + ensure_size!(in: src, size: 4); + let n = usize::try_from(src.read_u32_be()).map_err(|_| { + ironrdp_core::invalid_field_err::("Sessions", "len", "exceeds usize") + })?; + let mut sessions = Vec::with_capacity(n); + for _ in 0..n { + sessions.push(SessionSummary::decode(src)?); + } + Self::Sessions { sessions } + } + 5 => Self::Status { + summary: SessionSummary::decode(src)?, + }, + 6 => { + ensure_size!(in: src, size: 4); + Self::MousePosition { + x: src.read_u16_be(), + y: src.read_u16_be(), + } + } + 7 => Self::Screenshot { png: read_bytes(src)? }, + 8 => { + ensure_size!(in: src, size: 4); + let n = usize::try_from(src.read_u32_be()).map_err(|_| { + ironrdp_core::invalid_field_err::("Properties", "len", "exceeds usize") + })?; + let mut entries = Vec::with_capacity(n); + for _ in 0..n { + entries.push(PropertyEntry { + key: read_string(src)?, + value: read_string(src)?, + description: read_string(src)?, + }); + } + Self::Properties { entries } + } + _other => { + return Err(ironrdp_core::invalid_field_err::( + "Response", + "tag", + "unknown tag", + )); + } + }) + } +} + +// --- framing ----------------------------------------------------------------- + +pub async fn write_frame(writer: &mut W, pdu: &P) -> anyhow::Result<()> +where + W: tokio::io::AsyncWrite + Unpin, + P: Encode, +{ + let body = ironrdp_core::encode_vec(pdu).context("encode PDU")?; + let len = u32::try_from(body.len()).context("frame too large")?; + writer + .write_all(&len.to_be_bytes()) + .await + .context("write frame length")?; + writer.write_all(&body).await.context("write frame body")?; + writer.flush().await.context("flush frame")?; + Ok(()) +} + +pub async fn read_frame(reader: &mut R) -> anyhow::Result

+where + R: tokio::io::AsyncRead + Unpin, + for<'de> P: Decode<'de>, +{ + let mut len_buf = [0u8; 4]; + reader.read_exact(&mut len_buf).await.context("read frame length")?; + let len = u32::from_be_bytes(len_buf); + anyhow::ensure!(len <= MAX_FRAME_LEN, "frame too large: {len}"); + let mut body = vec![0u8; usize::try_from(len).context("frame length exceeds usize")?]; + reader.read_exact(&mut body).await.context("read frame body")?; + let mut cursor = ReadCursor::new(&body); + let pdu = P::decode(&mut cursor).context("decode PDU")?; + Ok(pdu) +} + +pub const REQUEST_TIMEOUT: Duration = Duration::from_secs(60); + +#[cfg(test)] +mod tests { + use super::*; + + fn round_trip_request(req: Request) { + let bytes = ironrdp_core::encode_vec(&req).expect("encode"); + let mut cursor = ReadCursor::new(&bytes); + let decoded = Request::decode(&mut cursor).expect("decode"); + let bytes2 = ironrdp_core::encode_vec(&decoded).expect("encode2"); + assert_eq!(bytes, bytes2, "round-trip mismatch for {req:?}"); + } + + fn round_trip_response(resp: Response) { + let bytes = ironrdp_core::encode_vec(&resp).expect("encode"); + let mut cursor = ReadCursor::new(&bytes); + let decoded = Response::decode(&mut cursor).expect("decode"); + let bytes2 = ironrdp_core::encode_vec(&decoded).expect("encode2"); + assert_eq!(bytes, bytes2); + } + + #[test] + fn request_round_trips() { + round_trip_request(Request::Health); + round_trip_request(Request::Sessions); + round_trip_request(Request::Connect { + rdp_content: "full address:s:host\nusername:s:bob".to_owned(), + label: Some("primary".to_owned()), + }); + round_trip_request(Request::Status { session_id: None }); + round_trip_request(Request::Status { + session_id: Some("abc".to_owned()), + }); + round_trip_request(Request::Disconnect { + session_id: "abc".to_owned(), + }); + round_trip_request(Request::Mouse { + session_id: "s".to_owned(), + action: MouseAction::Move { x: 10, y: 20 }, + }); + round_trip_request(Request::Mouse { + session_id: "s".to_owned(), + action: MouseAction::Click { + button: MouseButton::Left, + x: Some(1), + y: None, + }, + }); + round_trip_request(Request::Keyboard { + session_id: "s".to_owned(), + action: KeyboardAction::Text { + text: "hello".to_owned(), + }, + }); + round_trip_request(Request::Keyboard { + session_id: "s".to_owned(), + action: KeyboardAction::Shortcut { + scancodes: vec![1, 2, 3], + }, + }); + round_trip_request(Request::Keyboard { + session_id: "s".to_owned(), + action: KeyboardAction::ReleaseAll, + }); + round_trip_request(Request::Resize { + session_id: "s".to_owned(), + width: 1920, + height: 1080, + scale: 100, + }); + round_trip_request(Request::WaitFrame { + session_id: "s".to_owned(), + timeout_ms: 5000, + after_frame: Some(10), + }); + round_trip_request(Request::WaitFrame { + session_id: "s".to_owned(), + timeout_ms: 1000, + after_frame: None, + }); + round_trip_request(Request::Screenshot { + session_id: "s".to_owned(), + }); + round_trip_request(Request::MousePosition { + session_id: "s".to_owned(), + }); + round_trip_request(Request::DumpProperties { + session_id: "s".to_owned(), + }); + round_trip_request(Request::SetProperty { + session_id: "s".to_owned(), + key: "desktopwidth".to_owned(), + value: "1024".to_owned(), + }); + } + + #[test] + fn response_round_trips() { + round_trip_response(Response::Ok); + round_trip_response(Response::Health); + round_trip_response(Response::Error { + message: "boom".to_owned(), + }); + round_trip_response(Response::Connect { + session_id: "abc".to_owned(), + }); + round_trip_response(Response::Sessions { + sessions: vec![SessionSummary { + session_id: "abc".to_owned(), + label: Some("primary".to_owned()), + status: SessionStatus::Connected, + width: Some(1024), + height: Some(768), + frame_sequence: 42, + mouse_x: 100, + mouse_y: 200, + last_error: None, + }], + }); + round_trip_response(Response::MousePosition { x: 1, y: 2 }); + round_trip_response(Response::Screenshot { png: vec![1, 2, 3, 4] }); + round_trip_response(Response::Properties { + entries: vec![PropertyEntry { + key: "k".to_owned(), + value: "v".to_owned(), + description: "d".to_owned(), + }], + }); + } +} diff --git a/crates/ironrdp-agent/src/lib.rs b/crates/ironrdp-agent/src/lib.rs new file mode 100644 index 000000000..5bc42f2e1 --- /dev/null +++ b/crates/ironrdp-agent/src/lib.rs @@ -0,0 +1,9 @@ +#![cfg_attr(doc, doc = include_str!("../README.md"))] +// `image`, `ironrdp-client`, `tracing-subscriber` are referenced from `main.rs` only. +#![expect(unused_crate_dependencies, reason = "consumed by the binary target")] + +pub mod cli; +pub mod descriptions; +pub mod help; +pub mod ipc; +pub mod redact; diff --git a/crates/ironrdp-agent/src/main.rs b/crates/ironrdp-agent/src/main.rs new file mode 100644 index 000000000..150e95df4 --- /dev/null +++ b/crates/ironrdp-agent/src/main.rs @@ -0,0 +1,977 @@ +#![expect(clippy::print_stdout, reason = "CLI prints structured output to stdout")] +#![expect(unused_crate_dependencies, reason = "split lib/bin causes false positives")] + +use core::time::Duration; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::process::Stdio; +use std::sync::Arc; + +use anyhow::Context as _; +use ironrdp::input::{Database, MousePosition, Operation, Scancode, WheelRotations}; +use ironrdp_agent::cli::{ + Cli, Command, Endpoint, KeyboardAction as CliKeyboardAction, LogLevel, MouseAction as CliMouseAction, + ScreenshotCommand, SessionArg, SetPropertyCommand, WaitFrameCommand, +}; +use ironrdp_agent::descriptions::property_description; +use ironrdp_agent::help::HELP_AGENT; +use ironrdp_agent::ipc::{ + KeyboardAction, MouseAction, PropertyEntry, Request, Response, SessionStatus, SessionSummary, read_frame, + write_frame, +}; +use ironrdp_client::config::ConfigBuilder; +use ironrdp_client::rdp::{DvcPipeProxyFactory, RdpClient, RdpInputEvent, RdpOutputEvent}; +use ironrdp_propertyset::PropertySet; +use tokio::io::{AsyncRead, AsyncWrite}; +use tokio::sync::{Mutex, Notify, RwLock, mpsc}; +use tracing::{debug, error, info, warn}; + +use clap::Parser as _; + +trait AsyncReadWrite: AsyncRead + AsyncWrite {} +impl AsyncReadWrite for T where T: AsyncRead + AsyncWrite {} + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + let cli = Cli::parse(); + + if cli.help_agent { + print!("{HELP_AGENT}"); + return Ok(()); + } + + setup_logging(cli.log_level, cli.log_filter.as_deref(), cli.log_file.as_deref()) + .context("unable to initialize logging")?; + + let command = cli + .command + .ok_or_else(|| anyhow::anyhow!("missing subcommand; try --help-agent"))?; + + match command { + Command::Daemon => serve_daemon(cli.endpoint).await, + command => { + let daemon_logging = DaemonLogging { + log_level: cli.log_level, + log_filter: cli.log_filter, + log_file: cli.log_file, + }; + run_client_command(cli.endpoint, !cli.no_spawn_daemon, daemon_logging, command).await + } + } +} + +// --- logging ----------------------------------------------------------------- + +fn setup_logging(log_level: LogLevel, log_filter: Option<&str>, log_file: Option<&Path>) -> anyhow::Result<()> { + use tracing_subscriber::EnvFilter; + use tracing_subscriber::prelude::*; + + let env_filter = if let Some(log_filter) = log_filter { + EnvFilter::builder().parse_lossy(log_filter) + } else { + EnvFilter::builder() + .with_default_directive(log_level.as_level_filter().into()) + .with_env_var("IRONRDP_LOG") + .from_env_lossy() + }; + + if let Some(log_file) = log_file { + let file = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(log_file) + .with_context(|| format!("couldn't open {}", log_file.display()))?; + let fmt_layer = tracing_subscriber::fmt::layer() + .with_ansi(false) + .with_writer(file) + .compact(); + tracing_subscriber::registry() + .with(env_filter) + .with(fmt_layer) + .try_init() + .context("failed to set tracing global subscriber")?; + } else { + let fmt_layer = tracing_subscriber::fmt::layer().compact(); + tracing_subscriber::registry() + .with(env_filter) + .with(fmt_layer) + .try_init() + .context("failed to set tracing global subscriber")?; + } + + Ok(()) +} + +// --- daemon ------------------------------------------------------------------ + +struct Frame { + buffer: Vec, + width: u16, + height: u16, +} + +impl Frame { + fn to_png(&self) -> anyhow::Result> { + use std::io::Cursor; + + let mut rgba = Vec::with_capacity(self.buffer.len() * 4); + for pixel in &self.buffer { + let [_, r, g, b] = pixel.to_be_bytes(); + rgba.extend_from_slice(&[r, g, b, 255]); + } + let image = + image::ImageBuffer::, _>::from_raw(u32::from(self.width), u32::from(self.height), rgba) + .context("invalid framebuffer dimensions")?; + let mut png = Cursor::new(Vec::new()); + image::DynamicImage::ImageRgba8(image) + .write_to(&mut png, image::ImageFormat::Png) + .context("encode PNG")?; + Ok(png.into_inner()) + } +} + +struct SessionSnapshot { + status: SessionStatus, + frame: Option, + frame_sequence: u64, + pointer_x: u16, + pointer_y: u16, + last_error: Option, +} + +impl SessionSnapshot { + fn new() -> Self { + Self { + status: SessionStatus::Connecting, + frame: None, + frame_sequence: 0, + pointer_x: 0, + pointer_y: 0, + last_error: None, + } + } +} + +struct SessionEntry { + session_id: String, + label: Option, + input_sender: mpsc::UnboundedSender, + input_database: Arc>, + snapshot: Arc>, + notify: Arc, + properties: Arc>, +} + +impl SessionEntry { + async fn summary(&self) -> SessionSummary { + let snap = self.snapshot.read().await; + let mouse = self.input_database.lock().await.mouse_position(); + SessionSummary { + session_id: self.session_id.clone(), + label: self.label.clone(), + status: snap.status, + width: snap.frame.as_ref().map(|f| f.width), + height: snap.frame.as_ref().map(|f| f.height), + frame_sequence: snap.frame_sequence, + mouse_x: mouse.x, + mouse_y: mouse.y, + last_error: snap.last_error.clone(), + } + } + + async fn apply_operations(&self, operations: impl IntoIterator) -> anyhow::Result<()> { + let mut db = self.input_database.lock().await; + let events = db.apply(operations); + if !events.is_empty() { + self.input_sender + .send(RdpInputEvent::FastPath(events)) + .map_err(|_| anyhow::anyhow!("session input channel is closed"))?; + } + Ok(()) + } + + async fn wait_frame(&self, timeout: Duration, after_frame: Option) -> anyhow::Result<()> { + if self.has_requested_frame(after_frame).await { + return Ok(()); + } + tokio::time::timeout(timeout, async { + loop { + self.notify.notified().await; + if self.has_requested_frame(after_frame).await { + break; + } + } + }) + .await + .map_err(|_| anyhow::anyhow!("timed out waiting for frame"))?; + if self.has_requested_frame(after_frame).await { + Ok(()) + } else { + anyhow::bail!("session has no frame") + } + } + + async fn has_requested_frame(&self, after_frame: Option) -> bool { + let snap = self.snapshot.read().await; + snap.frame.is_some() && after_frame.is_none_or(|af| snap.frame_sequence > af) + } + + async fn screenshot_png(&self) -> anyhow::Result> { + let snap = self.snapshot.read().await; + let frame = snap.frame.as_ref().context("session has no frame")?; + frame.to_png() + } + + async fn dump_properties(&self) -> Vec { + let snap = self.snapshot.read().await; + let mouse = self.input_database.lock().await.mouse_position(); + let mut props = self.properties.read().await.clone(); + // Surface agent:* synthetics so the consumer sees them in one shot. + props.insert("agent:state", snap.status.as_str()); + if let Some(err) = &snap.last_error { + props.insert("agent:last_error", err.as_str()); + } + if let Some(frame) = &snap.frame { + props.insert("agent:current_width", i64::from(frame.width)); + props.insert("agent:current_height", i64::from(frame.height)); + } + props.insert( + "agent:frame_sequence", + i64::try_from(snap.frame_sequence).unwrap_or(i64::MAX), + ); + props.insert("agent:mouse_x", i64::from(mouse.x)); + props.insert("agent:mouse_y", i64::from(mouse.y)); + if let Some(label) = &self.label { + props.insert("agent:label", label.as_str()); + } + + let mut entries: Vec<_> = props + .iter() + .map(|(k, v)| { + let value_string = v.to_string(); + let key_str = k.as_ref(); + let value = ironrdp_agent::redact::redact_value(key_str, &value_string).to_owned(); + PropertyEntry { + key: k.to_string(), + value, + description: property_description(key_str).to_owned(), + } + }) + .collect(); + entries.sort_by(|a, b| a.key.cmp(&b.key)); + entries + } + + async fn set_property(&self, key: &str, value: &str) -> anyhow::Result<()> { + let mut props = self.properties.write().await; + let key_owned = key.to_owned(); + if let Ok(n) = value.parse::() { + props.insert(key_owned, n); + } else { + props.insert(key_owned, value.to_owned()); + } + Ok(()) + } +} + +struct Daemon { + sessions: RwLock>>, + counter: core::sync::atomic::AtomicU64, +} + +impl Daemon { + fn new() -> Self { + Self { + sessions: RwLock::new(HashMap::new()), + counter: core::sync::atomic::AtomicU64::new(1), + } + } + + fn new_session_id(&self) -> String { + let n = self.counter.fetch_add(1, core::sync::atomic::Ordering::Relaxed); + format!("s{:08x}{:04x}", std::process::id(), n & 0xffff,) + } + + async fn create_session(&self, rdp_content: String, label: Option) -> anyhow::Result { + let mut properties = PropertySet::new(); + if let Err(errors) = ironrdp_rdpfile::load(&mut properties, &rdp_content) { + for error in &errors { + warn!(%error, "Ignored .rdp entry from IPC connect payload"); + } + } + + let config = ConfigBuilder::from_property_set(&properties) + .build() + .context("build session config from property set")?; + + let session_id = self.new_session_id(); + let snapshot = Arc::new(RwLock::new(SessionSnapshot::new())); + let notify = Arc::new(Notify::new()); + let input_database = Arc::new(Mutex::new(Database::new())); + let (input_sender, input_receiver) = RdpInputEvent::create_channel(); + let (output_sender, output_receiver) = mpsc::channel::(64); + let dvc_pipe_proxy_factory = DvcPipeProxyFactory::new(input_sender.clone()); + + let client = RdpClient { + config, + output_event_sender: output_sender, + input_event_receiver: input_receiver, + dvc_pipe_proxy_factory, + }; + + std::thread::spawn(move || { + let runtime = match tokio::runtime::Builder::new_current_thread().enable_all().build() { + Ok(rt) => rt, + Err(error) => { + error!(%error, "Failed to create RDP session runtime"); + return; + } + }; + runtime.block_on(client.run()); + }); + + let properties = Arc::new(RwLock::new(properties)); + tokio::spawn(process_output_events( + Arc::clone(&snapshot), + Arc::clone(¬ify), + Arc::clone(&properties), + output_receiver, + )); + + let entry = Arc::new(SessionEntry { + session_id: session_id.clone(), + label, + input_sender, + input_database, + snapshot, + notify, + properties, + }); + + self.sessions.write().await.insert(session_id.clone(), entry); + Ok(session_id) + } + + async fn session(&self, session_id: &str) -> anyhow::Result> { + self.sessions + .read() + .await + .get(session_id) + .cloned() + .with_context(|| format!("session {session_id} not found")) + } + + async fn summaries(&self) -> Vec { + let sessions = self.sessions.read().await; + let mut out = Vec::with_capacity(sessions.len()); + for entry in sessions.values() { + out.push(entry.summary().await); + } + out + } +} + +async fn process_output_events( + snapshot: Arc>, + notify: Arc, + properties: Arc>, + mut receiver: mpsc::Receiver, +) { + while let Some(event) = receiver.recv().await { + match event { + RdpOutputEvent::Image { buffer, width, height } => { + let mut snap = snapshot.write().await; + snap.status = SessionStatus::Connected; + snap.frame_sequence = snap.frame_sequence.saturating_add(1); + snap.frame = Some(Frame { + buffer, + width: width.get(), + height: height.get(), + }); + let mut props = properties.write().await; + props.insert("agent:state", "connected"); + props.insert("agent:current_width", core::num::NonZeroI64::from(width).get()); + props.insert("agent:current_height", core::num::NonZeroI64::from(height).get()); + notify.notify_waiters(); + } + RdpOutputEvent::ConnectionFailure(error) => { + let mut snap = snapshot.write().await; + snap.status = SessionStatus::Failed; + snap.last_error = Some(error.to_string()); + properties.write().await.insert("agent:state", "failed"); + notify.notify_waiters(); + } + RdpOutputEvent::Terminated(result) => { + let mut snap = snapshot.write().await; + snap.status = match &result { + Ok(_) => SessionStatus::Disconnected, + Err(_) => SessionStatus::Failed, + }; + snap.last_error = match result { + Ok(reason) => Some(reason.to_string()), + Err(error) => Some(error.to_string()), + }; + properties.write().await.insert("agent:state", snap.status.as_str()); + notify.notify_waiters(); + } + RdpOutputEvent::PointerPosition { x, y } => { + let mut snap = snapshot.write().await; + snap.pointer_x = x; + snap.pointer_y = y; + } + RdpOutputEvent::PointerDefault | RdpOutputEvent::PointerHidden | RdpOutputEvent::PointerBitmap(_) => {} + } + } +} + +async fn serve_daemon(endpoint: Endpoint) -> anyhow::Result<()> { + let daemon = Arc::new(Daemon::new()); + info!(%endpoint, "Start ironrdp-agent daemon"); + + match endpoint { + Endpoint::Pipe(name) => serve_pipe(name, daemon).await, + Endpoint::Unix(path) => serve_unix(path, daemon).await, + } +} + +#[cfg(windows)] +async fn serve_pipe(name: String, daemon: Arc) -> anyhow::Result<()> { + use tokio::net::windows::named_pipe::{PipeMode, ServerOptions}; + + let path = format!(r"\\.\pipe\{name}"); + loop { + let server = ServerOptions::new() + .access_inbound(true) + .access_outbound(true) + .pipe_mode(PipeMode::Byte) + .create(&path) + .with_context(|| format!("failed to create named pipe {path}"))?; + server + .connect() + .await + .with_context(|| format!("failed to accept named pipe connection on {path}"))?; + let daemon = Arc::clone(&daemon); + tokio::spawn(async move { + if let Err(error) = serve_stream(server, daemon).await { + error!(%error, "IPC connection failed"); + } + }); + } +} + +#[cfg(not(windows))] +async fn serve_pipe(_name: String, _daemon: Arc) -> anyhow::Result<()> { + anyhow::bail!("named pipe endpoints are only supported on Windows") +} + +#[cfg(unix)] +async fn serve_unix(path: PathBuf, daemon: Arc) -> anyhow::Result<()> { + use std::os::unix::fs::FileTypeExt as _; + + if let Ok(metadata) = std::fs::metadata(&path) { + if metadata.file_type().is_socket() { + std::fs::remove_file(&path).with_context(|| format!("failed to remove {}", path.display()))?; + } else { + anyhow::bail!("{} already exists and is not a socket", path.display()); + } + } + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).with_context(|| format!("failed to create {}", parent.display()))?; + } + let listener = + tokio::net::UnixListener::bind(&path).with_context(|| format!("failed to bind {}", path.display()))?; + + loop { + let (stream, _addr) = listener + .accept() + .await + .with_context(|| format!("failed to accept {}", path.display()))?; + let daemon = Arc::clone(&daemon); + tokio::spawn(async move { + if let Err(error) = serve_stream(stream, daemon).await { + error!(%error, "IPC connection failed"); + } + }); + } +} + +#[cfg(not(unix))] +async fn serve_unix(_path: PathBuf, _daemon: Arc) -> anyhow::Result<()> { + anyhow::bail!("unix endpoints are only supported on Unix-like systems") +} + +async fn serve_stream(stream: S, daemon: Arc) -> anyhow::Result<()> +where + S: AsyncRead + AsyncWrite + Unpin + Send + 'static, +{ + let (read_half, write_half) = tokio::io::split(stream); + let mut reader = read_half; + let writer = Arc::new(Mutex::new(write_half)); + + loop { + let request = match read_frame::<_, Request>(&mut reader).await { + Ok(r) => r, + Err(error) => { + debug!(%error, "IPC stream closed"); + break; + } + }; + let response = match handle_request(request, &daemon).await { + Ok(r) => r, + Err(error) => Response::Error { + message: error.to_string(), + }, + }; + let mut w = writer.lock().await; + if let Err(error) = write_frame(&mut *w, &response).await { + warn!(%error, "Failed to write IPC response"); + break; + } + } + + Ok(()) +} + +async fn handle_request(request: Request, daemon: &Arc) -> anyhow::Result { + match request { + Request::Health => Ok(Response::Health), + Request::Connect { rdp_content, label } => { + let session_id = daemon.create_session(rdp_content, label).await?; + Ok(Response::Connect { session_id }) + } + Request::Sessions => Ok(Response::Sessions { + sessions: daemon.summaries().await, + }), + Request::Status { session_id } => { + if let Some(id) = session_id { + let session = daemon.session(&id).await?; + Ok(Response::Status { + summary: session.summary().await, + }) + } else { + Ok(Response::Health) + } + } + Request::Disconnect { session_id } => { + let session = daemon.session(&session_id).await?; + session + .input_sender + .send(RdpInputEvent::Close) + .map_err(|_| anyhow::anyhow!("session input channel is closed"))?; + Ok(Response::Ok) + } + Request::Mouse { session_id, action } => { + let session = daemon.session(&session_id).await?; + apply_mouse(&session, action).await?; + Ok(Response::Ok) + } + Request::Keyboard { session_id, action } => { + let session = daemon.session(&session_id).await?; + apply_keyboard(&session, action).await?; + Ok(Response::Ok) + } + Request::Resize { + session_id, + width, + height, + scale, + } => { + let session = daemon.session(&session_id).await?; + session + .input_sender + .send(RdpInputEvent::Resize { + width, + height, + scale_factor: scale, + physical_size: None, + }) + .map_err(|_| anyhow::anyhow!("session input channel is closed"))?; + Ok(Response::Ok) + } + Request::WaitFrame { + session_id, + timeout_ms, + after_frame, + } => { + let session = daemon.session(&session_id).await?; + session + .wait_frame(Duration::from_millis(timeout_ms), after_frame) + .await?; + Ok(Response::Ok) + } + Request::Screenshot { session_id } => { + let session = daemon.session(&session_id).await?; + let png = session.screenshot_png().await?; + Ok(Response::Screenshot { png }) + } + Request::MousePosition { session_id } => { + let session = daemon.session(&session_id).await?; + let pos = session.input_database.lock().await.mouse_position(); + Ok(Response::MousePosition { x: pos.x, y: pos.y }) + } + Request::DumpProperties { session_id } => { + let session = daemon.session(&session_id).await?; + Ok(Response::Properties { + entries: session.dump_properties().await, + }) + } + Request::SetProperty { session_id, key, value } => { + let session = daemon.session(&session_id).await?; + session.set_property(&key, &value).await?; + Ok(Response::Ok) + } + } +} + +async fn apply_mouse(session: &SessionEntry, action: MouseAction) -> anyhow::Result<()> { + match action { + MouseAction::Move { x, y } => { + session + .apply_operations([Operation::MouseMove(MousePosition { x, y })]) + .await + } + MouseAction::Click { button, x, y } => { + let mut ops = Vec::new(); + if let (Some(x), Some(y)) = (x, y) { + ops.push(Operation::MouseMove(MousePosition { x, y })); + } + let b = ironrdp::input::MouseButton::from(button); + ops.push(Operation::MouseButtonPressed(b)); + ops.push(Operation::MouseButtonReleased(b)); + session.apply_operations(ops).await + } + MouseAction::Down { button } => { + session + .apply_operations([Operation::MouseButtonPressed(ironrdp::input::MouseButton::from(button))]) + .await + } + MouseAction::Up { button } => { + session + .apply_operations([Operation::MouseButtonReleased(ironrdp::input::MouseButton::from( + button, + ))]) + .await + } + MouseAction::Wheel { units, horizontal } => { + session + .apply_operations([Operation::WheelRotations(WheelRotations { + is_vertical: !horizontal, + rotation_units: units, + })]) + .await + } + MouseAction::Position => Ok(()), + } +} + +async fn apply_keyboard(session: &SessionEntry, action: KeyboardAction) -> anyhow::Result<()> { + match action { + KeyboardAction::Key { scancode, release } => { + let op = if release { + Operation::KeyReleased(Scancode::from_u16(scancode)) + } else { + Operation::KeyPressed(Scancode::from_u16(scancode)) + }; + session.apply_operations([op]).await + } + KeyboardAction::Text { text } => { + let ops: Vec<_> = text + .chars() + .flat_map(|c| [Operation::UnicodeKeyPressed(c), Operation::UnicodeKeyReleased(c)]) + .collect(); + session.apply_operations(ops).await + } + KeyboardAction::Shortcut { scancodes } => { + let mut ops = Vec::with_capacity(scancodes.len() * 2); + for s in &scancodes { + ops.push(Operation::KeyPressed(Scancode::from_u16(*s))); + } + for s in scancodes.iter().rev() { + ops.push(Operation::KeyReleased(Scancode::from_u16(*s))); + } + session.apply_operations(ops).await + } + KeyboardAction::ReleaseAll => { + let mut db = session.input_database.lock().await; + let events = db.release_all(); + if !events.is_empty() { + session + .input_sender + .send(RdpInputEvent::FastPath(events)) + .map_err(|_| anyhow::anyhow!("session input channel is closed"))?; + } + Ok(()) + } + } +} + +// --- client mode ------------------------------------------------------------- + +struct DaemonLogging { + log_level: LogLevel, + log_filter: Option, + log_file: Option, +} + +async fn run_client_command( + endpoint: Endpoint, + spawn_daemon: bool, + daemon_logging: DaemonLogging, + command: Command, +) -> anyhow::Result<()> { + let (request, screenshot_output) = command_to_request(command).await?; + + let response = match send_request(&endpoint, &request).await { + Ok(r) => r, + Err(error) if spawn_daemon => { + debug!(%error, "Daemon request failed, spawning daemon"); + spawn_daemon_process(&endpoint, &daemon_logging).context("spawn daemon")?; + wait_for_daemon(&endpoint).await.context("wait for daemon")?; + send_request(&endpoint, &request).await? + } + Err(error) => return Err(error), + }; + + print_response(response, screenshot_output).await +} + +async fn command_to_request(command: Command) -> anyhow::Result<(Request, Option)> { + Ok(match command { + Command::Daemon => anyhow::bail!("daemon cannot be used as a client command"), + Command::Connect(connect) => { + let label = connect.label.clone(); + let rdp_content = connect.to_rdp_content()?; + (Request::Connect { rdp_content, label }, None) + } + Command::Sessions => (Request::Sessions, None), + Command::Status(SessionArg { session }) => (Request::Status { session_id: session }, None), + Command::Disconnect(arg) => ( + Request::Disconnect { + session_id: arg.session, + }, + None, + ), + Command::Mouse(cmd) => ( + Request::Mouse { + session_id: cmd.session, + action: cli_mouse_to_ipc(cmd.action), + }, + None, + ), + Command::Keyboard(cmd) => ( + Request::Keyboard { + session_id: cmd.session, + action: cli_keyboard_to_ipc(cmd.action), + }, + None, + ), + Command::Resize(cmd) => ( + Request::Resize { + session_id: cmd.session, + width: cmd.width, + height: cmd.height, + scale: cmd.scale, + }, + None, + ), + Command::WaitFrame(WaitFrameCommand { + session, + timeout_ms, + after_frame, + }) => ( + Request::WaitFrame { + session_id: session, + timeout_ms, + after_frame, + }, + None, + ), + Command::Screenshot(ScreenshotCommand { session, output }) => { + (Request::Screenshot { session_id: session }, Some(output)) + } + Command::DumpProperties(arg) => ( + Request::DumpProperties { + session_id: arg.session, + }, + None, + ), + Command::SetProperty(SetPropertyCommand { session, key, value }) => ( + Request::SetProperty { + session_id: session, + key, + value, + }, + None, + ), + }) +} + +fn cli_mouse_to_ipc(a: CliMouseAction) -> MouseAction { + match a { + CliMouseAction::Move { x, y } => MouseAction::Move { x, y }, + CliMouseAction::Click { button, x, y } => MouseAction::Click { + button: button.into(), + x, + y, + }, + CliMouseAction::Down { button } => MouseAction::Down { button: button.into() }, + CliMouseAction::Up { button } => MouseAction::Up { button: button.into() }, + CliMouseAction::Wheel { units, horizontal } => MouseAction::Wheel { units, horizontal }, + CliMouseAction::Position => MouseAction::Position, + } +} + +fn cli_keyboard_to_ipc(a: CliKeyboardAction) -> KeyboardAction { + match a { + CliKeyboardAction::Key { scancode, release } => KeyboardAction::Key { scancode, release }, + CliKeyboardAction::Text { text } => KeyboardAction::Text { text }, + CliKeyboardAction::Shortcut { scancodes } => KeyboardAction::Shortcut { scancodes }, + CliKeyboardAction::ReleaseAll => KeyboardAction::ReleaseAll, + } +} + +async fn send_request(endpoint: &Endpoint, request: &Request) -> anyhow::Result { + let mut stream = connect_endpoint(endpoint).await?; + write_frame(&mut stream, request).await?; + let response: Response = read_frame(&mut stream).await?; + if let Response::Error { message } = &response { + anyhow::bail!("{}", message); + } + Ok(response) +} + +async fn connect_endpoint(endpoint: &Endpoint) -> anyhow::Result> { + match endpoint { + Endpoint::Pipe(name) => connect_pipe(name).await, + Endpoint::Unix(path) => connect_unix(path.as_path()).await, + } +} + +#[cfg(windows)] +async fn connect_pipe(name: &str) -> anyhow::Result> { + use tokio::net::windows::named_pipe::ClientOptions; + + let path = format!(r"\\.\pipe\{name}"); + let stream = ClientOptions::new() + .open(&path) + .with_context(|| format!("failed to open named pipe {path}"))?; + Ok(Box::new(stream)) +} + +#[cfg(not(windows))] +async fn connect_pipe(_name: &str) -> anyhow::Result> { + anyhow::bail!("named pipe endpoints are only supported on Windows") +} + +#[cfg(unix)] +async fn connect_unix(path: &Path) -> anyhow::Result> { + let stream = tokio::net::UnixStream::connect(path) + .await + .with_context(|| format!("failed to connect {}", path.display()))?; + Ok(Box::new(stream)) +} + +#[cfg(not(unix))] +async fn connect_unix(_path: &Path) -> anyhow::Result> { + anyhow::bail!("unix endpoints are only supported on Unix-like systems") +} + +fn spawn_daemon_process(endpoint: &Endpoint, daemon_logging: &DaemonLogging) -> anyhow::Result<()> { + use clap::ValueEnum as _; + + let exe = std::env::current_exe().context("current executable")?; + let mut command = std::process::Command::new(exe); + command + .arg("--endpoint") + .arg(endpoint.to_string()) + .arg("--log-level") + .arg( + daemon_logging + .log_level + .to_possible_value() + .expect("log level") + .get_name(), + ); + + if let Some(log_filter) = &daemon_logging.log_filter { + command.arg("--log-filter").arg(log_filter); + } + if let Some(log_file) = &daemon_logging.log_file { + command.arg("--log-file").arg(log_file); + } + + command + .arg("--no-spawn-daemon") + .arg("daemon") + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .context("spawn daemon")?; + Ok(()) +} + +async fn wait_for_daemon(endpoint: &Endpoint) -> anyhow::Result<()> { + for _ in 0..50 { + if send_request(endpoint, &Request::Health).await.is_ok() { + return Ok(()); + } + tokio::time::sleep(Duration::from_millis(100)).await; + } + anyhow::bail!("daemon did not become ready") +} + +async fn print_response(response: Response, screenshot_output: Option) -> anyhow::Result<()> { + match response { + Response::Ok | Response::Health => Ok(()), + Response::Error { message } => anyhow::bail!("{message}"), + Response::Connect { session_id } => { + println!("{session_id}"); + Ok(()) + } + Response::Sessions { sessions } => { + for s in sessions { + println!( + "{} {:11} {}x{} seq={} label={} err={}", + s.session_id, + s.status.as_str(), + s.width.unwrap_or(0), + s.height.unwrap_or(0), + s.frame_sequence, + s.label.as_deref().unwrap_or("-"), + s.last_error.as_deref().unwrap_or("-"), + ); + } + Ok(()) + } + Response::Status { summary: s } => { + println!( + "{} {:11} {}x{} seq={} label={} err={}", + s.session_id, + s.status.as_str(), + s.width.unwrap_or(0), + s.height.unwrap_or(0), + s.frame_sequence, + s.label.as_deref().unwrap_or("-"), + s.last_error.as_deref().unwrap_or("-"), + ); + Ok(()) + } + Response::MousePosition { x, y } => { + println!("{x} {y}"); + Ok(()) + } + Response::Screenshot { png } => { + let path = screenshot_output.context("missing screenshot output path")?; + std::fs::write(&path, &png).with_context(|| format!("write {}", path.display()))?; + println!("{}", path.display()); + Ok(()) + } + Response::Properties { entries } => { + for entry in entries { + println!("{}={} # {}", entry.key, entry.value, entry.description); + } + Ok(()) + } + } +} diff --git a/crates/ironrdp-agent/src/redact.rs b/crates/ironrdp-agent/src/redact.rs new file mode 100644 index 000000000..6597ec3df --- /dev/null +++ b/crates/ironrdp-agent/src/redact.rs @@ -0,0 +1,113 @@ +//! Credential redaction for any property value surfaced over IPC or logs. +//! +//! [`dump_properties`](crate::SessionEntry::dump_properties) and any other path +//! that ships `PropertySet` values out of the daemon **must** filter values +//! through [`redact_value`] first. +//! +//! The agent is consumed by LLMs: leaking `ClearTextPassword`, gateway +//! credentials, or auth tokens through `DumpProperties`, stdout, or tracing +//! would prompt-inject the model with live secrets. + +/// Placeholder substituted for sensitive property values before they leave the +/// daemon. +pub const REDACTED: &str = "***REDACTED***"; + +/// Returns `true` if a property key holds a credential or token whose value +/// must never be exposed verbatim outside the daemon. +/// +/// Matches both known canonical `.rdp` credential keys (e.g. `ClearTextPassword`, +/// `GatewayPassword`) and any key that contains a credential-shaped substring +/// (`password`, `secret`, `token`, `credential`, `cookie`, `apikey`, +/// `passphrase`, `auth`) — case-insensitively, to defend against +/// non-canonical / custom keys an LLM might inject via `set-property`. +pub fn is_sensitive_key(key: &str) -> bool { + let lower = key.to_ascii_lowercase(); + const NEEDLES: &[&str] = &[ + "password", + "passphrase", + "secret", + "token", + "credential", + "cookie", + "apikey", + "api_key", + "privatekey", + "private_key", + ]; + if NEEDLES.iter().any(|needle| lower.contains(needle)) { + return true; + } + // Catch-all for keys that don't follow the substring pattern but are + // known to carry credential material. + matches!( + lower.as_str(), + "cleartextpassword" | "gatewayaccesstoken" | "kdcproxyclientcertificate" | "pcb" + ) +} + +/// Returns the value to emit for a given key: either the original or the +/// [`REDACTED`] placeholder when [`is_sensitive_key`] holds. +pub fn redact_value<'a>(key: &str, value: &'a str) -> &'a str { + if is_sensitive_key(key) { REDACTED } else { value } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn known_credential_keys_are_sensitive() { + for key in [ + "ClearTextPassword", + "cleartextpassword", + "Password", + "password 51", + "GatewayPassword", + "ProxyPassword", + "GatewayAccessToken", + "KdcProxyClientCertificate", + "winposstr-secret", + "my_custom_api_key", + "AuthToken", + "OAuthCredential", + "sessionCookie", + "private_key_pem", + "pcb", + ] { + assert!(is_sensitive_key(key), "{key} should be sensitive"); + } + } + + #[test] + fn non_credential_keys_are_not_sensitive() { + for key in [ + "full address", + "username", + "domain", + "desktopwidth", + "desktopheight", + "gatewayhostname", + "gatewayusername", + "agent:state", + "agent:current_width", + "compression", + "audiomode", + ] { + assert!(!is_sensitive_key(key), "{key} should not be sensitive"); + } + } + + #[test] + fn redact_value_passes_through_safe_keys() { + assert_eq!(redact_value("username", "alice"), "alice"); + assert_eq!(redact_value("desktopwidth", "1920"), "1920"); + } + + #[test] + fn redact_value_masks_sensitive_keys() { + assert_eq!(redact_value("ClearTextPassword", "hunter2"), REDACTED); + assert_eq!(redact_value("GatewayPassword", "hunter2"), REDACTED); + assert_eq!(redact_value("GatewayAccessToken", "eyJhbGc..."), REDACTED); + assert_eq!(redact_value("custom_api_key", "abc"), REDACTED); + } +} diff --git a/crates/ironrdp-agent/tests/ipc.rs b/crates/ironrdp-agent/tests/ipc.rs new file mode 100644 index 000000000..5f09b1e40 --- /dev/null +++ b/crates/ironrdp-agent/tests/ipc.rs @@ -0,0 +1,191 @@ +//! IPC framing integration test: round-trip every Request and Response over +//! an in-memory duplex stream and assert the value is preserved end-to-end. + +#![expect(unused_crate_dependencies, reason = "agent crate brings in many transitive deps")] + +use ironrdp_agent::ipc::{ + KeyboardAction, MouseAction, MouseButton, PropertyEntry, Request, Response, SessionStatus, SessionSummary, + read_frame, write_frame, +}; + +async fn roundtrip_request(req: Request) -> Request { + let (mut a, mut b) = tokio::io::duplex(1024 * 1024); + let writer = tokio::spawn(async move { + write_frame(&mut a, &req).await.expect("write"); + req + }); + let received: Request = read_frame(&mut b).await.expect("read"); + let original = writer.await.expect("writer task"); + // Re-encode for byte equality. + let a = ironrdp_core::encode_vec(&original).expect("encode a"); + let b = ironrdp_core::encode_vec(&received).expect("encode b"); + assert_eq!(a, b); + received +} + +async fn roundtrip_response(resp: Response) { + let (mut a, mut b) = tokio::io::duplex(1024 * 1024); + let original = resp.clone(); + let writer = tokio::spawn(async move { + write_frame(&mut a, &resp).await.expect("write"); + }); + let received: Response = read_frame(&mut b).await.expect("read"); + writer.await.expect("writer task"); + let a = ironrdp_core::encode_vec(&original).expect("encode a"); + let b = ironrdp_core::encode_vec(&received).expect("encode b"); + assert_eq!(a, b); +} + +#[tokio::test] +async fn all_request_variants_round_trip() { + roundtrip_request(Request::Health).await; + roundtrip_request(Request::Sessions).await; + roundtrip_request(Request::Connect { + rdp_content: "full address:s:host\nusername:s:bob".to_owned(), + label: Some("primary".to_owned()), + }) + .await; + roundtrip_request(Request::Status { session_id: None }).await; + roundtrip_request(Request::Status { + session_id: Some("abc".to_owned()), + }) + .await; + roundtrip_request(Request::Disconnect { + session_id: "abc".to_owned(), + }) + .await; + roundtrip_request(Request::Mouse { + session_id: "s".to_owned(), + action: MouseAction::Move { x: 10, y: 20 }, + }) + .await; + roundtrip_request(Request::Mouse { + session_id: "s".to_owned(), + action: MouseAction::Click { + button: MouseButton::Right, + x: Some(1), + y: Some(2), + }, + }) + .await; + roundtrip_request(Request::Mouse { + session_id: "s".to_owned(), + action: MouseAction::Wheel { + units: -3, + horizontal: true, + }, + }) + .await; + roundtrip_request(Request::Keyboard { + session_id: "s".to_owned(), + action: KeyboardAction::Text { + text: "hello".to_owned(), + }, + }) + .await; + roundtrip_request(Request::Keyboard { + session_id: "s".to_owned(), + action: KeyboardAction::Shortcut { + scancodes: vec![0x1d, 0x2e], + }, + }) + .await; + roundtrip_request(Request::Keyboard { + session_id: "s".to_owned(), + action: KeyboardAction::ReleaseAll, + }) + .await; + roundtrip_request(Request::Resize { + session_id: "s".to_owned(), + width: 1024, + height: 768, + scale: 125, + }) + .await; + roundtrip_request(Request::WaitFrame { + session_id: "s".to_owned(), + timeout_ms: 5000, + after_frame: Some(42), + }) + .await; + roundtrip_request(Request::Screenshot { + session_id: "s".to_owned(), + }) + .await; + roundtrip_request(Request::MousePosition { + session_id: "s".to_owned(), + }) + .await; + roundtrip_request(Request::DumpProperties { + session_id: "s".to_owned(), + }) + .await; + roundtrip_request(Request::SetProperty { + session_id: "s".to_owned(), + key: "desktopwidth".to_owned(), + value: "1024".to_owned(), + }) + .await; +} + +#[tokio::test] +async fn all_response_variants_round_trip() { + roundtrip_response(Response::Ok).await; + roundtrip_response(Response::Health).await; + roundtrip_response(Response::Error { + message: "boom".to_owned(), + }) + .await; + roundtrip_response(Response::Connect { + session_id: "abc".to_owned(), + }) + .await; + roundtrip_response(Response::Sessions { + sessions: vec![ + SessionSummary { + session_id: "a".to_owned(), + label: None, + status: SessionStatus::Connecting, + width: None, + height: None, + frame_sequence: 0, + mouse_x: 0, + mouse_y: 0, + last_error: None, + }, + SessionSummary { + session_id: "b".to_owned(), + label: Some("p".to_owned()), + status: SessionStatus::Failed, + width: Some(1920), + height: Some(1080), + frame_sequence: 99, + mouse_x: 10, + mouse_y: 20, + last_error: Some("nope".to_owned()), + }, + ], + }) + .await; + roundtrip_response(Response::Screenshot { + png: (0..256u32) + .map(|i| u8::try_from(i & 0xff).expect("masked to 0..=255")) + .collect(), + }) + .await; + roundtrip_response(Response::Properties { + entries: vec![ + PropertyEntry { + key: "k1".to_owned(), + value: "v1".to_owned(), + description: "d1".to_owned(), + }, + PropertyEntry { + key: "agent:state".to_owned(), + value: "connected".to_owned(), + description: "Current session state".to_owned(), + }, + ], + }) + .await; +}