Files
rdp-proxy/scripts/smoke/file-upload-smoke.ps1
T
2026-04-28 22:29:50 +03:00

526 lines
21 KiB
PowerShell

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]$OutputLog = "artifacts/stage5-file-upload-smoke.log"
)
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
$log = [System.Collections.Generic.List[string]]::new()
function Write-SmokeLog {
param([string]$Message)
$line = "[$(Get-Date -Format o)] $Message"
$log.Add($line)
Write-Host $line
}
function Invoke-RemoteSqlScalar {
param([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 Set-FileTransferMode {
param([ValidateSet("disabled", "client_to_server")][string]$Mode)
$enabled = if ($Mode -eq "disabled") { "FALSE" } else { "TRUE" }
$escapedName = $ResourceName.Replace("'", "''")
$sql = @"
UPDATE resource_policies rp
SET file_transfer_mode = '$Mode',
file_transfer_enabled = $enabled,
updated_at = NOW()
FROM resources r
WHERE rp.resource_id = r.id
AND r.name = '$escapedName';
SELECT COALESCE(MAX(rp.file_transfer_mode), '<missing>')
FROM resource_policies rp
JOIN resources r ON r.id = rp.resource_id
WHERE r.name = '$escapedName';
"@
$actual = Invoke-RemoteSqlScalar -Sql $sql
if ($actual -ne $Mode) {
throw "file_transfer_mode was not set. Expected=$Mode actual=$actual"
}
Write-SmokeLog "policy file_transfer_mode=$Mode"
}
function Invoke-Api {
param(
[ValidateSet("Get", "Post")][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 12)
}
function New-GatewayWebSocket {
param([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 {
param(
[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
}
}
$text = [Text.Encoding]::UTF8.GetString($stream.ToArray())
return $text | ConvertFrom-Json
}
function Receive-EnvelopeOfType {
param(
[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
}
Write-SmokeLog "skipping ws event type=$($envelope.type)"
}
throw "timed out waiting for envelope type: $($Types -join ',')"
}
function Send-Envelope {
param(
[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 Get-PayloadProperty {
param(
[object]$Payload,
[string]$Name
)
if ($null -eq $Payload) {
return ""
}
if ($Payload.PSObject.Properties.Name -contains $Name) {
return $Payload.$Name
}
return ""
}
function Wait-SessionActive {
param([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 Start-SmokeSession {
param([string]$UserId, [string]$DeviceId, [string]$ResourceId)
return Invoke-Api -Method Post -Path "sessions" -Body @{
resource_id = $ResourceId
user_id = $UserId
device_id = $DeviceId
}
}
function Stop-SmokeSession {
param([string]$SessionId, [string]$UserId)
try {
Invoke-Api -Method Post -Path "sessions/$SessionId/terminate" -Body @{
user_id = $UserId
reason = "stage5_file_upload_smoke_cleanup"
} | Out-Null
}
catch {
Write-SmokeLog "terminate skipped session=$SessionId reason=$($_.Exception.Message)"
}
}
function Restart-SmokeWorker {
Write-SmokeLog "restarting worker container=$WorkerContainerName"
$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 Stop-SmokeWorker {
Write-SmokeLog "stopping worker container=$WorkerContainerName"
ssh $DockerSshAlias "docker rm -f $WorkerContainerName 2>/dev/null || true" | Out-Null
}
function Wait-SessionState {
param(
[string]$SessionId,
[string]$ExpectedState,
[int]$TimeoutSeconds = 90
)
$deadline = (Get-Date).AddSeconds($TimeoutSeconds)
while ((Get-Date) -lt $deadline) {
$escaped = $SessionId.Replace("'", "''")
$state = Invoke-RemoteSqlScalar -Sql "SELECT state FROM remote_sessions WHERE id = '$escaped' LIMIT 1;"
Write-SmokeLog "session_state session=$SessionId state=$state expected=$ExpectedState"
if ($state -eq $ExpectedState) {
return
}
Start-Sleep -Seconds 3
}
throw "session $SessionId did not reach state $ExpectedState"
}
function Assert-UploadBlockedOnConnection {
param(
[Net.WebSockets.ClientWebSocket]$WebSocket,
[string]$Label
)
try {
Send-Envelope -WebSocket $WebSocket -Envelope @{
type = "file_upload.start"
payload = @{
direction = "client_to_server"
transfer_id = [guid]::NewGuid().ToString("D")
file_name = "$Label.txt"
file_size = 5
total_chunks = 1
content_hash = ""
}
}
$response = Receive-EnvelopeOfType -WebSocket $WebSocket -Types @("file_upload.blocked", "session.taken_over", "transport.closed", "session.state") -TimeoutSeconds 20
$reason = Get-PayloadProperty -Payload $response.payload -Name "reason"
$state = Get-PayloadProperty -Payload $response.payload -Name "state"
Write-SmokeLog "$Label upload gate response type=$($response.type) state=$state reason=$reason"
if ($response.type -eq "file_upload.progress") {
throw "$Label upload was accepted unexpectedly"
}
if ($response.type -eq "session.state" -and $state -eq "active") {
throw "$Label upload gate returned active session state"
}
}
catch {
Write-SmokeLog "$Label upload blocked by closed/failed transport: $($_.Exception.Message)"
}
}
function Upload-Bytes {
param(
[Net.WebSockets.ClientWebSocket]$WebSocket,
[string]$FileName,
[byte[]]$Bytes
)
$transferId = [guid]::NewGuid().ToString("D")
$chunkSize = 256 * 1024
$totalChunks = [int64][Math]::Ceiling($Bytes.Length / $chunkSize)
Write-SmokeLog "client upload start transfer_id=$transferId file=$FileName bytes=$($Bytes.Length)"
Send-Envelope -WebSocket $WebSocket -Envelope @{
type = "file_upload.start"
payload = @{
direction = "client_to_server"
transfer_id = $transferId
file_name = $FileName
file_size = [int64]$Bytes.Length
total_chunks = $totalChunks
content_hash = ""
}
}
$startResponse = Receive-EnvelopeOfType -WebSocket $WebSocket -Types @("file_upload.progress", "file_upload.blocked") -TimeoutSeconds 20
$startStatus = Get-PayloadProperty -Payload $startResponse.payload -Name "status"
$startReason = Get-PayloadProperty -Payload $startResponse.payload -Name "reason"
Write-SmokeLog "client upload response type=$($startResponse.type) status=$startStatus reason=$startReason"
if ($startResponse.type -ne "file_upload.progress" -or $startResponse.payload.status -ne "started") {
throw "upload was not accepted by gateway"
}
$offset = 0
$chunkIndex = 0
while ($offset -lt $Bytes.Length) {
$take = [Math]::Min($chunkSize, $Bytes.Length - $offset)
$chunk = [byte[]]::new($take)
[Array]::Copy($Bytes, $offset, $chunk, 0, $take)
Send-Envelope -WebSocket $WebSocket -Envelope @{
type = "file_upload.chunk"
payload = @{
direction = "client_to_server"
transfer_id = $transferId
chunk_index = [int64]$chunkIndex
offset = [int64]$offset
chunk_size = $take
chunk_bytes = [Convert]::ToBase64String($chunk)
}
}
$progress = Receive-EnvelopeOfType -WebSocket $WebSocket -Types @("file_upload.progress", "file_upload.blocked") -TimeoutSeconds 20
$progressStatus = Get-PayloadProperty -Payload $progress.payload -Name "status"
$progressReceived = Get-PayloadProperty -Payload $progress.payload -Name "received"
$progressTotal = Get-PayloadProperty -Payload $progress.payload -Name "total"
Write-SmokeLog "client upload progress type=$($progress.type) status=$progressStatus received=$progressReceived total=$progressTotal"
$offset += $take
$chunkIndex++
}
return $transferId
}
function Get-RemoteFileEvidence {
param(
[string]$SessionId,
[string]$TransferId,
[string]$FileName,
[string]$ExpectedSha256
)
$path = "/tmp/rap-rdp-worker-transfers/$SessionId/visible/$FileName"
$remoteScript = "p='$path'; ls -l ""`$p""; wc -c < ""`$p""; sha256sum ""`$p"""
$output = $null
$deadline = (Get-Date).AddSeconds(20)
while ((Get-Date) -lt $deadline) {
$output = ssh $DockerSshAlias "docker exec $WorkerContainerName sh -lc '$remoteScript'" 2>$null
if ($LASTEXITCODE -eq 0 -and (($output -join "`n") -like "*$ExpectedSha256*")) {
break
}
Start-Sleep -Milliseconds 500
}
foreach ($line in $output) {
Write-SmokeLog "worker_fs $line"
}
if ((($output -join "`n") -notlike "*$ExpectedSha256*")) {
throw "remote sha256 mismatch for $FileName"
}
}
function Get-Sha256 {
param([byte[]]$Bytes)
$stream = [IO.MemoryStream]::new($Bytes)
try {
return (Get-FileHash -Algorithm SHA256 -InputStream $stream).Hash.ToLowerInvariant()
}
finally {
$stream.Dispose()
}
}
New-Item -ItemType Directory -Force -Path (Split-Path -Parent $OutputLog) | Out-Null
$login = Invoke-Api -Method Post -Path "auth/login" -Body @{
email = $Email
password = $Password
trust_device = $true
device_fingerprint = "stage5-file-upload-a"
device_label = "stage5-file-upload-a"
}
$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("'", "''")
$resourceSql = "SELECT id::text FROM resources WHERE name = '$escapedResourceName' LIMIT 1;"
$resourceId = Invoke-RemoteSqlScalar -Sql $resourceSql
if ([string]::IsNullOrWhiteSpace($resourceId)) {
throw "resource not found: $ResourceName"
}
Write-SmokeLog "smoke user=$userId device=$deviceId resource=$resourceId"
Restart-SmokeWorker
$sessions = Invoke-Api -Method Get -Path "sessions?user_id=$([Uri]::EscapeDataString($userId))"
foreach ($session in @($sessions.sessions)) {
if ($session.state -in @("starting", "active", "detached", "reconnecting")) {
Stop-SmokeSession -SessionId $session.id -UserId $userId
}
}
Set-FileTransferMode -Mode disabled
$disabled = Start-SmokeSession -UserId $userId -DeviceId $deviceId -ResourceId $resourceId
$disabledSessionId = [string]$disabled.session.ID
$disabledAttachToken = [string]$disabled.attach_token.token
Write-SmokeLog "disabled session=$disabledSessionId"
$disabledWs = New-GatewayWebSocket -AttachToken $disabledAttachToken
try {
Wait-SessionActive -WebSocket $disabledWs
$blockedTransferId = [guid]::NewGuid().ToString("D")
Send-Envelope -WebSocket $disabledWs -Envelope @{
type = "file_upload.start"
payload = @{
direction = "client_to_server"
transfer_id = $blockedTransferId
file_name = "blocked.txt"
file_size = 5
total_chunks = 1
content_hash = ""
}
}
$blocked = Receive-EnvelopeOfType -WebSocket $disabledWs -Types @("file_upload.blocked") -TimeoutSeconds 20
$blockedReason = if ($blocked.payload.PSObject.Properties.Name -contains "reason") { $blocked.payload.reason } else { "" }
Write-SmokeLog "disabled upload response type=$($blocked.type) reason=$blockedReason"
if ($blocked.type -ne "file_upload.blocked") {
throw "disabled policy did not block upload"
}
}
finally {
$disabledWs.Dispose()
Stop-SmokeSession -SessionId $disabledSessionId -UserId $userId
}
Set-FileTransferMode -Mode client_to_server
$allowed = Start-SmokeSession -UserId $userId -DeviceId $deviceId -ResourceId $resourceId
$sessionId = [string]$allowed.session.ID
$attachmentId = [string]$allowed.attachment.ID
$attachToken = [string]$allowed.attach_token.token
Write-SmokeLog "allowed session=$sessionId attachment=$attachmentId"
$ws = New-GatewayWebSocket -AttachToken $attachToken
try {
Wait-SessionActive -WebSocket $ws
Send-Envelope -WebSocket $ws -Envelope @{ type = "input"; payload = @{ kind = "mouse"; action = "move"; normalized_x = 0.5; normalized_y = 0.5; surface_width = 1024; surface_height = 768 } }
Send-Envelope -WebSocket $ws -Envelope @{ type = "input"; payload = @{ kind = "keyboard"; action = "key_down"; scan_code = 30; is_extended = $false } }
Send-Envelope -WebSocket $ws -Envelope @{ type = "input"; payload = @{ kind = "keyboard"; action = "key_up"; scan_code = 30; is_extended = $false } }
Send-Envelope -WebSocket $ws -Envelope @{ type = "clipboard"; payload = @{ direction = "client_to_server"; text = "stage5 clipboard regression"; sequence_id = [DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds(); origin = $attachmentId; content_hash = "" } }
Start-Sleep -Seconds 2
$textBytes = [Text.Encoding]::UTF8.GetBytes("Stage 5.1 text upload`nрусский текст`n")
$textSha = Get-Sha256 -Bytes $textBytes
$textTransfer = Upload-Bytes -WebSocket $ws -FileName "stage5-upload-text.txt" -Bytes $textBytes
Get-RemoteFileEvidence -SessionId $sessionId -TransferId $textTransfer -FileName "stage5-upload-text.txt" -ExpectedSha256 $textSha
$binaryBytes = [byte[]]::new(4096)
[Security.Cryptography.RandomNumberGenerator]::Fill($binaryBytes)
$binarySha = Get-Sha256 -Bytes $binaryBytes
$binaryTransfer = Upload-Bytes -WebSocket $ws -FileName "stage5-upload-binary.bin" -Bytes $binaryBytes
Get-RemoteFileEvidence -SessionId $sessionId -TransferId $binaryTransfer -FileName "stage5-upload-binary.bin" -ExpectedSha256 $binarySha
Invoke-Api -Method Post -Path "sessions/$sessionId/detach" -Body @{
attachment_id = $attachmentId
user_id = $userId
reason = "stage5_file_upload_detach_gate"
} | Out-Null
Start-Sleep -Seconds 2
try {
Send-Envelope -WebSocket $ws -Envelope @{
type = "file_upload.start"
payload = @{
direction = "client_to_server"
transfer_id = [guid]::NewGuid().ToString("D")
file_name = "after-detach.txt"
file_size = 5
total_chunks = 1
content_hash = ""
}
}
$detachGate = Receive-Envelope -WebSocket $ws -TimeoutMilliseconds 8000
Write-SmokeLog "detach upload response type=$($detachGate.type) reason=$($detachGate.payload.reason)"
}
catch {
Write-SmokeLog "detach upload blocked by closed transport: $($_.Exception.Message)"
}
Stop-SmokeSession -SessionId $sessionId -UserId $userId
$takeoverSession = Start-SmokeSession -UserId $userId -DeviceId $deviceId -ResourceId $resourceId
$takeoverSessionId = [string]$takeoverSession.session.ID
$takeoverAttachmentId = [string]$takeoverSession.attachment.ID
$takeoverAttachToken = [string]$takeoverSession.attach_token.token
Write-SmokeLog "takeover session=$takeoverSessionId old_attachment=$takeoverAttachmentId"
$takeoverOldWs = New-GatewayWebSocket -AttachToken $takeoverAttachToken
$takeoverNewWs = $null
try {
Wait-SessionActive -WebSocket $takeoverOldWs
$loginB = Invoke-Api -Method Post -Path "auth/login" -Body @{
email = $Email
password = $Password
trust_device = $true
device_fingerprint = "stage5-file-upload-b"
device_label = "stage5-file-upload-b"
}
$deviceB = [string]$loginB.device.ID
if ([string]::IsNullOrWhiteSpace($deviceB)) {
$deviceB = [string]$loginB.device.id
}
$takeoverResult = Invoke-Api -Method Post -Path "sessions/$takeoverSessionId/takeover" -Body @{
user_id = $userId
device_id = $deviceB
reason = "stage5_file_upload_takeover_gate"
}
$newAttachmentId = [string]$takeoverResult.attachment.ID
$newAttachToken = [string]$takeoverResult.attach_token.token
Write-SmokeLog "takeover complete new_attachment=$newAttachmentId"
$takeoverNewWs = New-GatewayWebSocket -AttachToken $newAttachToken
Wait-SessionActive -WebSocket $takeoverNewWs
Assert-UploadBlockedOnConnection -WebSocket $takeoverOldWs -Label "takeover_old_client"
}
finally {
if ($null -ne $takeoverNewWs) {
$takeoverNewWs.Dispose()
}
$takeoverOldWs.Dispose()
Stop-SmokeSession -SessionId $takeoverSessionId -UserId $userId
}
$failureSession = Start-SmokeSession -UserId $userId -DeviceId $deviceId -ResourceId $resourceId
$failureSessionId = [string]$failureSession.session.ID
$failureAttachToken = [string]$failureSession.attach_token.token
Write-SmokeLog "worker_failure session=$failureSessionId"
$failureWs = New-GatewayWebSocket -AttachToken $failureAttachToken
try {
Wait-SessionActive -WebSocket $failureWs
Stop-SmokeWorker
Wait-SessionState -SessionId $failureSessionId -ExpectedState "failed" -TimeoutSeconds 120
Assert-UploadBlockedOnConnection -WebSocket $failureWs -Label "worker_failure"
}
finally {
$failureWs.Dispose()
Restart-SmokeWorker
Stop-SmokeSession -SessionId $failureSessionId -UserId $userId
}
}
finally {
$ws.Dispose()
Stop-SmokeSession -SessionId $sessionId -UserId $userId
$log | Set-Content -LiteralPath $OutputLog -Encoding UTF8
Write-SmokeLog "wrote $OutputLog"
}