625 lines
27 KiB
PowerShell
625 lines
27 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]$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=<redacted>')
|
|
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)"
|
|
}
|
|
}
|
|
}
|