Initial project snapshot

This commit is contained in:
2026-04-28 22:29:50 +03:00
commit 8ba0561f4f
365 changed files with 91832 additions and 0 deletions
+597
View File
@@ -0,0 +1,597 @@
# Local Smoke Test
This smoke path is for proving the minimal end-to-end server-side session lifecycle without any UI.
## Verification matrix
### Locally proven in this repository work
- backend `go build ./...` succeeds
- worker build environment files exist and are aligned across devcontainer, Docker, and CI
- worker Docker image contract now has a deterministic runtime binary path: `/usr/local/bin/rdp-worker`
- worker source had minimal compile fixes applied for missing declarations/includes needed by the reproducible build environment
### Container-proven
- the canonical worker build environment is [workers/rdp-worker/Dockerfile](/\\?\UNC\192.168.220.200\mst\codex\rdp-proxy\workers\rdp-worker\Dockerfile)
- a successful `docker build` in that environment proves CMake configure + compile + install for the worker
- Data Plane v1 Stage DP-1C builds the optional worker direct WSS endpoint and
installs `/usr/local/bin/rdp-worker-dataplane-token-probe`
- Data Plane v1 Stage DP-1D.1 builds the worker direct JSON realtime bridge
for the same JSON envelopes used by the backend gateway
- DP-1C endpoint validation is proven for malformed-token rejection and
valid-token-without-runtime rejection, and replayed `jti` rejection;
successful direct attach to a live runtime and direct JSON traffic proof are
still live smoke targets
### CI-defined but not yet executed in this verification pass
- [`.github/workflows/build.yml`](/\\?\UNC\192.168.220.200\mst\codex\rdp-proxy\.github\workflows\build.yml) builds the backend
- the same workflow builds the worker Docker image
- the workflow verifies that `/usr/local/bin/rdp-worker` exists inside the image
### Still not proven automatically
- behavior on host reboot during an active real RDP session
- automated assertion of actual viewer-side rendering correctness
- Stage 5.1 file upload is build-proven in this pass, but live upload proof
requires the RAP stack to be running on the current test Docker host
`192.168.200.61`
## Data Plane v1C worker WSS validation smoke
Build the worker image on the test Docker host:
```powershell
$env:DOCKER_BUILDKIT='0'
docker --context test-ubuntu build --tag rap-rdp-worker:dp1c-hardened --file workers/rdp-worker/Dockerfile workers/rdp-worker
```
Run the narrow endpoint smoke:
```powershell
pwsh -ExecutionPolicy Bypass -File scripts/smoke/data-plane-v1c-smoke.ps1
```
Expected evidence:
- worker logs `direct data-plane WSS endpoint listening`
- malformed token receives `401 Unauthorized`
- valid token with no existing session runtime receives `404 Not Found` with
`missing_runtime`
- replaying the same token receives `401 Unauthorized` with
`jti_replay_rejected`
- worker logs `event=token_validation_failed reason=malformed_token`
- worker logs `event=data_plane_bind_failed ... reason=missing_runtime`
- worker logs `event=jti_replay_rejected`
This smoke intentionally does not route Windows client traffic through the
direct worker WSS endpoint. The backend gateway remains the runtime path until
DP-1D.
## Data Plane v1D.1 direct JSON bridge smoke status
Build the DP-1D.1 worker image on the test Docker host:
```powershell
$env:DOCKER_BUILDKIT='0'
docker --context test-ubuntu build --tag rap-rdp-worker:dp1d1 --file workers/rdp-worker/Dockerfile workers/rdp-worker
```
Backend metadata must remain explicitly gated:
```text
DATA_PLANE_DIRECT_WORKER_JSON_RUNTIME=false # default, client falls back
DATA_PLANE_DIRECT_WORKER_JSON_RUNTIME=true # advertise runtime_transport=json_v1
```
Locally/container-proven in DP-1D.1:
- backend tests prove direct candidate metadata appears only when
`DATA_PLANE_DIRECT_WORKER_JSON_RUNTIME=true`
- Windows client build still succeeds and remains capability-gated
- worker Docker image builds with direct JSON envelope bridge
- endpoint/token smoke still proves invalid token, missing runtime, and replay
rejection
Still requiring live RDP smoke:
- direct WSS connects to an already-running runtime
- direct WSS carries input/render/clipboard/file_upload JSON envelopes
- direct WSS does not recreate the RDP runtime
- backend gateway fallback activates when direct WSS is unavailable
- direct vs fallback latency comparison on the same RDP target
## Prerequisites
- Docker Desktop or Docker Engine with `docker compose`
- Go `1.23.x` for local backend runs
- a reachable RDP host for the seeded resource
- a machine where the worker Docker image can actually be built and started
## 0. Verify raw TCP reachability to the target first
Run this from the same machine or container host that will run the worker:
```powershell
python scripts/smoke/check-rdp-target.py --host 192.168.60.210 --port 60210
```
Expected result:
- `tcp_connect=ok ...`
If this step fails, FreeRDP connect proof is blocked by target reachability and the later lifecycle steps cannot be considered proven.
## Canonical build environments
- worker devcontainer: [`.devcontainer/devcontainer.json`](/\\?\UNC\192.168.220.200\mst\codex\rdp-proxy\.devcontainer\devcontainer.json)
- worker Docker image: [workers/rdp-worker/Dockerfile](/\\?\UNC\192.168.220.200\mst\codex\rdp-proxy\workers\rdp-worker\Dockerfile)
- worker CMake preset: [workers/rdp-worker/CMakePresets.json](/\\?\UNC\192.168.220.200\mst\codex\rdp-proxy\workers\rdp-worker\CMakePresets.json)
## 1. Start infra
```powershell
pwsh -File scripts/smoke/start-infra.ps1
```
Expected result:
- PostgreSQL is reachable on `127.0.0.1:5432`
- Redis is reachable on `127.0.0.1:6379`
## 2. Apply backend migrations
```powershell
pwsh -File scripts/smoke/apply-migrations.ps1
```
Expected result:
- backend tables exist in `remote_access_platform`
## 3. Seed a smoke-test user, trusted device, resource, and policy
Edit the connection parameters to point to a reachable RDP host:
```powershell
pwsh -File scripts/smoke/seed-resource.ps1 `
-RdpHost 10.0.0.10 `
-RdpPort 3389 `
-RdpUsername Administrator `
-RdpPassword secret `
-RdpDomain "" `
-CertificateVerificationMode strict
```
Expected result:
- the script creates or reuses the `default` organization created by the v2 migrations
- the script creates an active default-organization membership for the seeded smoke user
- the script prints `user_id`
- the script prints `device_id`
- the script prints `resource_id`
## 4. Start backend
```powershell
pwsh -File scripts/smoke/run-backend.ps1
```
Expected result:
- backend listens on `http://192.168.200.61:8080` from the Windows client and on `127.0.0.1:8080` inside the Docker host network
Containerized fallback when the smoke host does not have `go` installed:
```sh
docker run -d --name rap_backend_smoke --network host \
-v /absolute/path/to/repo/backend:/workspace/backend \
-w /workspace/backend \
-e APP_NAME=rap-api \
-e APP_ENV=development \
-e HTTP_HOST=0.0.0.0 \
-e HTTP_PORT=8080 \
-e POSTGRES_DSN=postgres://rap_user:rap_password@127.0.0.1:5432/remote_access_platform?sslmode=disable \
-e REDIS_ADDR=127.0.0.1:6379 \
-e AUTH_ACCESS_TOKEN_SECRET=smoke-access-secret \
-e AUTH_REFRESH_HASH_SECRET=smoke-refresh-secret \
golang:1.23.8-bookworm /bin/sh -lc '/usr/local/go/bin/go run ./cmd/api'
```
## 5. Build the worker image
```powershell
pwsh -File scripts/smoke/build-worker-image.ps1
docker run --rm --entrypoint /bin/sh rap-rdp-worker:dev -lc "test -x /usr/local/bin/rdp-worker"
```
Expected result:
- image build succeeds
- the binary exists at `/usr/local/bin/rdp-worker`
## 6. Run the worker container
```powershell
pwsh -File scripts/smoke/run-worker-container.ps1
```
Expected result:
- the worker process starts
- Redis contains `worker:registration:rdp-worker-1`
If the test RDP host uses a self-signed or mismatched certificate and smoke verification needs to continue, set:
```text
RDP_WORKER_INSECURE_SKIP_VERIFY=true
```
This override is worker-runtime-only and is intended strictly for smoke verification.
## 7. Start a session
```powershell
$body = @{
resource_id = "<resource_id>"
user_id = "<user_id>"
device_id = "<device_id>"
} | ConvertTo-Json
Invoke-RestMethod `
-Method Post `
-Uri http://192.168.200.61:8080/api/v1/sessions `
-ContentType 'application/json' `
-Body $body
```
Expected result:
- the response includes `session.id`
- the response includes `attachment.id`
- the response includes `attach_token`
- the response session payload carries `organization_id`
- backend logs a session start and assignment path
- worker logs a new assignment and FreeRDP connect attempt
- Redis `worker:events` emits `session_connected` and then periodic `session_heartbeat`
## 8. Detach
```powershell
$body = @{
attachment_id = "<attachment_id>"
user_id = "<user_id>"
reason = "manual_smoke_detach"
} | ConvertTo-Json
Invoke-RestMethod `
-Method Post `
-Uri http://192.168.200.61:8080/api/v1/sessions/<session_id>/detach `
-ContentType 'application/json' `
-Body $body
```
Expected result:
- PostgreSQL session state becomes `detached`
- worker keeps the RDP connection alive
- worker does not emit `session_terminated`
## 9. Reattach
```powershell
$body = @{
user_id = "<user_id>"
device_id = "<device_id>"
} | ConvertTo-Json
Invoke-RestMethod `
-Method Post `
-Uri http://192.168.200.61:8080/api/v1/sessions/<session_id>/attach `
-ContentType 'application/json' `
-Body $body
```
Expected result:
- backend returns a new short-lived `attach_token`
- worker does not recreate the remote RDP session
- worker continues heartbeating the same `session_id`
## 10. Takeover
Create or seed a second trusted device for the same user, then:
```powershell
$body = @{
user_id = "<user_id>"
device_id = "<second_device_id>"
reason = "manual_smoke_takeover"
} | ConvertTo-Json
Invoke-RestMethod `
-Method Post `
-Uri http://192.168.200.61:8080/api/v1/sessions/<session_id>/takeover `
-ContentType 'application/json' `
-Body $body
```
Expected result:
- backend atomically supersedes the previous attachment
- previous controller WebSocket session receives `session.taken_over` if connected
- worker stays on the same remote RDP session and only updates controller ownership
## 10A. Prove WebSocket takeover delivery
Use the real smoke client built into the backend module:
```sh
cd backend
go run ./cmd/ws-smoke-client \
-attach-token "<controller_a_attach_token>" \
-duration 120s
```
Expected result:
- controller A first receives `session.state`
- after takeover from controller B, controller A receives `session.taken_over`
- PostgreSQL shows the new attachment for controller B as `active`
- worker logs only `updated assignment for existing session ...` and does not log a new runtime start
## 11. Terminate
```powershell
$body = @{
user_id = "<user_id>"
reason = "manual_smoke_terminate"
} | ConvertTo-Json
Invoke-RestMethod `
-Method Post `
-Uri http://192.168.200.61:8080/api/v1/sessions/<session_id>/terminate `
-ContentType 'application/json' `
-Body $body
```
Expected result:
- backend marks the session `terminated`
- worker receives a `terminate` control envelope
- worker disconnects the FreeRDP session and emits `session_terminated`
## 12. Prove stale lease and worker-death recovery
With a live active session still running:
```sh
docker rm -f rap_worker_smoke
```
Wait at least:
- `WORKER_HEARTBEAT_TTL`
- plus `WORKER_STALE_LEASE_GRACE_PERIOD`
- plus one lease-monitor interval
With current defaults from [backend/configs/api.example.env](/\\?\UNC\192.168.220.200\mst\codex\rdp-proxy\backend\configs\api.example.env), waiting about `90s` is sufficient for a manual smoke pass.
Expected result:
- `worker:registration:<worker_id>` disappears from Redis
- `worker:session-lease:<session_id>` is released
- `live:session:<session_id>`, `live:binding:<session_id>`, and `live:route:<session_id>` are cleared
- PostgreSQL moves the session to `failed`
- non-superseded attachments become `closed`
- `audit_events` contains `session_failed` with reason `worker_lease_stale_or_worker_missing`
## Troubleshooting
- If the worker image build fails, run `docker build --tag rap-rdp-worker:dev --file workers/rdp-worker/Dockerfile workers/rdp-worker` directly and inspect the compiler output.
- If raw TCP reachability to the RDP target fails, stop there and fix host/network/firewall/port access before evaluating FreeRDP behavior.
- If the worker starts but never receives assignments, verify `worker:registration:<worker_id>` and `worker:control:<worker_id>` in Redis.
- If session start returns `access denied` after the v2 migrations, verify that the seeded user has an active membership in the resource organization.
- If takeover does not produce `session.taken_over`, confirm that controller A is attached through `/api/v1/gateway/ws` using a still-valid attach token and that the broker binding changed.
- If worker death does not transition the session quickly enough, verify `WORKER_HEARTBEAT_TTL`, `WORKER_STALE_LEASE_GRACE_PERIOD`, and the lease monitor interval before treating the session as stuck.
- If FreeRDP cannot connect, verify the seeded host, port, username, password, domain, certificate verification mode, and network reachability from Docker to the target host.
- If attach tokens expire during manual testing, repeat the attach or takeover call to mint a new token.
## Stage 5.1 / 5.1.1 File Upload Smoke
Current target Docker host for this project is `192.168.200.61` via Docker
context `test-ubuntu`. Verify before running:
```powershell
docker context use test-ubuntu
docker ps
```
Policy setup:
```sql
UPDATE resource_policies
SET file_transfer_mode = 'client_to_server',
file_transfer_enabled = TRUE,
updated_at = now()
WHERE resource_id = '<resource_id>';
```
Disabled-policy regression:
```sql
UPDATE resource_policies
SET file_transfer_mode = 'disabled',
file_transfer_enabled = FALSE,
updated_at = now()
WHERE resource_id = '<resource_id>';
```
Manual upload proof from the Windows client:
- start or attach an active RDP session
- open the session window
- click `Upload File`
- choose a small text file, then a small binary file
- verify the UI progress reaches 100%
- inspect backend logs for `session gateway file upload start accepted` and
`session gateway file upload chunk accepted`
- inspect worker logs for `file upload completed`
- verify the file and hash inside the worker container:
```powershell
docker exec rap_worker_smoke sh -lc `
"find /tmp/rap-rdp-worker-transfers -path '*/visible/*' -type f -maxdepth 4 -print -exec wc -c {} \;"
```
Stage 5.1.1 visibility proof:
Automated smoke command used for the accepted proof:
```powershell
pwsh -ExecutionPolicy Bypass -File scripts\smoke\drive-visibility-smoke.ps1 `
-WorkerImage rap-rdp-worker:rdp-p1-region-order2 `
-OutputFrame artifacts\stage5-drive-visibility-frame-p1-rerun.bmp
```
- worker logs must show `visible transfer directory ready`
- worker logs must show `FreeRDP restricted transfer drive configured name=RAP_Transfers`
- inside the remote Windows session, open File Explorer and verify the
redirected drive `RAP_Transfers` is present
- verify uploaded files are visible under `RAP_Transfers`
- open the uploaded text file and verify the content matches
- for binary files, verify size and hash through worker storage evidence unless
a remote-side hash tool is available
- after detach, takeover old-client, and worker failure, upload must be blocked
and the visible transfer directory must be cleaned up
Required PASS cases for accepting Stage 5.1:
- `disabled` blocks upload
- `client_to_server` allows upload
- small text file hash matches
- small binary file hash matches
- file larger than 25 MiB is blocked by client/gateway policy
- path traversal names are blocked by gateway/worker validation
- upload is blocked after detach, old-client takeover, and worker failure
- rendering, mouse input, keyboard input, clipboard, reconnect, and takeover
still work
Accepted Stage 5.1.1 proof artifact:
- `artifacts/stage5-drive-visibility-frame-p1-rerun.bmp` shows the uploaded
`stage5-upload-text.txt` opened inside remote Windows from the restricted
`RAP_Transfers` drive.
Important limitation for Stage 5.1.1: it intentionally exposed only the
restricted per-session `visible` directory as `RAP_Transfers`. It must not be
expanded to arbitrary paths, full shared folders, SMB/WebDAV, or Windows agent
delivery.
## Stage 5.2 File Download Smoke
Stage 5.2 server-to-client download has a runtime-proven core data path and
lifecycle blocking proof. Manual desktop UI proof remains before full
acceptance.
Build-proven images:
```text
rap-backend-smoke:stage5-2-download
rap-rdp-worker:stage5-2-download
```
Headless core data-path proof:
```powershell
pwsh -NoProfile -ExecutionPolicy Bypass `
-File scripts\smoke\file-download-smoke.ps1 `
-AllowMode server_to_client `
-Transport direct_worker_wss `
-OutputDirectory artifacts/stage5-2-download-smoke-direct-fixed2
pwsh -NoProfile -ExecutionPolicy Bypass `
-File scripts\smoke\file-download-smoke.ps1 `
-AllowMode bidirectional `
-Transport direct_worker_wss `
-OutputDirectory artifacts/stage5-2-download-smoke-direct-bidirectional
pwsh -NoProfile -ExecutionPolicy Bypass `
-File scripts\smoke\file-download-smoke.ps1 `
-AllowMode client_to_server `
-Transport direct_worker_wss `
-ExpectBlocked `
-OutputDirectory artifacts/stage5-2-download-smoke-direct-client-to-server-block-fixed
pwsh -NoProfile -ExecutionPolicy Bypass `
-File scripts\smoke\file-download-smoke.ps1 `
-AllowMode disabled `
-Transport direct_worker_wss `
-ExpectBlocked `
-OutputDirectory artifacts/stage5-2-download-smoke-direct-disabled-fixed
pwsh -NoProfile -ExecutionPolicy Bypass `
-File scripts\smoke\file-download-smoke.ps1 `
-AllowMode server_to_client `
-Transport backend_gateway `
-OutputDirectory artifacts/stage5-2-download-smoke-backend-regression-after-direct-block
```
Lifecycle proof:
```powershell
pwsh -NoProfile -ExecutionPolicy Bypass `
-File scripts\smoke\file-download-smoke.ps1 `
-AllowMode server_to_client `
-Transport direct_worker_wss `
-LifecycleScenario detach `
-OutputDirectory artifacts/stage5-2-download-lifecycle-detach-fixed
pwsh -NoProfile -ExecutionPolicy Bypass `
-File scripts\smoke\file-download-smoke.ps1 `
-AllowMode server_to_client `
-Transport direct_worker_wss `
-LifecycleScenario takeover_old_controller `
-OutputDirectory artifacts/stage5-2-download-lifecycle-takeover-fixed
pwsh -NoProfile -ExecutionPolicy Bypass `
-File scripts\smoke\file-download-smoke.ps1 `
-AllowMode server_to_client `
-Transport direct_worker_wss `
-LifecycleScenario worker_failure `
-OutputDirectory artifacts/stage5-2-download-lifecycle-worker-failure
```
Accepted core evidence:
- direct worker WSS `server_to_client`: text and binary size/hash match
- direct worker WSS `bidirectional`: text and binary download succeeds
- direct worker WSS `client_to_server`: download blocked with `access denied`
- direct worker WSS `disabled`: download blocked with `access denied`
- backend gateway fallback `server_to_client`: text and binary size/hash match
- detach blocks download with `file_download.blocked`
- old controller after takeover receives `session.taken_over` and cannot
continue download
- worker failure transitions PostgreSQL state to `failed`; direct WebSocket
closes and download cannot continue
Report:
- `artifacts/stage5-2-file-download-runtime-report.md`
Remaining manual live proof:
- keep the Stage 5.2 backend and worker images on `docker-test`
- set `resource_policies.file_transfer_mode = 'server_to_client'`
- start or attach a real RDP session
- inside remote Windows, copy a small text file to `RAP_Transfers\ToClient`
- verify the Windows client shows `file_download.available`
- click `Download File`, choose a local save path, and verify completion
- compare size and hash with worker evidence
- repeat with a small binary file
- verify `disabled` and `client_to_server` block download
- verify `bidirectional` allows upload and download
- verify rendering, mouse, keyboard, clipboard, upload, reconnect, takeover,
and backend gateway fallback do not regress
+9
View File
@@ -0,0 +1,9 @@
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
$migrationFiles = Get-ChildItem -Path "backend/migrations" -Filter "*.up.sql" | Sort-Object Name
foreach ($migration in $migrationFiles) {
Write-Host "Applying $($migration.Name)..."
Get-Content -Raw $migration.FullName | docker exec -i rap_postgres psql -U rap_user -d remote_access_platform -v ON_ERROR_STOP=1 -f -
}
+5
View File
@@ -0,0 +1,5 @@
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
docker build -t rap-rdp-worker:dev -f workers/rdp-worker/Dockerfile workers/rdp-worker
docker run --rm --entrypoint /bin/sh rap-rdp-worker:dev -lc "test -x /usr/local/bin/rdp-worker"
+30
View File
@@ -0,0 +1,30 @@
#!/usr/bin/env python3
import argparse
import socket
import sys
def main() -> int:
parser = argparse.ArgumentParser(description="Check raw TCP reachability to an RDP target.")
parser.add_argument("--host", required=True, help="RDP host or IP")
parser.add_argument("--port", required=True, type=int, help="RDP TCP port")
parser.add_argument("--timeout", type=float, default=5.0, help="connect timeout seconds")
args = parser.parse_args()
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.settimeout(args.timeout)
try:
sock.connect((args.host, args.port))
except OSError as exc:
print(f"tcp_connect=failed host={args.host} port={args.port} error={exc}")
return 1
finally:
sock.close()
print(f"tcp_connect=ok host={args.host} port={args.port}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+84
View File
@@ -0,0 +1,84 @@
param(
[string]$DockerSshAlias = "docker-test",
[string]$WorkerImage = "rap-rdp-worker:dp1c-hardened",
[string]$WorkerID = "rdp-worker-1",
[int]$Port = 18443
)
$ErrorActionPreference = "Stop"
function ConvertTo-Base64Url([byte[]]$Bytes) {
return [Convert]::ToBase64String($Bytes).TrimEnd("=").Replace("+", "-").Replace("/", "_")
}
function New-DataPlaneJwt([hashtable]$Payload) {
$encoding = [Text.Encoding]::UTF8
$headerJson = @{ alg = "RS256"; typ = "JWT" } | ConvertTo-Json -Compress
$payloadJson = $Payload | ConvertTo-Json -Compress -Depth 6
$body = "$(ConvertTo-Base64Url $encoding.GetBytes($headerJson)).$(ConvertTo-Base64Url $encoding.GetBytes($payloadJson))"
$signature = ConvertTo-Base64Url $script:DataPlaneRsa.SignData(
$encoding.GetBytes($body),
[System.Security.Cryptography.HashAlgorithmName]::SHA256,
[System.Security.Cryptography.RSASignaturePadding]::Pkcs1)
return "$body.$signature"
}
function ConvertTo-Pem([string]$Label, [byte[]]$Bytes) {
$base64 = [Convert]::ToBase64String($Bytes)
$lines = for ($i = 0; $i -lt $base64.Length; $i += 64) {
$base64.Substring($i, [Math]::Min(64, $base64.Length - $i))
}
$joined = $lines -join "`n"
return "-----BEGIN $Label-----`n$joined`n-----END $Label-----`n"
}
$now = [int][DateTimeOffset]::UtcNow.ToUnixTimeSeconds()
$script:DataPlaneRsa = [System.Security.Cryptography.RSA]::Create(2048)
$publicKeyPem = ConvertTo-Pem "PUBLIC KEY" $script:DataPlaneRsa.ExportSubjectPublicKeyInfo()
$publicKeyB64 = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($publicKeyPem))
$validPayload = [ordered]@{
session_id = "session-without-runtime"
attachment_id = "attachment-1"
user_id = "user-1"
organization_id = "org-1"
worker_id = $WorkerID
resource_id = "resource-1"
allowed_channels = @("control", "input", "render")
jti = [guid]::NewGuid().ToString()
aud = @("rap-data-plane", "worker:$WorkerID")
iat = $now
nbf = $now
exp = $now + 300
}
$validToken = New-DataPlaneJwt $validPayload
$remoteScript = @"
set -eu
rm -rf /tmp/rap-dp1c && mkdir -p /tmp/rap-dp1c
openssl req -x509 -newkey rsa:2048 -nodes -keyout /tmp/rap-dp1c/key.pem -out /tmp/rap-dp1c/cert.pem -subj '/CN=localhost' -days 1 >/tmp/rap-dp1c/openssl.log 2>&1
printf '%s' '$publicKeyB64' | base64 -d >/tmp/rap-dp1c/dp-public.pem
(docker rm -f rap_worker_dp1c_probe >/dev/null 2>&1 || true)
docker run -d --name rap_worker_dp1c_probe --network rdp-proxy_default -p ${Port}:${Port} -v /tmp/rap-dp1c:/certs:ro \
-e RDP_WORKER_ID=$WorkerID \
-e RDP_WORKER_REDIS_HOST=rap_redis \
-e RDP_WORKER_DATA_PLANE_ENABLED=true \
-e RDP_WORKER_DATA_PLANE_LISTEN_HOST=0.0.0.0 \
-e RDP_WORKER_DATA_PLANE_LISTEN_PORT=$Port \
-e RDP_WORKER_DATA_PLANE_PUBLIC_KEY_FILE=/certs/dp-public.pem \
-e RDP_WORKER_DATA_PLANE_TLS_CERT_FILE=/certs/cert.pem \
-e RDP_WORKER_DATA_PLANE_TLS_KEY_FILE=/certs/key.pem \
$WorkerImage >/tmp/rap-dp1c/container.id
sleep 2
printf 'invalid token response:\n'
printf 'GET /rap/v1/data-plane?data_plane_token=bad HTTP/1.1\r\nHost: localhost\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Key: x3JJHMbDL1EzLkh9GBhXDw==\r\nSec-WebSocket-Version: 13\r\n\r\n' | timeout 5 openssl s_client -connect 127.0.0.1:$Port -servername localhost -quiet 2>/dev/null | head -20
printf '\nvalid token without runtime response:\n'
printf 'GET /rap/v1/data-plane?data_plane_token=$validToken HTTP/1.1\r\nHost: localhost\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Key: x3JJHMbDL1EzLkh9GBhXDw==\r\nSec-WebSocket-Version: 13\r\n\r\n' | timeout 5 openssl s_client -connect 127.0.0.1:$Port -servername localhost -quiet 2>/dev/null | head -20
printf '\nreplayed jti response:\n'
printf 'GET /rap/v1/data-plane?data_plane_token=$validToken HTTP/1.1\r\nHost: localhost\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Key: x3JJHMbDL1EzLkh9GBhXDw==\r\nSec-WebSocket-Version: 13\r\n\r\n' | timeout 5 openssl s_client -connect 127.0.0.1:$Port -servername localhost -quiet 2>/dev/null | head -20
printf '\nworker logs:\n'
docker logs rap_worker_dp1c_probe --tail 30
docker rm -f rap_worker_dp1c_probe >/dev/null 2>&1 || true
rm -rf /tmp/rap-dp1c
"@
ssh $DockerSshAlias $remoteScript
+260
View File
@@ -0,0 +1,260 @@
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)"
}
}
+624
View File
@@ -0,0 +1,624 @@
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)"
}
}
}
+525
View File
@@ -0,0 +1,525 @@
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"
}
@@ -0,0 +1,132 @@
param(
[string]$DockerSshAlias = "docker-test",
[string]$RemoteOutputDir = "/tmp/rap-p3-5-platform-ca",
[string]$LocalCaOutputPath = "artifacts/p3-5-platform-ca.crt",
[string]$WorkerHost = "192.168.200.61",
[string]$WorkerDnsName = "",
[string]$ClusterId = "default",
[string]$WorkerId = "rdp-worker-1",
[int]$Days = 30
)
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
function Quote-Bash {
param([string]$Value)
return "'" + $Value.Replace("'", "'\''") + "'"
}
$remoteDir = Quote-Bash $RemoteOutputDir
$workerHost = Quote-Bash $WorkerHost
$workerDnsName = Quote-Bash $WorkerDnsName
$clusterId = Quote-Bash $ClusterId
$workerId = Quote-Bash $WorkerId
$remoteScript = @"
set -euo pipefail
REMOTE_DIR=$remoteDir
WORKER_HOST=$workerHost
WORKER_DNS_NAME=$workerDnsName
CLUSTER_ID=$clusterId
WORKER_ID=$workerId
DAYS=$Days
mkdir -p "`$REMOTE_DIR"
chmod 700 "`$REMOTE_DIR"
rm -f "`$REMOTE_DIR"/ca.crt "`$REMOTE_DIR"/ca.key "`$REMOTE_DIR"/ca.srl \
"`$REMOTE_DIR"/worker.crt "`$REMOTE_DIR"/worker.key "`$REMOTE_DIR"/worker.csr \
"`$REMOTE_DIR"/ca-openssl.cnf "`$REMOTE_DIR"/worker-openssl.cnf
cat >"`$REMOTE_DIR/ca-openssl.cnf" <<'EOF_CA'
[req]
distinguished_name = dn
x509_extensions = v3_ca
prompt = no
[dn]
CN = RAP Test Platform Root CA
[v3_ca]
basicConstraints = critical,CA:true,pathlen:1
keyUsage = critical,keyCertSign,cRLSign
subjectKeyIdentifier = hash
authorityKeyIdentifier = keyid:always,issuer
EOF_CA
cat >"`$REMOTE_DIR/worker-openssl.cnf" <<EOF_WORKER
[req]
distinguished_name = dn
req_extensions = v3_req
prompt = no
[dn]
CN = `$WORKER_ID
[v3_req]
basicConstraints = critical,CA:false
keyUsage = critical,digitalSignature,keyEncipherment
extendedKeyUsage = serverAuth
subjectAltName = @alt_names
[alt_names]
IP.1 = `$WORKER_HOST
URI.1 = spiffe://rap/cluster/`$CLUSTER_ID/worker/`$WORKER_ID
EOF_WORKER
if [ -n "`$WORKER_DNS_NAME" ]; then
printf 'DNS.1 = %s\n' "`$WORKER_DNS_NAME" >>"`$REMOTE_DIR/worker-openssl.cnf"
fi
openssl genrsa -out "`$REMOTE_DIR/ca.key" 3072 >/dev/null 2>&1
openssl req -x509 -new -nodes -key "`$REMOTE_DIR/ca.key" -sha256 -days "`$DAYS" \
-out "`$REMOTE_DIR/ca.crt" -config "`$REMOTE_DIR/ca-openssl.cnf" >/dev/null 2>&1
openssl genrsa -out "`$REMOTE_DIR/worker.key" 3072 >/dev/null 2>&1
openssl req -new -key "`$REMOTE_DIR/worker.key" -out "`$REMOTE_DIR/worker.csr" \
-config "`$REMOTE_DIR/worker-openssl.cnf" >/dev/null 2>&1
openssl x509 -req -in "`$REMOTE_DIR/worker.csr" -CA "`$REMOTE_DIR/ca.crt" -CAkey "`$REMOTE_DIR/ca.key" \
-CAcreateserial -out "`$REMOTE_DIR/worker.crt" -days "`$DAYS" -sha256 \
-extensions v3_req -extfile "`$REMOTE_DIR/worker-openssl.cnf" >/dev/null 2>&1
if [ -f /tmp/rap-dp1d1/dp-public.pem ]; then
cp /tmp/rap-dp1d1/dp-public.pem "`$REMOTE_DIR/dp-public.pem"
elif [ -f /tmp/rap-dp1c/dp-public.pem ]; then
cp /tmp/rap-dp1c/dp-public.pem "`$REMOTE_DIR/dp-public.pem"
fi
chmod 600 "`$REMOTE_DIR"/ca.key "`$REMOTE_DIR"/worker.key
chmod 644 "`$REMOTE_DIR"/ca.crt "`$REMOTE_DIR"/worker.crt
echo "remote_dir=`$REMOTE_DIR"
echo "ca_cert=`$REMOTE_DIR/ca.crt"
echo "worker_cert=`$REMOTE_DIR/worker.crt"
echo "worker_key=`$REMOTE_DIR/worker.key"
openssl x509 -in "`$REMOTE_DIR/worker.crt" -noout -subject -ext subjectAltName
"@
$remoteScript | & ssh $DockerSshAlias "bash -s"
if ($LASTEXITCODE -ne 0) {
throw "Failed to prepare platform CA and worker certificate on SSH alias '$DockerSshAlias'."
}
$localCaPsPath = if ([System.IO.Path]::IsPathRooted($LocalCaOutputPath)) {
$LocalCaOutputPath
} else {
Join-Path (Resolve-Path -LiteralPath ".").Path $LocalCaOutputPath
}
$resolvedLocalCaPath = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($localCaPsPath)
$localCaDirectory = Split-Path -Parent $resolvedLocalCaPath
if (-not [string]::IsNullOrWhiteSpace($localCaDirectory)) {
New-Item -ItemType Directory -Force -Path $localCaDirectory | Out-Null
}
$remoteCaSource = "$DockerSshAlias`:$RemoteOutputDir/ca.crt"
& scp $remoteCaSource $resolvedLocalCaPath
if ($LASTEXITCODE -ne 0) {
throw "Failed to copy platform CA certificate from '$remoteCaSource'."
}
Write-Host "Local platform CA bundle: $resolvedLocalCaPath"
Write-Host "Remote worker TLS directory: $RemoteOutputDir"
+19
View File
@@ -0,0 +1,19 @@
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
$env:APP_NAME = "rap-api"
$env:APP_ENV = "development"
$env:HTTP_HOST = "0.0.0.0"
$env:HTTP_PORT = "8080"
$env:POSTGRES_DSN = "postgres://rap_user:rap_password@127.0.0.1:5432/remote_access_platform?sslmode=disable"
$env:REDIS_ADDR = "127.0.0.1:6379"
$env:AUTH_ACCESS_TOKEN_SECRET = "local-dev-access-secret"
$env:AUTH_REFRESH_HASH_SECRET = "local-dev-refresh-secret"
Push-Location backend
try {
go run ./cmd/api
}
finally {
Pop-Location
}
+21
View File
@@ -0,0 +1,21 @@
param(
[string]$WorkerId = "rdp-worker-1",
[bool]$InsecureSkipVerify = $false
)
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
docker rm -f $WorkerId 2>$null | Out-Null
$envArgs = @(
"-e", "RDP_WORKER_ID=$WorkerId",
"-e", "RDP_WORKER_REDIS_HOST=host.docker.internal",
"-e", "RDP_WORKER_REDIS_PORT=6379"
)
if ($InsecureSkipVerify) {
$envArgs += @("-e", "RDP_WORKER_INSECURE_SKIP_VERIFY=true")
}
docker run --rm --name $WorkerId @envArgs rap-rdp-worker:dev
+108
View File
@@ -0,0 +1,108 @@
param(
[Parameter(Mandatory = $true)]
[string]$RdpHost,
[int]$RdpPort = 3389,
[Parameter(Mandatory = $true)]
[string]$RdpUsername,
[Parameter(Mandatory = $true)]
[string]$RdpPassword,
[string]$RdpDomain = "",
[ValidateSet("strict", "ignore")]
[string]$CertificateVerificationMode = "strict"
)
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
$userId = [guid]::NewGuid().ToString()
$deviceId = [guid]::NewGuid().ToString()
$resourceId = [guid]::NewGuid().ToString()
$userEmail = "smoke-user-$userId@example.local"
$metadata = @{
rdp_host = $RdpHost
rdp_port = $RdpPort
username = $RdpUsername
password = $RdpPassword
domain = $RdpDomain
certificate_verification_mode = $CertificateVerificationMode
} | ConvertTo-Json -Compress
$sql = @"
INSERT INTO users (id, email, password_hash, mfa_enabled)
VALUES ('$userId'::uuid, '$userEmail', '`$2a`$10`$7EqJtq98hPqEX7fNZaFWoOHi6s6i.5NQ32mibXwjlzAIXazhbugzu', FALSE)
ON CONFLICT (email) DO NOTHING;
INSERT INTO organization_memberships (
id, organization_id, user_id, role_id, status, invited_by_user_id, created_at, updated_at
)
SELECT
gen_random_uuid(),
(SELECT id FROM organizations WHERE slug = 'default'),
'$userId'::uuid,
'org_member',
'active',
NULL,
NOW(),
NOW()
ON CONFLICT (organization_id, user_id) DO UPDATE SET
status = 'active',
updated_at = EXCLUDED.updated_at;
INSERT INTO devices (id, user_id, device_fingerprint, device_label, trust_status, trusted_at, last_seen_at, created_at, updated_at)
VALUES (
'$deviceId'::uuid,
'$userId'::uuid,
'smoke-device-1',
'Smoke Device 1',
'trusted',
NOW(),
NOW(),
NOW(),
NOW()
)
ON CONFLICT (user_id, device_fingerprint) DO UPDATE SET
trust_status = 'trusted',
trusted_at = NOW(),
last_seen_at = NOW(),
updated_at = NOW();
INSERT INTO resources (id, organization_id, name, address, protocol, certificate_verification_mode, metadata, created_at, updated_at)
SELECT
'$resourceId'::uuid,
(SELECT id FROM organizations WHERE slug = 'default'),
'Smoke RDP Resource',
'$RdpHost',
'rdp',
'$CertificateVerificationMode',
'$metadata'::jsonb,
NOW(),
NOW()
ON CONFLICT (id) DO NOTHING;
INSERT INTO resource_policies (
resource_id, max_concurrent_sessions, takeover_policy, require_trusted_device,
detach_grace_period_seconds, clipboard_enabled, file_transfer_enabled, created_at, updated_at
)
VALUES (
'$resourceId'::uuid,
1,
'trusted_device',
TRUE,
1800,
FALSE,
FALSE,
NOW(),
NOW()
)
ON CONFLICT (resource_id) DO UPDATE SET
updated_at = NOW();
"@
$sql | docker exec -i rap_postgres psql -U rap_user -d remote_access_platform -v ON_ERROR_STOP=1 -f -
Write-Host "Seed complete"
Write-Host "email=$userEmail"
Write-Host "user_id=$userId"
Write-Host "device_id=$deviceId"
Write-Host "resource_id=$resourceId"
+4
View File
@@ -0,0 +1,4 @@
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
docker compose up -d postgres redis