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

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)"
}
}
}