param( [string]$BackendApiBase = "http://192.168.200.61:8080/api/v1", [string]$BackendWsBase = "ws://192.168.200.61:8080/api/v1/gateway/ws", [string]$DockerSshAlias = "docker-test", [string]$WorkerContainerName = "rap_worker_smoke", [string]$WorkerImage = "rap-rdp-worker:stage5-drive-visible", [string]$ResourceName = "Windows Smoke Default Resource", [string]$Email = "windows-smoke@example.local", [string]$Password = "SmokePass!123", [string]$OutputFrame = "artifacts/stage5-drive-visibility-frame.bmp" ) Set-StrictMode -Version Latest $ErrorActionPreference = "Stop" function Write-SmokeLog([string]$Message) { Write-Host "[$(Get-Date -Format o)] $Message" } function Invoke-RemoteSqlScalar([string]$Sql) { $encoded = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($Sql)) $output = ssh $DockerSshAlias "printf '%s' '$encoded' | base64 -d | docker exec -i rap_postgres psql -U rap_user -d remote_access_platform -t -A -v ON_ERROR_STOP=1" return (($output | Select-Object -Last 1) -as [string]).Trim() } function Invoke-Api([string]$Method, [string]$Path, [object]$Body = $null) { $uri = "$BackendApiBase/$Path" if ($null -eq $Body) { return Invoke-RestMethod -Method $Method -Uri $uri } return Invoke-RestMethod -Method $Method -Uri $uri -ContentType "application/json" -Body ($Body | ConvertTo-Json -Depth 16) } function Restart-SmokeWorker { $cmd = "docker rm -f $WorkerContainerName 2>/dev/null || true; docker run -d --name $WorkerContainerName --network host -e RDP_WORKER_ID=rdp-worker-1 -e RDP_WORKER_REDIS_HOST=127.0.0.1 -e RDP_WORKER_REDIS_PORT=6379 -e RDP_WORKER_TRANSFER_ROOT=/tmp/rap-rdp-worker-transfers -e RDP_WORKER_CAPABILITIES=adaptive-quality,dirty-rects,clipboard,file-transfer $WorkerImage" $output = ssh $DockerSshAlias $cmd Write-SmokeLog "worker restart output=$($output -join ' ')" Start-Sleep -Seconds 5 } function New-GatewayWebSocket([string]$AttachToken) { $ws = [Net.WebSockets.ClientWebSocket]::new() $uri = [Uri]::new("${BackendWsBase}?attach_token=$([Uri]::EscapeDataString($AttachToken))") $null = $ws.ConnectAsync($uri, [Threading.CancellationToken]::None).GetAwaiter().GetResult() return $ws } function Receive-Envelope([Net.WebSockets.ClientWebSocket]$WebSocket, [int]$TimeoutMilliseconds = 30000) { $buffer = [byte[]]::new(1024 * 1024) $segment = [ArraySegment[byte]]::new($buffer) $stream = [IO.MemoryStream]::new() while ($true) { $cts = [Threading.CancellationTokenSource]::new($TimeoutMilliseconds) try { $result = $WebSocket.ReceiveAsync($segment, $cts.Token).GetAwaiter().GetResult() } finally { $cts.Dispose() } if ($result.MessageType -eq [Net.WebSockets.WebSocketMessageType]::Close) { throw "websocket closed" } $stream.Write($buffer, 0, $result.Count) if ($result.EndOfMessage) { break } } return ([Text.Encoding]::UTF8.GetString($stream.ToArray()) | ConvertFrom-Json) } function Receive-EnvelopeOfType([Net.WebSockets.ClientWebSocket]$WebSocket, [string[]]$Types, [int]$TimeoutSeconds = 30) { $deadline = (Get-Date).AddSeconds($TimeoutSeconds) while ((Get-Date) -lt $deadline) { $envelope = Receive-Envelope -WebSocket $WebSocket -TimeoutMilliseconds 10000 if ($Types -contains [string]$envelope.type) { return $envelope } } throw "timed out waiting for envelope type: $($Types -join ',')" } function Send-Envelope([Net.WebSockets.ClientWebSocket]$WebSocket, [object]$Envelope) { $json = $Envelope | ConvertTo-Json -Depth 16 -Compress $bytes = [Text.Encoding]::UTF8.GetBytes($json) $null = $WebSocket.SendAsync([ArraySegment[byte]]::new($bytes), [Net.WebSockets.WebSocketMessageType]::Text, $true, [Threading.CancellationToken]::None).GetAwaiter().GetResult() } function Wait-SessionActive([Net.WebSockets.ClientWebSocket]$WebSocket) { $deadline = (Get-Date).AddSeconds(90) while ((Get-Date) -lt $deadline) { $envelope = Receive-Envelope -WebSocket $WebSocket -TimeoutMilliseconds 30000 Write-SmokeLog "ws event type=$($envelope.type) state=$($envelope.payload.state)" if ($envelope.type -eq "session.state" -and $envelope.payload.state -eq "active") { return } } throw "session did not become active" } function Send-Key([Net.WebSockets.ClientWebSocket]$WebSocket, [int]$ScanCode, [bool]$Extended = $false) { Send-Envelope $WebSocket @{ type = "input"; payload = @{ kind = "keyboard"; action = "key_down"; scan_code = $ScanCode; is_extended = $Extended } } Start-Sleep -Milliseconds 60 Send-Envelope $WebSocket @{ type = "input"; payload = @{ kind = "keyboard"; action = "key_up"; scan_code = $ScanCode; is_extended = $Extended } } } function Send-Chord([Net.WebSockets.ClientWebSocket]$WebSocket, [int]$ModifierScanCode, [bool]$ModifierExtended, [int]$KeyScanCode) { Send-Envelope $WebSocket @{ type = "input"; payload = @{ kind = "keyboard"; action = "key_down"; scan_code = $ModifierScanCode; is_extended = $ModifierExtended } } Start-Sleep -Milliseconds 80 Send-Key $WebSocket $KeyScanCode $false Start-Sleep -Milliseconds 80 Send-Envelope $WebSocket @{ type = "input"; payload = @{ kind = "keyboard"; action = "key_up"; scan_code = $ModifierScanCode; is_extended = $ModifierExtended } } } function Upload-TextFile([Net.WebSockets.ClientWebSocket]$WebSocket) { $bytes = [Text.Encoding]::UTF8.GetBytes("Stage 5.1.1 RAP_Transfers visible file`r`nрусский текст`r`n") $transferId = [guid]::NewGuid().ToString("D") Send-Envelope $WebSocket @{ type = "file_upload.start" payload = @{ direction = "client_to_server" transfer_id = $transferId file_name = "stage5-upload-text.txt" file_size = [int64]$bytes.Length total_chunks = 1 content_hash = "" } } $started = Receive-EnvelopeOfType $WebSocket @("file_upload.progress", "file_upload.blocked") 20 if ($started.type -ne "file_upload.progress") { throw "upload start was blocked" } Send-Envelope $WebSocket @{ type = "file_upload.chunk" payload = @{ direction = "client_to_server" transfer_id = $transferId chunk_index = 0 offset = 0 chunk_size = $bytes.Length chunk_bytes = [Convert]::ToBase64String($bytes) } } $done = Receive-EnvelopeOfType $WebSocket @("file_upload.progress", "file_upload.blocked") 20 Write-SmokeLog "upload result type=$($done.type) status=$($done.payload.status)" if ($done.type -ne "file_upload.progress" -or $done.payload.status -ne "completed") { throw "upload did not complete" } } function Write-BgraFrameToBmp([string]$SessionId, [string]$Path) { $state = $null $data = $null $width = 0 $height = 0 $imageSize = 0 $deadline = (Get-Date).AddSeconds(20) do { $json = ssh $DockerSshAlias "docker exec rap_redis redis-cli --raw GET live:session:$SessionId" if ($json) { $state = ($json -join "`n") | ConvertFrom-Json $width = [int]$state.render_width $height = [int]$state.render_height $stride = $width * 4 $imageSize = $stride * $height if ($width -gt 0 -and $height -gt 0 -and -not [string]::IsNullOrWhiteSpace([string]$state.render_frame_data)) { $data = [Convert]::FromBase64String([string]$state.render_frame_data) if ($data.Length -ge $imageSize) { break } Write-SmokeLog "waiting for full framebuffer in live state bytes=$($data.Length) expected=$imageSize sequence=$($state.render_frame_sequence)" } } Start-Sleep -Milliseconds 500 } while ((Get-Date) -lt $deadline) if ($null -eq $data -or $data.Length -lt $imageSize) { $actualBytes = if ($null -eq $data) { 0 } else { $data.Length } throw "frame data too small or unavailable bytes=$actualBytes expected=$imageSize" } New-Item -ItemType Directory -Force -Path (Split-Path -Parent $Path) | Out-Null $fs = [IO.File]::Open($Path, [IO.FileMode]::Create, [IO.FileAccess]::Write) try { $writer = [IO.BinaryWriter]::new($fs) $fileSize = 14 + 40 + $imageSize $writer.Write([byte][char]'B') $writer.Write([byte][char]'M') $writer.Write([uint32]$fileSize) $writer.Write([uint16]0) $writer.Write([uint16]0) $writer.Write([uint32]54) $writer.Write([uint32]40) $writer.Write([int32]$width) $writer.Write([int32](-$height)) $writer.Write([uint16]1) $writer.Write([uint16]32) $writer.Write([uint32]0) $writer.Write([uint32]$imageSize) $writer.Write([int32]2835) $writer.Write([int32]2835) $writer.Write([uint32]0) $writer.Write([uint32]0) $writer.Write($data, 0, $imageSize) } finally { $fs.Dispose() } Write-SmokeLog "wrote frame $Path" } Restart-SmokeWorker $login = Invoke-Api Post "auth/login" @{ email = $Email password = $Password trust_device = $true device_fingerprint = "stage5-drive-visibility" device_label = "stage5-drive-visibility" } $userId = [string]$login.user.id if ([string]::IsNullOrWhiteSpace($userId)) { $userId = [string]$login.user.ID } $deviceId = [string]$login.device.id if ([string]::IsNullOrWhiteSpace($deviceId)) { $deviceId = [string]$login.device.ID } $escapedResourceName = $ResourceName.Replace("'", "''") $resourceId = Invoke-RemoteSqlScalar "SELECT id::text FROM resources WHERE name = '$escapedResourceName' LIMIT 1;" $null = Invoke-RemoteSqlScalar "UPDATE resource_policies SET file_transfer_mode = 'client_to_server', file_transfer_enabled = TRUE, clipboard_mode = 'client_to_server', clipboard_enabled = TRUE, updated_at = NOW() WHERE resource_id = '$resourceId'; SELECT 'ok';" $session = Invoke-Api Post "sessions" @{ resource_id = $resourceId user_id = $userId device_id = $deviceId } $sessionId = [string]$session.session.id if ([string]::IsNullOrWhiteSpace($sessionId)) { $sessionId = [string]$session.session.ID } $attachToken = [string]$session.attach_token.token Write-SmokeLog "visibility session=$sessionId" $ws = New-GatewayWebSocket $attachToken try { Wait-SessionActive $ws Upload-TextFile $ws Start-Sleep -Seconds 2 Send-Envelope $ws @{ type = "clipboard"; payload = @{ direction = "client_to_server"; text = "\\tsclient\RAP_Transfers\stage5-upload-text.txt"; sequence_id = [DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds(); origin = "visibility-smoke"; content_hash = "" } } Start-Sleep -Seconds 1 Send-Chord $ws 91 $true 19 Start-Sleep -Seconds 1 Send-Chord $ws 29 $false 47 Start-Sleep -Milliseconds 300 Send-Key $ws 28 $false Start-Sleep -Seconds 5 Write-BgraFrameToBmp $sessionId $OutputFrame } finally { $ws.Dispose() try { Invoke-Api Post "sessions/$sessionId/terminate" @{ user_id = $userId; reason = "stage5_drive_visibility_cleanup" } | Out-Null } catch { Write-SmokeLog "terminate skipped: $($_.Exception.Message)" } }