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]$ResourceName = "P3.3 Secret RDP Resource", [string]$UserEmail = "windows-smoke@example.local", [ValidateSet("disabled", "client_to_server", "server_to_client", "bidirectional")] [string]$AllowMode = "server_to_client", [ValidateSet("backend_gateway", "direct_worker_wss")] [string]$Transport = "backend_gateway", [switch]$ExpectBlocked, [ValidateSet("none", "detach", "takeover_old_controller", "worker_failure")] [string]$LifecycleScenario = "none", [string]$OutputDirectory = "artifacts/stage5-2-download-smoke" ) Set-StrictMode -Version Latest $ErrorActionPreference = "Stop" Add-Type -TypeDefinition @" using System.Net.Security; using System.Security.Cryptography.X509Certificates; public static class RapSmokeTls { public static bool AcceptAll(object sender, X509Certificate certificate, X509Chain chain, SslPolicyErrors sslPolicyErrors) { return true; } } "@ function Write-Log { param([string]$Message) Write-Host "[$(Get-Date -Format HH:mm:ss)] $Message" } function Format-ExceptionChain { param([System.Exception]$Exception) $parts = @() $current = $Exception while ($current -ne $null) { $parts += "$($current.GetType().FullName): $($current.Message)" $current = $current.InnerException } return ($parts -join " -> ") } function Invoke-Remote { param([string]$Command) ssh $DockerSshAlias $Command } function Invoke-DbRows { param([string]$Sql) $encoded = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($Sql)) $command = "printf '%s' '$encoded' | base64 -d | docker exec -i rap_postgres psql -U rap_user -d remote_access_platform -t -A -F `"|`" -v ON_ERROR_STOP=1" @(Invoke-Remote -Command $command) } function Invoke-Api { param( [ValidateSet("Get", "Post")] [string]$Method, [string]$Path, [object]$Body = $null ) $uri = "$BackendApiBase/$Path".Replace("//sessions", "/sessions") if ($Body -ne $null) { return Invoke-RestMethod -Method $Method -Uri $uri -ContentType "application/json" -Body ($Body | ConvertTo-Json -Depth 16) } return Invoke-RestMethod -Method $Method -Uri $uri } function Send-WsJson { param( [System.Net.WebSockets.ClientWebSocket]$Socket, [object]$Value ) $json = $Value | ConvertTo-Json -Depth 20 -Compress $bytes = [Text.Encoding]::UTF8.GetBytes($json) $segment = [ArraySegment[byte]]::new($bytes) [void]$Socket.SendAsync($segment, [System.Net.WebSockets.WebSocketMessageType]::Text, $true, [Threading.CancellationToken]::None).GetAwaiter().GetResult() } function Receive-WsJson { param( [System.Net.WebSockets.ClientWebSocket]$Socket, [int]$TimeoutSeconds = 30 ) $cts = [Threading.CancellationTokenSource]::new([TimeSpan]::FromSeconds($TimeoutSeconds)) try { while ($true) { $buffer = New-Object byte[] 65536 $stream = [IO.MemoryStream]::new() do { $segment = [ArraySegment[byte]]::new($buffer) $result = $Socket.ReceiveAsync($segment, $cts.Token).GetAwaiter().GetResult() if ($result.MessageType -eq [System.Net.WebSockets.WebSocketMessageType]::Close) { throw "websocket closed by peer" } $stream.Write($buffer, 0, $result.Count) } while (-not $result.EndOfMessage) if ($result.MessageType -eq [System.Net.WebSockets.WebSocketMessageType]::Binary) { continue } $text = [Text.Encoding]::UTF8.GetString($stream.ToArray()) return $text | ConvertFrom-Json } } finally { $cts.Dispose() } } function Wait-DbValue { param( [string]$Sql, [string]$Expected, [int]$TimeoutSeconds = 90 ) $deadline = (Get-Date).AddSeconds($TimeoutSeconds) do { $value = (Invoke-DbRows -Sql $Sql | Select-Object -First 1) if ($value -eq $Expected) { return $value } Start-Sleep -Seconds 1 } while ((Get-Date) -lt $deadline) throw "Timed out waiting for DB value '$Expected'. Last value: '$value'" } function Wait-DbValueIn { param( [string]$Sql, [string[]]$Expected, [int]$TimeoutSeconds = 90 ) $deadline = (Get-Date).AddSeconds($TimeoutSeconds) do { $value = (Invoke-DbRows -Sql $Sql | Select-Object -First 1) if ($Expected -contains $value) { return $value } Start-Sleep -Seconds 1 } while ((Get-Date) -lt $deadline) throw "Timed out waiting for DB value in '$($Expected -join ',')'. Last value: '$value'" } function Wait-WorkerPath { param( [string]$SessionId, [int]$TimeoutSeconds = 90 ) $path = "/tmp/rap-rdp-worker-transfers/$SessionId/visible/ToClient" $deadline = (Get-Date).AddSeconds($TimeoutSeconds) do { $exists = Invoke-Remote -Command "docker exec $WorkerContainerName sh -lc 'test -d ""$path"" && echo yes || true'" | Select-Object -First 1 if ($exists -eq "yes") { return $path } Start-Sleep -Seconds 1 } while ((Get-Date) -lt $deadline) throw "Timed out waiting for worker ToClient path: $path" } function Wait-EnvelopeType { param( [System.Net.WebSockets.ClientWebSocket]$Socket, [string]$Type, [string]$FileName = "", [int]$TimeoutSeconds = 60 ) $deadline = (Get-Date).AddSeconds($TimeoutSeconds) do { $remaining = [Math]::Max(1, [int]($deadline - (Get-Date)).TotalSeconds) $message = Receive-WsJson -Socket $Socket -TimeoutSeconds $remaining if ($message.type -eq $Type) { if ($FileName -eq "" -or ($message.payload.file_name -eq $FileName)) { return $message } } } while ((Get-Date) -lt $deadline) throw "Timed out waiting for envelope type '$Type' file '$FileName'" } function Send-DownloadStartAndWaitOutcome { param( [System.Net.WebSockets.ClientWebSocket]$Socket, [string]$FileId, [int]$TimeoutSeconds = 30 ) $transferId = [guid]::NewGuid().ToString() Send-WsJson -Socket $Socket -Value @{ type = "file_download.start" payload = @{ direction = "server_to_client" transfer_id = $transferId file_id = $FileId } } $deadline = (Get-Date).AddSeconds($TimeoutSeconds) do { $remaining = [Math]::Max(1, [int]($deadline - (Get-Date)).TotalSeconds) try { $message = Receive-WsJson -Socket $Socket -TimeoutSeconds $remaining } catch { return [ordered]@{ type = "websocket.closed" transfer_id = $transferId reason = $_.Exception.Message } } switch ($message.type) { "file_download.blocked" { return [ordered]@{ type = [string]$message.type transfer_id = $transferId reason = [string]$message.payload.reason payload = $message.payload } } "file_download.failed" { return [ordered]@{ type = [string]$message.type transfer_id = $transferId reason = [string]$message.payload.reason payload = $message.payload } } "session.taken_over" { return [ordered]@{ type = [string]$message.type transfer_id = $transferId reason = "controller binding changed" payload = $message.payload } } "session.state" { $state = [string]$message.payload.state if ($state -eq "active" -or $state -eq "reconnecting") { continue } return [ordered]@{ type = [string]$message.type transfer_id = $transferId reason = $state payload = $message.payload } } "transport.closed" { return [ordered]@{ type = [string]$message.type transfer_id = $transferId reason = [string]$message.payload.reason payload = $message.payload } } "file_download.chunk" { return [ordered]@{ type = "unexpected_file_download.chunk" transfer_id = $transferId reason = "download chunk was delivered while scenario expected blocking" payload = $message.payload } } "file_download.completed" { return [ordered]@{ type = "unexpected_file_download.completed" transfer_id = $transferId reason = "download completed while scenario expected blocking" payload = $message.payload } } } } while ((Get-Date) -lt $deadline) return [ordered]@{ type = "timeout_no_download" transfer_id = $transferId reason = "no file download data was delivered before timeout" } } function Restart-WorkerContainer { param([string]$ContainerName) Write-Log "Starting worker container $ContainerName" Invoke-Remote -Command "docker start $ContainerName >/dev/null" | Out-Null Start-Sleep -Seconds 5 } function Download-AvailableFile { param( [System.Net.WebSockets.ClientWebSocket]$Socket, [object]$Available, [string]$Destination ) $transferId = [guid]::NewGuid().ToString() $fileId = [string]$Available.payload.file_id $fileName = [string]$Available.payload.file_name $expectedSize = [int64]$Available.payload.file_size $stream = [IO.File]::Open($Destination, [IO.FileMode]::Create, [IO.FileAccess]::Write, [IO.FileShare]::None) try { Write-Log "Starting download file=$fileName file_id=$fileId size=$expectedSize transfer=$transferId" Send-WsJson -Socket $Socket -Value @{ type = "file_download.start" payload = @{ direction = "server_to_client" transfer_id = $transferId file_id = $fileId } } $completed = $false while (-not $completed) { $message = Receive-WsJson -Socket $Socket -TimeoutSeconds 60 switch ($message.type) { "file_download.progress" { Write-Log "Download progress status=$($message.payload.status) received=$($message.payload.received) total=$($message.payload.total)" } "file_download.chunk" { if ([string]$message.payload.transfer_id -ne $transferId) { continue } $offset = [int64]$message.payload.offset if ($offset -ne $stream.Position) { throw "Invalid chunk offset. Expected $($stream.Position), got $offset" } $bytes = [Convert]::FromBase64String([string]$message.payload.chunk_bytes) $stream.Write($bytes, 0, $bytes.Length) $ackOffset = [int64]$stream.Position Send-WsJson -Socket $Socket -Value @{ type = "file_download.ack" payload = @{ direction = "server_to_client" transfer_id = $transferId offset = $ackOffset sequence = [int64]$message.payload.sequence } } } "file_download.completed" { if ([string]$message.payload.transfer_id -eq $transferId) { $completed = $true } } "file_download.failed" { throw "File download failed: $($message.payload | ConvertTo-Json -Compress)" } "file_download.blocked" { throw "File download blocked: $($message.payload | ConvertTo-Json -Compress)" } } } } finally { $stream.Dispose() } $actualSize = (Get-Item -LiteralPath $Destination).Length if ($actualSize -ne $expectedSize) { throw "Downloaded size mismatch for $fileName. Expected $expectedSize, got $actualSize" } return @{ transfer_id = $transferId file_name = $fileName expected_size = $expectedSize actual_size = $actualSize destination = $Destination } } New-Item -ItemType Directory -Force -Path $OutputDirectory | Out-Null $lookupSql = @" select u.id::text, d.id::text, r.id::text, coalesce(p.file_transfer_mode, 'disabled') from users u join devices d on d.user_id = u.id and d.trust_status = 'trusted' and d.revoked_at is null join resources r on r.name = '$($ResourceName.Replace("'", "''"))' left join resource_policies p on p.resource_id = r.id where u.email = '$($UserEmail.Replace("'", "''"))' order by d.created_at desc limit 1; "@ $row = Invoke-DbRows -Sql $lookupSql | Select-Object -First 1 if ([string]::IsNullOrWhiteSpace($row)) { throw "Could not find smoke user/device/resource for user=$UserEmail resource=$ResourceName" } $parts = $row -split "\|" $userId = $parts[0] $deviceId = $parts[1] $resourceId = $parts[2] $previousMode = $parts[3] Write-Log "Using user=$userId device=$deviceId resource=$resourceId previous_file_transfer_mode=$previousMode" $sessionId = "" $socket = $null $workerStoppedForScenario = $false try { Invoke-DbRows -Sql "update resource_policies set file_transfer_mode = '$AllowMode', updated_at = now() where resource_id = '$resourceId';" | Out-Null Write-Log "Set file_transfer_mode=$AllowMode" $activeRows = Invoke-DbRows -Sql "select id::text from remote_sessions where controller_user_id = '$userId' and state in ('starting','active','detached');" foreach ($active in $activeRows) { if (-not [string]::IsNullOrWhiteSpace($active)) { Write-Log "Terminating pre-existing smoke session $active" Invoke-Api -Method Post -Path "sessions/$active/terminate" -Body @{ user_id = $userId; reason = "stage5_2_download_smoke_cleanup" } | Out-Null } } $start = Invoke-Api -Method Post -Path "sessions/" -Body @{ resource_id = $resourceId user_id = $userId device_id = $deviceId } $sessionId = [string]$start.session.id $attachmentId = [string]$start.attachment.id $attachToken = [string]$start.attach_token.token Write-Log "Started session $sessionId attachment=$attachmentId" $socket = [System.Net.WebSockets.ClientWebSocket]::new() if ($Transport -eq "direct_worker_wss") { $callback = [Delegate]::CreateDelegate( [System.Net.Security.RemoteCertificateValidationCallback], [RapSmokeTls].GetMethod("AcceptAll")) $socket.Options.RemoteCertificateValidationCallback = $callback $candidate = @($start.data_plane.candidates | Where-Object { $_.type -eq "direct_worker_wss" } | Select-Object -First 1) if (-not $candidate -or [string]::IsNullOrWhiteSpace([string]$candidate.url)) { throw "Start response did not include direct_worker_wss candidate." } $token = [string]$start.data_plane.token $candidateUrl = [string]$candidate.url $separator = if ($candidateUrl.Contains("?")) { "&" } else { "?" } $wsUri = [Uri]::new("$candidateUrl$separator" + "data_plane_token=$([Uri]::EscapeDataString($token))&render_transport=binary_v1&color_mode=full_color") } else { $wsUri = [Uri]::new("${BackendWsBase}?attach_token=$([Uri]::EscapeDataString($attachToken))") } $redactedWsUri = [regex]::Replace($wsUri.ToString(), '(data_plane_token|attach_token)=([^&]+)', '$1=') Write-Log "Connecting websocket transport=$Transport uri=$redactedWsUri" try { [void]$socket.ConnectAsync($wsUri, [Threading.CancellationToken]::None).GetAwaiter().GetResult() } catch { Write-Log "WebSocket connect failed: $(Format-ExceptionChain -Exception $_.Exception)" throw } if ($Transport -eq "direct_worker_wss") { $attached = Receive-WsJson -Socket $socket -TimeoutSeconds 15 if ($attached.type -ne "data_plane.attached") { throw "Direct data-plane did not return data_plane.attached. Got: $($attached | ConvertTo-Json -Compress)" } } Write-Log "Connected websocket transport=$Transport" Wait-DbValue -Sql "select state from remote_sessions where id = '$sessionId';" -Expected "active" -TimeoutSeconds 120 | Out-Null Write-Log "Session is active" if ($LifecycleScenario -ne "none") { $toClientPath = Wait-WorkerPath -SessionId $sessionId -TimeoutSeconds 120 Write-Log "Worker ToClient path ready for lifecycle scenario: $toClientPath" $lifecycleName = "stage5-2-download-lifecycle.txt" $lifecycleContent = "Stage 5.2 lifecycle download guard`nscenario=$LifecycleScenario`n" $lifecycleBytes = [Text.Encoding]::UTF8.GetBytes($lifecycleContent) $lifecycleB64 = [Convert]::ToBase64String($lifecycleBytes) Invoke-Remote -Command "docker exec $WorkerContainerName sh -lc 'rm -f ""$toClientPath/$lifecycleName"" && printf %s $lifecycleB64 | base64 -d > ""$toClientPath/$lifecycleName"" && sync'" | Out-Null $workerLifecycleEvidence = Invoke-Remote -Command "docker exec $WorkerContainerName sh -lc 'cd ""$toClientPath"" && ls -l $lifecycleName && sha256sum $lifecycleName'" $workerLifecycleEvidence | Set-Content -LiteralPath (Join-Path $OutputDirectory "worker-lifecycle-file.txt") -Encoding UTF8 $available = Wait-EnvelopeType -Socket $socket -Type "file_download.available" -FileName $lifecycleName -TimeoutSeconds 90 $fileId = [string]$available.payload.file_id Write-Log "Lifecycle file is available file_id=$fileId scenario=$LifecycleScenario" $stateAfterAction = "" $outcome = $null switch ($LifecycleScenario) { "detach" { Write-Log "Detaching current attachment before attempting download" Invoke-Api -Method Post -Path "sessions/$sessionId/detach" -Body @{ attachment_id = $attachmentId user_id = $userId reason = "stage5_2_download_lifecycle_detach" } | Out-Null $stateAfterAction = Wait-DbValue -Sql "select state from remote_sessions where id = '$sessionId';" -Expected "detached" -TimeoutSeconds 60 $outcome = Send-DownloadStartAndWaitOutcome -Socket $socket -FileId $fileId -TimeoutSeconds 30 } "takeover_old_controller" { $takeoverDeviceId = Invoke-DbRows -Sql "select id::text from devices where user_id = '$userId' and trust_status = 'trusted' and revoked_at is null and id <> '$deviceId' order by created_at desc limit 1;" | Select-Object -First 1 if ([string]::IsNullOrWhiteSpace($takeoverDeviceId)) { throw "No second trusted device is available for takeover lifecycle proof." } Write-Log "Taking over session from second trusted device=$takeoverDeviceId" Invoke-Api -Method Post -Path "sessions/$sessionId/takeover" -Body @{ user_id = $userId device_id = $takeoverDeviceId reason = "stage5_2_download_lifecycle_takeover" } | Out-Null $stateAfterAction = Wait-DbValue -Sql "select state from remote_sessions where id = '$sessionId';" -Expected "active" -TimeoutSeconds 60 $outcome = Send-DownloadStartAndWaitOutcome -Socket $socket -FileId $fileId -TimeoutSeconds 30 } "worker_failure" { Write-Log "Stopping worker container to prove download cannot continue after worker failure" Invoke-Remote -Command "docker stop $WorkerContainerName >/dev/null" | Out-Null $workerStoppedForScenario = $true $stateAfterAction = Wait-DbValueIn -Sql "select state from remote_sessions where id = '$sessionId';" -Expected @("failed", "terminated") -TimeoutSeconds 180 $outcome = Send-DownloadStartAndWaitOutcome -Socket $socket -FileId $fileId -TimeoutSeconds 30 } } $acceptableOutcomes = @("file_download.blocked", "file_download.failed", "session.taken_over", "transport.closed", "websocket.closed") $pass = $acceptableOutcomes -contains [string]$outcome.type if ($LifecycleScenario -eq "worker_failure" -and ([string]$outcome.type) -eq "timeout_no_download" -and @("failed", "terminated") -contains $stateAfterAction) { $pass = $true } $result = [ordered]@{ session_id = $sessionId attachment_id = $attachmentId mode = $AllowMode transport = $Transport lifecycle_scenario = $LifecycleScenario state_after_action = $stateAfterAction file_id = $fileId outcome = $outcome worker_evidence_file = (Resolve-Path (Join-Path $OutputDirectory "worker-lifecycle-file.txt")).Path pass = $pass } $result | ConvertTo-Json -Depth 10 | Tee-Object -FilePath (Join-Path $OutputDirectory "lifecycle-result.json") if (-not $pass) { throw "Lifecycle scenario '$LifecycleScenario' failed: $($outcome | ConvertTo-Json -Compress)" } return } if ($ExpectBlocked) { $blockedTransferId = [guid]::NewGuid().ToString() Send-WsJson -Socket $socket -Value @{ type = "file_download.start" payload = @{ direction = "server_to_client" transfer_id = $blockedTransferId file_id = "blocked-file-id" } } $blocked = Wait-EnvelopeType -Socket $socket -Type "file_download.blocked" -TimeoutSeconds 30 $result = [ordered]@{ session_id = $sessionId mode = $AllowMode transport = $Transport transfer_id = $blockedTransferId blocked_reason = [string]$blocked.payload.reason pass = $true } $result | ConvertTo-Json -Depth 8 | Tee-Object -FilePath (Join-Path $OutputDirectory "blocked-result.json") return } $toClientPath = Wait-WorkerPath -SessionId $sessionId -TimeoutSeconds 120 Write-Log "Worker ToClient path ready: $toClientPath" $textName = "stage5-2-download-text.txt" $binaryName = "stage5-2-download-binary.bin" $textContent = "Stage 5.2 server-to-client download smoke`nрусский текст`n" $textBytes = [Text.Encoding]::UTF8.GetBytes($textContent) $textB64 = [Convert]::ToBase64String($textBytes) Invoke-Remote -Command "docker exec $WorkerContainerName sh -lc 'rm -f ""$toClientPath/$textName"" ""$toClientPath/$binaryName"" && printf %s $textB64 | base64 -d > ""$toClientPath/$textName"" && sync'" | Out-Null $workerTextEvidence = Invoke-Remote -Command "docker exec $WorkerContainerName sh -lc 'cd ""$toClientPath"" && ls -l $textName && sha256sum $textName'" $workerTextEvidence | Set-Content -LiteralPath (Join-Path $OutputDirectory "worker-text-file.txt") -Encoding UTF8 Write-Log "Worker text file evidence captured" $textAvailable = Wait-EnvelopeType -Socket $socket -Type "file_download.available" -FileName $textName -TimeoutSeconds 90 $textDestination = Join-Path $OutputDirectory $textName $textResult = Download-AvailableFile -Socket $socket -Available $textAvailable -Destination $textDestination Invoke-Remote -Command "docker exec $WorkerContainerName sh -lc 'head -c 4096 /dev/urandom > ""$toClientPath/$binaryName"" && sync'" | Out-Null $workerBinaryEvidence = Invoke-Remote -Command "docker exec $WorkerContainerName sh -lc 'cd ""$toClientPath"" && ls -l $binaryName && sha256sum $binaryName'" $workerBinaryEvidence | Set-Content -LiteralPath (Join-Path $OutputDirectory "worker-binary-file.txt") -Encoding UTF8 Write-Log "Worker binary file evidence captured" $binaryAvailable = Wait-EnvelopeType -Socket $socket -Type "file_download.available" -FileName $binaryName -TimeoutSeconds 90 $binaryDestination = Join-Path $OutputDirectory $binaryName $binaryResult = Download-AvailableFile -Socket $socket -Available $binaryAvailable -Destination $binaryDestination $localHashes = Get-FileHash -Algorithm SHA256 -LiteralPath $textDestination, $binaryDestination | Select-Object Path, Hash $localHashes | ConvertTo-Json -Depth 4 | Set-Content -LiteralPath (Join-Path $OutputDirectory "local-hashes.json") -Encoding UTF8 $result = [ordered]@{ session_id = $sessionId mode = $AllowMode transport = $Transport text = $textResult binary = $binaryResult worker_text_evidence_file = (Resolve-Path (Join-Path $OutputDirectory "worker-text-file.txt")).Path worker_binary_evidence_file = (Resolve-Path (Join-Path $OutputDirectory "worker-binary-file.txt")).Path local_hashes_file = (Resolve-Path (Join-Path $OutputDirectory "local-hashes.json")).Path pass = $true } $result | ConvertTo-Json -Depth 8 | Tee-Object -FilePath (Join-Path $OutputDirectory "result.json") } finally { if ($socket) { try { $socket.Abort(); $socket.Dispose() } catch {} } if ($workerStoppedForScenario) { try { Restart-WorkerContainer -ContainerName $WorkerContainerName } catch { Write-Log "Worker restart failed: $($_.Exception.Message)" } } if ($sessionId) { try { Invoke-Api -Method Post -Path "sessions/$sessionId/terminate" -Body @{ user_id = $userId; reason = "stage5_2_download_smoke_complete" } | Out-Null Write-Log "Terminated session $sessionId" } catch { Write-Log "Session cleanup failed: $($_.Exception.Message)" } } if ($previousMode) { try { Invoke-DbRows -Sql "update resource_policies set file_transfer_mode = '$previousMode', updated_at = now() where resource_id = '$resourceId';" | Out-Null Write-Log "Restored file_transfer_mode=$previousMode" } catch { Write-Log "Policy restore failed: $($_.Exception.Message)" } } }