diff --git a/.cargo/config.toml b/.cargo/config.toml index f691dd8afef..6580c3c4ffc 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -1,5 +1,5 @@ [target.x86_64-pc-windows-msvc] -rustflags = ["-Ctarget-feature=+crt-static"] +rustflags = ["-Ctarget-feature=+crt-static", "-Clink-arg=/Brepro"] [target.i686-pc-windows-msvc] rustflags = ["-C", "target-feature=+crt-static", "-C", "link-args=/NODEFAULTLIB:MSVCRT"] [target.aarch64-pc-windows-msvc] diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000000..971311b1709 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,86 @@ +# Deny by default: Git ignore rules do not apply to Docker build contexts. +# Keep company environment files, keys, databases, packages and build caches out +# of both the final image and intermediate layers sent to the build daemon. +** +!LICENCE +!product/ +product/** +!product/Cargo.toml +!product/Cargo.lock +!product/protocol/ +product/protocol/** +!product/protocol/Cargo.toml +!product/protocol/src/ +product/protocol/src/** +!product/protocol/src/lib.rs +!product/protocol/src/installation.rs +!product/agent/ +product/agent/** +!product/agent/Cargo.toml +!product/agent/src/ +product/agent/src/** +!product/agent/src/lib.rs +!product/agent/src/main.rs +!product/agent/src/payload.rs +!product/agent/src/reporting.rs +!product/agent/src/technician.rs +!product/agent/src/technician_runtime.rs +!product/agent/src/update.rs +!product/agent/src/verifier_process.rs +!product/management/ +product/management/** +!product/management/Cargo.toml +!product/management/src/ +product/management/src/** +!product/management/src/main.rs +!product/management/src/service.rs +!product/management/src/installation_reporting.rs +!product/management/src/network.rs +!product/management/src/network_probe.rs +!product/management/src/backup.rs +!product/management/src/backup_stream.rs +!product/management/src/windows.rs +!product/management/src/components.rs +!product/management/src/tests.rs +!product/management/src/schema.sql +!product/management/web/ +product/management/web/** +!product/management/web/index.html +!product/management/web/app.js +!product/management/web/style.css +!product/worker/ +product/worker/** +!product/worker/Cargo.toml +!product/worker/src/ +product/worker/src/** +!product/worker/src/main.rs +!product/worker/src/configuration.rs +!product/worker/src/windows.rs +!branding/ +branding/** +!branding/swan-support-mark.svg +!deployment/ +deployment/** +!deployment/windows/ +deployment/windows/** +!deployment/windows/Install-Company.ps1 +!deployment/windows/Install-SwanRemoteSupport.cmd +!deployment/windows/Open-Technician.ps1 +!deployment/windows/Uninstall-Technician.ps1 +!deployment/windows/Verify-MsiRegistration.ps1 +!deployment/windows/Verify-Package.ps1 +!deployment/windows/Get-MsiIdentity.ps1 +!deployment/windows/Get-MsiInstallMode.ps1 +!deployment/windows/Get-WindowsCompatibility.ps1 +!deployment/windows/Restore-ReleaseMsi.ps1 +!deployment/windows/Restore-CustomerExe.ps1 +!deployment/windows/Restore-Configuration.ps1 +!deployment/windows/Restart-Configuration.ps1 +!deployment/windows/Restart-CustomerUi.ps1 +!deployment/windows/Run-SetupUiTask.ps1 +!deployment/windows/Remove-Configuration.ps1 +!deployment/windows/Update-RecoveryTask.ps1 +!deployment/windows/Update-UninstallCancellation.ps1 +!deployment/windows/Commit-Uninstall.ps1 +!deployment/windows/Prepare-CustomerDirectory.ps1 +!deployment/windows/Read-WorkerConfiguration.ps1 diff --git a/.gitattributes b/.gitattributes index 176a458f94e..3847e581db9 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1 +1,7 @@ * text=auto +deployment/transport/** text eol=lf +deployment/windows/flutter-engine* text eol=lf +deployment/windows/fixtures/flutter-sdk-3.24.5/** text eol=lf +deployment/windows/Build-FlutterEngine.ps1 text eol=lf +deployment/windows/Install-FlutterEngineToolchain.ps1 text eol=lf +.github/workflows/swan-flutter-engine.yml text eol=lf diff --git a/.github/workflows/aom-repro-diagnostic.yml b/.github/workflows/aom-repro-diagnostic.yml new file mode 100644 index 00000000000..f707e2c45c0 --- /dev/null +++ b/.github/workflows/aom-repro-diagnostic.yml @@ -0,0 +1,41 @@ +name: Diagnose pinned AV1 object reproducibility + +on: + workflow_dispatch: + +permissions: + contents: read + +jobs: + aom: + runs-on: windows-2022 + strategy: + fail-fast: false + matrix: + replica: [first-runner, independent-runner] + timeout-minutes: 45 + env: + VCPKG_COMMIT_ID: 120deac3062162151622ca4860575a33844ba10b + VCPKG_DEFAULT_HOST_TRIPLET: x64-windows-static + VCPKG_BINARY_SOURCES: clear + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 + with: + persist-credentials: false + - uses: lukka/run-vcpkg@b1a0dd252f06b9e25b3c022a9a03bd7a427fb6a2 + with: + vcpkgDirectory: C:\vcpkg + vcpkgGitCommitId: 120deac3062162151622ca4860575a33844ba10b + doNotCache: true + - name: Build pinned AV1 and repeatedly compile its original source unit + shell: pwsh + run: | + ./deployment/windows/Diagnose-AomObject.ps1 -WorkDirectory (Join-Path $env:RUNNER_TEMP 'swan-aom-object-diagnostic') -RepeatCompiles 24 + - name: Preserve raw objects and effective compiler inputs + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a + with: + name: aom-object-diagnostic-${{ matrix.replica }}-UNSIGNED-TEST + path: ${{ runner.temp }}/swan-aom-object-diagnostic/evidence/** + if-no-files-found: warn + retention-days: 90 diff --git a/.github/workflows/bridge.yml b/.github/workflows/bridge.yml index a7b74fa55a1..75990969844 100644 --- a/.github/workflows/bridge.yml +++ b/.github/workflows/bridge.yml @@ -4,11 +4,16 @@ name: Build flutter-rust-bridge on: workflow_call: + inputs: + include_windows_arm64: + description: Also generate the upstream Flutter 3.44 Windows ARM64 bridge + type: boolean + default: true env: CARGO_EXPAND_VERSION: "1.0.95" FLUTTER_RUST_BRIDGE_VERSION: "1.80.1" - RUST_VERSION: "1.75" # https://github.com/rustdesk/rustdesk/discussions/7503 + RUST_VERSION: "1.90.0" jobs: generate_bridge: @@ -16,23 +21,9 @@ jobs: strategy: fail-fast: false matrix: - job: - # Default bridge for every platform still on Flutter 3.24.5 (generated with 3.22.3). - - { - target: x86_64-unknown-linux-gnu, - os: ubuntu-22.04, - extra-build-args: "", - flutter-version: "3.22.3", - artifact-name: "bridge-artifact", - } - # Dedicated bridge for the Windows arm64 build (Flutter 3.44); runs in parallel. - - { - target: x86_64-unknown-linux-gnu, - os: ubuntu-22.04, - extra-build-args: "", - flutter-version: "3.44.0", - artifact-name: "bridge-artifact-flutter-3.44", - } + # The Swan x64 release uses only the default bridge. Upstream callers + # retain both SDK-specific bridge jobs unless they explicitly opt out. + job: ${{ fromJSON(inputs.include_windows_arm64 && '[{"target":"x86_64-unknown-linux-gnu","os":"ubuntu-22.04","extra-build-args":"","flutter-version":"3.22.3","artifact-name":"bridge-artifact"},{"target":"x86_64-unknown-linux-gnu","os":"ubuntu-22.04","extra-build-args":"","flutter-version":"3.44.0","artifact-name":"bridge-artifact-flutter-3.44"}]' || '[{"target":"x86_64-unknown-linux-gnu","os":"ubuntu-22.04","extra-build-args":"","flutter-version":"3.22.3","artifact-name":"bridge-artifact"}]') }} steps: - name: Checkout source code uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 @@ -114,3 +105,12 @@ jobs: ./flutter/lib/generated_bridge.freezed.dart ./flutter/macos/Runner/bridge_generated.h ./flutter/ios/Runner/bridge_generated.h + + # Review and commit dependency resolution before native --locked builds. + # Never overwrite a checked-out lockfile from this artifact during builds. + - name: Export resolved dependency lock for review + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: ${{ matrix.job.artifact-name }}-resolved-lock + path: ./Cargo.lock + if-no-files-found: error diff --git a/.github/workflows/check-service-bootstrap.yml b/.github/workflows/check-service-bootstrap.yml new file mode 100644 index 00000000000..bfb0c108428 --- /dev/null +++ b/.github/workflows/check-service-bootstrap.yml @@ -0,0 +1,41 @@ +name: Check Windows service bootstrap +on: + workflow_dispatch: + push: + paths: + - .github/workflows/check-service-bootstrap.yml + - flutter/windows/runner/service_bootstrap.* + - deployment/windows/service-bootstrap-tests/** + - deployment/windows/test_service_bootstrap.ps1 +permissions: + contents: read +jobs: + scm-loader: + runs-on: windows-2022 + timeout-minutes: 15 + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - name: Compile isolated service fixtures + shell: pwsh + run: | + cmake -S deployment/windows/service-bootstrap-tests -B bootstrap-build -A x64 + if ($LASTEXITCODE) { throw 'CMake configuration failed' } + cmake --build bootstrap-build --config Release + if ($LASTEXITCODE) { throw 'Fixture build failed' } + - name: Exercise real service manager and loader boundary + shell: pwsh + run: | + ./deployment/windows/test_service_bootstrap.ps1 -BuildDirectory bootstrap-build/Release -OutputDirectory "$env:RUNNER_TEMP/bootstrap-results" + - name: Collect fixture source, unsigned binaries and receipts + if: always() + uses: actions/upload-artifact@v4 + with: + name: windows-service-bootstrap-unsigned-fixtures + path: | + bootstrap-build/Release/*.exe + bootstrap-build/Release/*.dll + deployment/windows/test_service_bootstrap.ps1 + deployment/windows/service-bootstrap-tests/ + flutter/windows/runner/service_bootstrap.* + ${{ runner.temp }}/bootstrap-results/** + if-no-files-found: error diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 173eda9f456..96b2e8e65a4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,6 +24,10 @@ on: - "appimage/**" - "flatpak/**" +concurrency: + group: ${{ github.workflow }}-${{ github.event_name == 'workflow_dispatch' && github.run_id || github.ref }} + cancel-in-progress: ${{ github.event_name != 'workflow_dispatch' }} + jobs: # ensure_cargo_fmt: # name: Ensure 'cargo fmt' has been run diff --git a/.github/workflows/company-product.yml b/.github/workflows/company-product.yml new file mode 100644 index 00000000000..0558057230a --- /dev/null +++ b/.github/workflows/company-product.yml @@ -0,0 +1,294 @@ +name: Company product components +on: + workflow_dispatch: + inputs: + technician_msi_only: + description: Test the pinned unsigned technician MSI on a disposable Windows runner + type: boolean + default: false + technician_msi_os: + description: Disposable Windows runner for the MSI lifecycle test + type: choice + options: [windows-2022, windows-2025] + default: windows-2022 + push: + paths: ['product/**', 'deployment/**', 'libs/portable/build.rs', 'libs/portable/resource_order.rs', '.cargo/config.toml', '.dockerignore', '.github/workflows/company-product.yml'] + pull_request: + paths: ['product/**', 'deployment/**', 'libs/portable/build.rs', 'libs/portable/resource_order.rs', '.cargo/config.toml', '.dockerignore', '.github/workflows/company-product.yml'] +permissions: + contents: read + actions: read +concurrency: + group: ${{ github.workflow }}-${{ github.event_name == 'workflow_dispatch' && github.run_id || github.ref }} + cancel-in-progress: ${{ github.event_name != 'workflow_dispatch' }} +jobs: + maintained-windows-transport: + if: github.event_name != 'workflow_dispatch' || !inputs.technician_msi_only + uses: ./.github/workflows/swan-transport.yml + with: + windows_only: true + + verify: + needs: maintained-windows-transport + if: github.event_name != 'workflow_dispatch' || !inputs.technician_msi_only + strategy: + matrix: + os: [ubuntu-24.04, windows-2022] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - name: Install pinned Rust + run: rustup toolchain install 1.90.0 --profile minimal + - name: Reject randomized portable resource metadata ordering + shell: bash + run: | + rustc +1.90.0 --edition 2021 --test libs/portable/resource_order.rs -o "$RUNNER_TEMP/portable-resource-tests.exe" + "$RUNNER_TEMP/portable-resource-tests.exe" + - name: Preserve company component source inputs + shell: bash + run: | + python -m unittest discover -s deployment/windows -p test_build_source.py + python -m unittest discover -s deployment/windows -p test_customer_msi.py + python -m pip install --disable-pip-version-check brotli==1.1.0 + python -m unittest discover -s deployment/windows -p test_customer_exe.py + mkdir -p component-artifacts + python deployment/windows/archive-build-source.py . component-artifacts/CompanyComponents-source.zip --components-only + - name: Check customer EXE native output handling in Windows PowerShell + if: runner.os == 'Windows' + shell: pwsh + run: | + powershell.exe -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -File deployment/windows/test_customer_exe_native_output.ps1 -PythonExe (Get-Command python -CommandType Application).Source + if ($LASTEXITCODE -ne 0) { throw 'Customer EXE native-output regression failed.' } + - name: Reject mismatched maintained Windows binaries and corresponding source + shell: bash + run: python -m unittest discover -s deployment/transport -p test_windows_components.py + - name: Verify real bounded SYSTEM setup UI task broker + if: runner.os == 'Windows' + shell: pwsh + run: | + powershell.exe -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -File deployment/windows/test_setup_ui_broker.ps1 + if ($LASTEXITCODE -ne 0) { throw 'Explicit setup UI SYSTEM broker checks failed.' } + - name: Test security and management + run: cargo +1.90.0 test --manifest-path product/Cargo.toml --workspace --locked + - name: Verify real HTTPS update streaming and interrupted download recovery + if: runner.os == 'Windows' + shell: pwsh + run: | + ./deployment/windows/test_update_download.ps1 -OutputDirectory "${{ runner.temp }}/swan-update-download-test" + Copy-Item -LiteralPath "${{ runner.temp }}/swan-update-download-test/verified.json" -Destination component-artifacts/update-download-https.json + - name: Restore an incompressible backup above the former 512 MiB limit + if: runner.os == 'Windows' + shell: bash + run: | + set -o pipefail + cargo +1.90.0 test --manifest-path product/Cargo.toml --locked -p swan-management backup::stream::tests::backup_above_previous_limit_preserves_incompressible_artifact -- --exact --ignored 2>&1 | tee component-artifacts/large-backup-tests.log + grep -F 'test result: ok. 1 passed; 0 failed;' component-artifacts/large-backup-tests.log + - name: Verify installation status escaping and sign-out isolation + run: node deployment/web/test-installation-status.js + - name: Verify first-run branding and policy form contracts + run: node deployment/web/test-company-setup.js + - name: Verify bounded pinned archive retries and integrity rejection + if: runner.os == 'Windows' + shell: pwsh + run: | + powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -File ./deployment/windows/test_pinned_archive.ps1 + if ($LASTEXITCODE -ne 0) { throw 'Pinned download retry and integrity checks failed.' } + - name: Build components + run: cargo +1.90.0 build --manifest-path product/Cargo.toml --workspace --release --locked + - name: Verify compiled standalone network checker trust and isolation + if: runner.os == 'Windows' + shell: pwsh + run: | + powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -File ./deployment/windows/test_network_probe.ps1 -ManagementExecutable ./product/target/release/swan-management.exe -OutputDirectory "${{ runner.temp }}/swan-network-probe-test" + if ($LASTEXITCODE -ne 0) { throw 'Compiled network checker checks failed.' } + Copy-Item -LiteralPath "${{ runner.temp }}/swan-network-probe-test/verified.json" -Destination component-artifacts/network-probe-https.json + - name: Verify compiled worker private configuration and registry downgrade rejection + if: runner.os == 'Windows' + shell: pwsh + run: | + powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -File ./deployment/windows/test_worker_configuration.ps1 -WorkerExecutable ./product/target/release/swan-worker.exe -ResultPath ./component-artifacts/worker-private-configuration.json + - name: Verify the real backup CLI above 512 MiB with bounded address space + if: runner.os == 'Linux' + run: python3 deployment/linux/test_backup_memory.py product/target/release/swan-management component-artifacts/large-backup-cli.json + - name: Verify compiled management creates fresh private server state + if: runner.os == 'Windows' + shell: pwsh + run: | + powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -File ./deployment/windows/test_server_directory.ps1 -ManagementExe ./product/target/release/swan-management.exe + if ($LASTEXITCODE -ne 0) { throw 'Compiled server directory checks failed.' } + - name: Verify company server removal ownership and data preservation + if: runner.os == 'Windows' + shell: pwsh + run: | + ./deployment/windows/test_server_removal_plan.ps1 + powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -File ./deployment/windows/test_server_removal_plan.ps1 + if ($LASTEXITCODE -ne 0) { throw 'Windows PowerShell removal-plan checks failed.' } + - name: Verify technician offline launcher and package rejection + if: runner.os == 'Windows' + shell: pwsh + run: | + powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -File ./deployment/windows/test_technician_launcher.ps1 + if ($LASTEXITCODE -ne 0) { throw 'Windows PowerShell technician launcher checks failed.' } + - name: Verify uninstall commit and rollback preservation + if: runner.os == 'Windows' + shell: pwsh + run: | + powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -File ./deployment/windows/test_uninstall_commit.ps1 + if ($LASTEXITCODE -ne 0) { throw 'Uninstall commit checks failed.' } + - name: Verify customer EXE uninstall completion + if: runner.os == 'Windows' + shell: pwsh + run: | + powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -File ./deployment/windows/test_customer_exe_uninstall.ps1 + if ($LASTEXITCODE -ne 0) { throw 'Customer EXE uninstall completion checks failed.' } + - name: Verify private customer state creation and unsafe-state rejection + if: runner.os == 'Windows' + shell: pwsh + run: | + powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -File ./deployment/windows/test_customer_directory.ps1 -RequireAdministratorCases + if ($LASTEXITCODE -ne 0) { throw 'Customer state directory checks failed.' } + - name: Verify company setup entry point and trust rejection + if: runner.os == 'Windows' + shell: pwsh + run: | + powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -File ./deployment/windows/test_company_setup_entrypoint.ps1 + if ($LASTEXITCODE -ne 0) { throw 'Company setup entry-point checks failed.' } + powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -File ./deployment/windows/test_msi_install_mode.ps1 + if ($LASTEXITCODE -ne 0) { throw 'MSI version preflight checks failed.' } + powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -File ./deployment/windows/test_company_watch_recovery.ps1 + if ($LASTEXITCODE -ne 0) { throw 'Company configuration watcher recovery checks failed.' } + powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -File ./deployment/windows/test_update_recovery_task.ps1 + if ($LASTEXITCODE -ne 0) { throw 'Update recovery task ownership and lifecycle checks failed.' } + - name: Render the Windows setup wizard without installation + if: runner.os == 'Windows' + shell: pwsh + run: | + powershell.exe -NoProfile -NonInteractive -STA -ExecutionPolicy Bypass -File ./deployment/windows/Setup-Server.ps1 -RenderPreview "$env:RUNNER_TEMP/server-setup-preview.png" + if ($LASTEXITCODE -ne 0) { throw 'Server setup preview failed.' } + - name: Restore maintained Windows transport and corresponding source + if: runner.os == 'Windows' + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: swan-transport-candidate-windows-2022 + path: ${{ runner.temp }}/server-maintained-transport + + - name: Verify unsigned server setup reproducibility with pinned compiler + if: runner.os == 'Windows' + shell: pwsh + run: | + $pins = Get-Content deployment/windows/server-components.json -Raw | ConvertFrom-Json + $compiler = Get-Content deployment/windows/server-compiler.json -Raw | ConvertFrom-Json + $references = Get-Content deployment/windows/server-references.json -Raw | ConvertFrom-Json + $https = Join-Path $env:RUNNER_TEMP 'server-repro-https.zip' + $toolset = Join-Path $env:RUNNER_TEMP 'server-repro-roslyn.nupkg' + $framework = Join-Path $env:RUNNER_TEMP 'server-repro-framework.nupkg' + $powershell = Join-Path $env:RUNNER_TEMP 'server-repro-powershell.nupkg' + ./deployment/windows/Get-PinnedArchive.ps1 -Uri $pins.https.url -Sha256 $pins.https.sha256 -Output $https | Out-Null + ./deployment/windows/Get-PinnedArchive.ps1 -Uri $compiler.url -Sha256 $compiler.sha256 -Output $toolset | Out-Null + ./deployment/windows/Get-PinnedArchive.ps1 -Uri $references.framework.url -Sha256 $references.framework.sha256 -Output $framework | Out-Null + ./deployment/windows/Get-PinnedArchive.ps1 -Uri $references.powershell.url -Sha256 $references.powershell.sha256 -Output $powershell | Out-Null + $components = Join-Path $env:RUNNER_TEMP 'server-repro-components' + ./deployment/windows/Prepare-ServerComponents.ps1 -TransportDirectory (Join-Path $env:RUNNER_TEMP 'server-maintained-transport') -HttpsPackage $https -OutputDirectory $components + $evidence = Join-Path $env:RUNNER_TEMP 'server-repro-evidence' + ./deployment/windows/test_server_setup_reproducibility.ps1 -ManagementExecutable product/target/release/swan-management.exe -ComponentsDirectory $components -CompilerPackage $toolset -FrameworkReferencesPackage $framework -PowerShellReferencesPackage $powershell -WorkDirectory $evidence + Copy-Item -LiteralPath (Join-Path $evidence 'result.json') -Destination component-artifacts/server-setup-repro.json + Copy-Item -LiteralPath (Join-Path $evidence 'packaged-preview.png') -Destination component-artifacts/server-setup-packaged-preview.png + + - name: Record unsigned component hashes and build provenance + shell: bash + run: | + python - <<'PY' + import hashlib, json, os, pathlib, shutil, subprocess + root = pathlib.Path('component-artifacts') + suffix = '.exe' if os.name == 'nt' else '' + hashes = {} + for name in ('swan-management', 'swan-agent', 'swan-worker'): + source = pathlib.Path('product/target/release') / (name + suffix) + destination = root / source.name + shutil.copyfile(source, destination) + hashes[destination.name] = hashlib.sha256(destination.read_bytes()).hexdigest() + hashes['CompanyComponents-source.zip'] = hashlib.sha256((root / 'CompanyComponents-source.zip').read_bytes()).hexdigest() + record = {'schema': 1, 'source': os.environ['GITHUB_SHA'], 'repository': os.environ['GITHUB_REPOSITORY'], + 'rustc': subprocess.check_output(['rustc', '+1.90.0', '-Vv'], text=True), + 'unsigned_test_inputs': True, 'production_approved': False, 'sha256': hashes} + (root / 'SOURCE.json').write_text(json.dumps(record, indent=2), encoding='utf-8') + PY + - name: Upload unsigned company component test inputs and corresponding source + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: CompanyComponents-${{ matrix.os }}-UNSIGNED-TEST + path: component-artifacts/ + if-no-files-found: error + linux-deployment: + if: github.event_name != 'workflow_dispatch' || !inputs.technician_msi_only + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - name: Verify bounded pinned image pulls without retrying unknown download state + run: python3 -m unittest discover -s deployment/linux -p test_pinned_images.py + - name: Verify private files cannot enter the Docker build context + run: python3 deployment/linux/test_build_context.py + - name: Build the company management image + run: docker build --file deployment/linux/Dockerfile --tag swan-management:test . + - name: Exercise fresh company setup and persistence in the deployment image + run: python3 deployment/linux/test_container_startup.py swan-management:test + - name: Rehearse pinned HTTPS and transport components with an isolated test CA + run: python3 deployment/linux/test_container_stack.py swan-management:test + - name: Build maintained transport from pinned public source with corresponding source + run: docker build --file deployment/transport/Dockerfile --tag swan-transport:maintained-test . + - name: Preserve maintained Linux transport binaries and exact patched source + run: python3 deployment/linux/export_transport_candidate.py transport-artifacts + - name: Rehearse maintained transport with HTTPS, enrollment and complete backup recovery + run: python3 deployment/linux/test_container_stack.py swan-management:test --maintained-transport + - name: Retain unsigned Linux transport candidate and source + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: MaintainedTransport-Linux-x64-UNSIGNED-TEST + path: transport-artifacts/ + if-no-files-found: error + technician-msi-lifecycle: + if: github.event_name == 'workflow_dispatch' && inputs.technician_msi_only + runs-on: ${{ inputs.technician_msi_os || 'windows-2022' }} + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - name: Verify the pinned completed unsigned build + uses: actions/github-script@d7906e4ad0b1822421a7e6a35d5ca353c962f410 # v6 + with: + script: | + const run = await github.rest.actions.getWorkflowRun({owner: context.repo.owner, repo: context.repo.repo, run_id: 37110881152}); + if (run.data.conclusion !== 'success' || run.data.head_sha !== 'c7d118c4b655d401c14fd95672ae2aff3b5ed679') { + throw new Error('Pinned native build provenance failed'); + } + - name: Download the pinned unsigned build inputs + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: SwanRemoteSupport-1.5.0-x64-unsigned + run-id: 37110881152 + github-token: ${{ github.token }} + path: artifacts + - name: Verify payload hashes before MSI execution + shell: pwsh + run: | + $expected = @{} + foreach ($line in Get-Content artifacts/SHA256SUMS.txt) { + if ($line -match '^([a-f0-9]{64})\s+\*?(.+)$') { + if ($expected.ContainsKey($Matches[2])) { throw 'Duplicate artifact checksum.' } + $expected[$Matches[2]] = $Matches[1] + } + } + # This older build's checksum list covers installers only. Pin its + # agent hash from the independently verified GitHub artifact archive. + $expected['swan-agent.exe'] = 'c54da22a3d70f337d8a28ffb940549dbedfa303503a552b92d30b5a536d2ce26' + foreach ($name in @('SwanRemoteSupport-1.5.0-technician-x64-unsigned.msi','SwanRemoteSupport-1.5.0-technician-x64-unsigned.exe','swan-agent.exe')) { + if (-not $expected.ContainsKey($name) -or (Get-FileHash -LiteralPath (Join-Path artifacts $name) -Algorithm SHA256).Hash.ToLowerInvariant() -ne $expected[$name]) { throw 'Pinned MSI payload checksum failed.' } + } + - name: Test unsigned MSI install repair and uninstall + shell: pwsh + run: ./deployment/windows/test_technician_msi_lifecycle.ps1 -Package artifacts/SwanRemoteSupport-1.5.0-technician-x64-unsigned.msi -TechnicianExecutable artifacts/SwanRemoteSupport-1.5.0-technician-x64-unsigned.exe -AgentExecutable artifacts/swan-agent.exe + - name: Preserve MSI lifecycle evidence + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: technician-msi-lifecycle-${{ inputs.technician_msi_os || 'windows-2022' }} + if-no-files-found: ignore + path: ${{ runner.temp }}/swan-msi-lifecycle-*/** diff --git a/.github/workflows/compare-native-builds.yml b/.github/workflows/compare-native-builds.yml new file mode 100644 index 00000000000..dfd4803e673 --- /dev/null +++ b/.github/workflows/compare-native-builds.yml @@ -0,0 +1,97 @@ +name: Compare independent native build artifacts + +on: + push: + paths: + - .github/workflows/compare-native-builds.yml + - deployment/windows/compare-native-builds.py + - deployment/windows/test_native_build_comparison.py + pull_request: + paths: + - .github/workflows/compare-native-builds.yml + - deployment/windows/compare-native-builds.py + - deployment/windows/test_native_build_comparison.py + workflow_dispatch: + inputs: + first_run: + description: First successful native workflow run ID + required: true + type: string + second_run: + description: Independent successful native workflow run ID + required: true + type: string + source: + description: Exact public 40-character source commit shared by both runs + required: true + type: string + +permissions: + contents: read + actions: read + +jobs: + tests: + runs-on: ubuntu-24.04 + timeout-minutes: 5 + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + persist-credentials: false + - name: Verify comparison trust boundaries + run: python3 -m unittest discover -s deployment/windows -p test_native_build_comparison.py + + compare: + if: github.event_name == 'workflow_dispatch' + needs: tests + runs-on: ubuntu-24.04 + timeout-minutes: 15 + steps: + - name: Check out comparison tools + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + persist-credentials: false + + - name: Validate independent build identities + env: + FIRST_RUN: ${{ inputs.first_run }} + SECOND_RUN: ${{ inputs.second_run }} + SOURCE_COMMIT: ${{ inputs.source }} + run: | + python3 - <<'PY' + import os, re + first, second = os.environ['FIRST_RUN'], os.environ['SECOND_RUN'] + if not all(re.fullmatch(r'[1-9][0-9]*', n) for n in (first, second)) or first == second: + raise SystemExit('Two different positive native run IDs are required') + if not re.fullmatch(r'[0-9a-f]{40}', os.environ['SOURCE_COMMIT']): + raise SystemExit('An exact public source commit is required') + PY + + - name: Check out exact public source and pinned submodules + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + ref: ${{ inputs.source }} + path: source-proof + submodules: recursive + persist-credentials: false + + - name: Verify original archives and compare every shipping output + env: + GITHUB_TOKEN: ${{ github.token }} + FIRST_RUN: ${{ inputs.first_run }} + SECOND_RUN: ${{ inputs.second_run }} + SOURCE_COMMIT: ${{ inputs.source }} + run: | + python3 deployment/windows/compare-native-builds.py \ + --first-run "$FIRST_RUN" --second-run "$SECOND_RUN" \ + --source "$SOURCE_COMMIT" --checkout source-proof \ + --work comparison-inputs --output evidence/native-build-comparison.json + + - name: Preserve comparison report even when original binaries differ + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: native-comparison-${{ inputs.first_run }}-${{ inputs.second_run }} + path: evidence/native-build-comparison.json + if-no-files-found: warn + retention-days: 90 diff --git a/.github/workflows/flutter-build.yml b/.github/workflows/flutter-build.yml index 2491789e6a4..898ab07ff0e 100644 --- a/.github/workflows/flutter-build.yml +++ b/.github/workflows/flutter-build.yml @@ -19,8 +19,8 @@ on: env: SCITER_RUST_VERSION: "1.75" # https://github.com/rustdesk/rustdesk/discussions/7503, also 1.78 has ABI change which causes our sciter version not working, https://blog.rust-lang.org/2024/03/30/i128-layout-update.html - RUST_VERSION: "1.75" # sciter failed on m1 with 1.78 because of https://blog.rust-lang.org/2024/03/30/i128-layout-update.html - MAC_RUST_VERSION: "1.81" # 1.81 is requred for macos, because of https://github.com/yury/cidre requires 1.81 + RUST_VERSION: "1.90.0" # Matches the managed product's pinned minimum toolchain. + MAC_RUST_VERSION: "1.90.0" CARGO_NDK_VERSION: "3.1.2" SCITER_ARMV7_CMAKE_VERSION: "3.29.7" SCITER_NASM_DEBVERSION: "2.15.05-1" @@ -209,7 +209,7 @@ jobs: - name: Install Rust toolchain uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # v1 with: - toolchain: ${{ env.SCITER_RUST_VERSION }} + toolchain: ${{ env.RUST_VERSION }} targets: ${{ matrix.job.target }} components: "rustfmt" @@ -415,7 +415,8 @@ jobs: - name: Install Rust toolchain uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # v1 with: - toolchain: nightly-2023-10-13-${{ matrix.job.target }} # must use nightly here, because of abi_thiscall feature required + # Run the host compiler and cross-compile for the matrix target below. + toolchain: nightly-2023-10-13 # abi_thiscall requires nightly targets: ${{ matrix.job.target }} components: "rustfmt" diff --git a/.github/workflows/flutter-ci.yml b/.github/workflows/flutter-ci.yml index a64dd11972a..d37bae20c1e 100644 --- a/.github/workflows/flutter-ci.yml +++ b/.github/workflows/flutter-ci.yml @@ -17,6 +17,10 @@ on: - "appimage/**" - "flatpak/**" +concurrency: + group: ${{ github.workflow }}-${{ github.event_name == 'workflow_dispatch' && github.run_id || github.ref }} + cancel-in-progress: ${{ github.event_name != 'workflow_dispatch' }} + jobs: run-ci: uses: ./.github/workflows/flutter-build.yml diff --git a/.github/workflows/msvc-determinism.yml b/.github/workflows/msvc-determinism.yml new file mode 100644 index 00000000000..69192df200a --- /dev/null +++ b/.github/workflows/msvc-determinism.yml @@ -0,0 +1,38 @@ +name: Diagnose MSVC callback reproducibility +on: + workflow_dispatch: + pull_request: + paths: + - .github/workflows/msvc-determinism.yml + - deployment/windows/test_msvc_determinism.ps1 + - deployment/windows/patch-flutter-client-wrapper.py + - deployment/windows/test_flutter_client_wrapper.py + - deployment/windows/emit-flutter-reply-fixture.py + - deployment/windows/fixtures/flutter_reply_handler.cpp.in +permissions: + contents: read +jobs: + fixture: + runs-on: windows-2022 + timeout-minutes: 15 + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 + with: + persist-credentials: false + - name: Compare actual callback and RTTI bytes across build directories + shell: pwsh + run: | + python3 -m unittest discover -s deployment/windows -p test_flutter_client_wrapper.py + if ($LASTEXITCODE) { throw 'Client-wrapper source trust regressions failed.' } + ./deployment/windows/test_msvc_determinism.ps1 -WorkDirectory (Join-Path $env:RUNNER_TEMP 'swan-msvc-repro-fixture') + - name: Retain original fixture outputs and diagnostics + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a + with: + name: msvc-callback-reproducibility-UNSIGNED-TEST + path: | + ${{ runner.temp }}/swan-msvc-repro-fixture/report.json + ${{ runner.temp }}/swan-msvc-repro-fixture/*.log + ${{ runner.temp }}/swan-msvc-repro-fixture/*.arguments.json + ${{ runner.temp }}/swan-msvc-repro-fixture/*.exe + if-no-files-found: warn diff --git a/.github/workflows/portable-install-wait.yml b/.github/workflows/portable-install-wait.yml new file mode 100644 index 00000000000..65a0a17094c --- /dev/null +++ b/.github/workflows/portable-install-wait.yml @@ -0,0 +1,41 @@ +name: Portable installer wait regression +on: + push: + paths: ['libs/portable/src/install_wait.rs', 'libs/portable/src/runtime_cache.rs', '.github/workflows/portable-install-wait.yml'] + pull_request: + paths: ['libs/portable/src/install_wait.rs', 'libs/portable/src/runtime_cache.rs', '.github/workflows/portable-install-wait.yml'] + workflow_dispatch: +permissions: + contents: read +jobs: + verify: + strategy: + matrix: + os: [ubuntu-24.04, windows-2022] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - name: Install pinned Rust + run: rustup toolchain install 1.90.0 --profile minimal + - name: Test actual child exit and wait in standalone and embedded module contexts + shell: bash + run: | + python - <<'PY' + import json, os, pathlib, subprocess + source = pathlib.Path('libs/portable/src/install_wait.rs').resolve() + work = pathlib.Path(os.environ['RUNNER_TEMP']) / 'portable-install-wait' + work.mkdir() + wrapper = work / 'embedded.rs' + wrapper.write_text('#[path = ' + json.dumps(source.as_posix()) + ']\nmod install_wait;\n', encoding='utf-8') + for context, input_file, prefix in [('standalone', source, 'tests::'), ('embedded', wrapper, 'install_wait::tests::')]: + executable = work / (context + '.exe') + subprocess.run(['rustc', '+1.90.0', '--edition', '2021', '--test', str(input_file), '-o', str(executable)], check=True) + names = subprocess.check_output([str(executable), '--list'], text=True) + assert prefix + 'child_fixture: test' in names + subprocess.run([str(executable)], check=True) + PY + - name: Test payload cache isolation while the previous runtime file is held open + shell: bash + run: | + rustc +1.90.0 --edition 2021 --test libs/portable/src/runtime_cache.rs -o portable-cache-tests.exe + ./portable-cache-tests.exe diff --git a/.github/workflows/swan-flutter-engine.yml b/.github/workflows/swan-flutter-engine.yml new file mode 100644 index 00000000000..f59f4ce9e89 --- /dev/null +++ b/.github/workflows/swan-flutter-engine.yml @@ -0,0 +1,55 @@ +name: Rebuild patched Windows Flutter engine from pinned source +on: + workflow_dispatch: + push: + paths: + - .github/workflows/swan-flutter-engine.yml + - deployment/windows/Build-FlutterEngine.ps1 + - deployment/windows/flutter-engine-source.py + - deployment/windows/flutter-engine-windows.patch + - deployment/windows/flutter-engine.lock.json + - deployment/windows/flutter-engine-buildroot.patch + - deployment/windows/Install-FlutterEngineToolchain.ps1 + - deployment/windows/test_flutter_engine_source.py +permissions: + contents: read +concurrency: + group: swan-engine-source-${{ github.ref }} + cancel-in-progress: false +jobs: + source-engine: + runs-on: windows-2022 + timeout-minutes: 180 + steps: + - name: Keep reviewed engine recipe bytes unchanged + shell: pwsh + run: git config --global core.autocrlf false + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + persist-credentials: false + - name: Test source and toolchain provenance guards + shell: pwsh + run: python -m unittest discover -s deployment/windows -p test_flutter_engine_source.py + - name: Install the compatible pinned Microsoft C++ toolset + shell: pwsh + run: ./deployment/windows/Install-FlutterEngineToolchain.ps1 + - name: Build source reconstruction candidate + shell: pwsh + run: ./deployment/windows/Build-FlutterEngine.ps1 -BuildRoot C:\SwanEngineSourceBuild + - name: Publish candidate and exact source evidence + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: swan-flutter-engine-source-built-${{ github.sha }} + path: C:\SwanEngineSourceBuild\artifacts\* + if-no-files-found: error + - name: Preserve build diagnostics on failure + if: failure() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: swan-flutter-engine-diagnostics-${{ github.sha }} + path: | + C:\SwanEngineSourceBuild\toolchain.json + C:\SwanEngineSourceBuild\dependency-revisions.txt + C:\SwanEngineSourceBuild\ninja-commands.txt + C:\SwanEngineSourceBuild\link-response.txt + if-no-files-found: warn diff --git a/.github/workflows/swan-transport.yml b/.github/workflows/swan-transport.yml new file mode 100644 index 00000000000..e072bf02442 --- /dev/null +++ b/.github/workflows/swan-transport.yml @@ -0,0 +1,75 @@ +name: Verify and build candidate Swan transport +on: + workflow_call: + inputs: + windows_only: + type: boolean + default: false + workflow_dispatch: + pull_request: + paths: ['deployment/transport/**', '.github/workflows/swan-transport.yml'] +permissions: + contents: read +concurrency: + group: swan-transport-${{ github.workflow }}-${{ github.ref }}-${{ github.event_name }} + cancel-in-progress: false +jobs: + transport: + timeout-minutes: 60 + strategy: + fail-fast: false + matrix: + os: ${{ fromJSON(inputs.windows_only && '["windows-2022"]' || '["windows-2022","ubuntu-24.04"]') }} + runs-on: ${{ matrix.os }} + defaults: + run: + shell: bash + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - name: Checkout pinned public transport source and submodule + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + repository: rustdesk/rustdesk-server + ref: 9bae9f2f39d92c4b4ba2e28e089da5071897b22e + submodules: recursive + path: transport-source + persist-credentials: false + - name: Install pinned Rust + run: rustup toolchain install 1.90.0 --profile minimal + - name: Install Linux build dependencies + if: runner.os == 'Linux' + run: | + sudo apt-get update + sudo apt-get install -y pkg-config libssl-dev libsodium-dev + - name: Add regression to clean pinned source + run: python deployment/transport/prepare_source.py transport-source tests + - name: Reproduce Windows failure and verify IPv4 and IPv6 controls + run: python deployment/transport/check_baseline.py transport-source + - name: Apply reviewed destination normalization + run: python deployment/transport/prepare_source.py transport-source fix + - name: Verify actual shared UDP message and raw send paths + run: python deployment/transport/check_baseline.py transport-source fixed + - name: Preserve exact public build inputs + run: python deployment/transport/archive_source.py transport-source transport-artifacts/transport-build-source.zip + - name: Build candidate rendezvous and relay executables + working-directory: transport-source + run: cargo +1.90.0 build --release --locked --bin hbbs --bin hbbr + - name: Retain regression evidence + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: transport-regression-${{ matrix.os }} + path: transport-source/swan-evidence/** + if-no-files-found: ignore + - name: Retain unsigned candidate and source + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: swan-transport-candidate-${{ matrix.os }} + path: | + transport-source/target/release/hbbs + transport-source/target/release/hbbs.exe + transport-source/target/release/hbbr + transport-source/target/release/hbbr.exe + transport-source/SWAN-SOURCE.json + transport-artifacts/transport-build-source.zip + if-no-files-found: error diff --git a/.github/workflows/swan-windows-build.yml b/.github/workflows/swan-windows-build.yml index 2a2af7ebdcb..dccf7068c75 100644 --- a/.github/workflows/swan-windows-build.yml +++ b/.github/workflows/swan-windows-build.yml @@ -2,21 +2,39 @@ name: Build Swan Remote Support for Windows x64 on: workflow_dispatch: + inputs: + engine: + description: Experimental source engine requires a new matching app bundle and lab qualification + type: choice + default: reviewed-upstream + options: + - reviewed-upstream + - source-candidate-9f97ef0 + - source-candidate-05d5369 permissions: contents: read + actions: read env: - RUST_VERSION: "1.75" + RUST_VERSION: "1.90.0" LLVM_VERSION: "15.0.6" FLUTTER_VERSION: "3.24.5" + FLUTTER_ENGINE_SHA256: "ec8cabf36ee4ff24c8d98de25b00e70781eb03876265aee84d0fe554a110036e" VCPKG_COMMIT_ID: "120deac3062162151622ca4860575a33844ba10b" - VCPKG_BINARY_SOURCES: "clear;x-gha,readwrite" - VERSION: "1.4.9" + VCPKG_BINARY_SOURCES: "clear" + VERSION: "1.5.23" jobs: + maintained-transport: + uses: ./.github/workflows/swan-transport.yml + with: + windows_only: true + generate-bridge: uses: ./.github/workflows/bridge.yml + with: + include_windows_arm64: false build-topmost-window: uses: ./.github/workflows/third-party-RustDeskTempTopMostWindow.yml @@ -29,16 +47,10 @@ jobs: build-windows-x64: name: Swan Remote Support x64 - needs: [generate-bridge, build-topmost-window] + needs: [generate-bridge, build-topmost-window, maintained-transport] runs-on: windows-2022 env: SWAN_BUILD: "1" - # Public Tailscale routing/trust defaults. Never put a private server key, - # unattended password, token, or signing credential in this section. - RENDEZVOUS_SERVER: "100.82.236.84" - RELAY_SERVER: "100.82.236.84" - RS_PUB_KEY: "I0s1JvqJ19WDNKBLFY+FC5HekcOqNWPpH+6xGmKUQLI=" - API_SERVER: "" steps: - name: Export GitHub Actions cache environment variables uses: actions/github-script@d7906e4ad0b1822421a7e6a35d5ca353c962f410 # v6 @@ -52,34 +64,27 @@ jobs: with: submodules: recursive - - name: Validate public server configuration + - name: Verify application and installer versions agree + env: + SWAN_ENGINE_SELECTION: ${{ inputs.engine }} shell: pwsh run: | - if ([string]::IsNullOrWhiteSpace($env:RENDEZVOUS_SERVER)) { - throw "Set the RENDEZVOUS_SERVER repository Actions variable to the public hbbs hostname." - } - if ($env:RENDEZVOUS_SERVER -match "^https?://") { - throw "RENDEZVOUS_SERVER must be a hostname or host:port, without http:// or https://." - } - if ([string]::IsNullOrWhiteSpace($env:RS_PUB_KEY)) { - throw "Set the RS_PUB_KEY repository Actions variable to the contents of id_ed25519.pub." - } - try { - $decodedKey = [Convert]::FromBase64String($env:RS_PUB_KEY) - } catch { - throw "RS_PUB_KEY is not valid Base64: $($_.Exception.Message)" - } - if ($decodedKey.Length -ne 32) { - throw "RS_PUB_KEY must decode to a 32-byte Ed25519 public key; received $($decodedKey.Length) bytes." + if ($env:SWAN_ENGINE_SELECTION -cnotin @('reviewed-upstream','source-candidate-9f97ef0','source-candidate-05d5369')) { + throw 'Unknown engine selection; use an explicitly pinned candidate.' } - if (-not [string]::IsNullOrWhiteSpace($env:API_SERVER)) { - $apiUri = $null - if (-not [Uri]::TryCreate($env:API_SERVER, [UriKind]::Absolute, [ref]$apiUri) -or - $apiUri.Scheme -ne "https") { - throw "API_SERVER must be an absolute HTTPS URL when supplied." - } + $sourceVersion = [regex]::Match((Get-Content Cargo.toml -Raw), '(?m)^version = "([0-9]+\.[0-9]+\.[0-9]+)"').Groups[1].Value + $packerVersion = [regex]::Match((Get-Content libs/portable/Cargo.toml -Raw), '(?m)^version = "([0-9]+\.[0-9]+\.[0-9]+)"').Groups[1].Value + $flutterVersion = [regex]::Match((Get-Content flutter/pubspec.yaml -Raw), '(?m)^version: ([0-9]+\.[0-9]+\.[0-9]+)\+[0-9]+').Groups[1].Value + if (-not $sourceVersion -or $sourceVersion -cne $env:VERSION -or $packerVersion -cne $sourceVersion -or $flutterVersion -cne $sourceVersion) { + throw 'Application, portable packer, Flutter and installer artifact versions must agree.' } - Write-Output "Public Swan server configuration passed structural validation." + + - name: Set reproducible UTC application build date from public source + shell: pwsh + run: | + $epoch = (git show -s --format=%ct HEAD).Trim() + if ($LASTEXITCODE -ne 0 -or $epoch -cnotmatch '^[0-9]+$') { throw 'Source commit timestamp is unavailable.' } + Add-Content -LiteralPath $env:GITHUB_ENV -Value ('SOURCE_DATE_EPOCH='+$epoch) - name: Restore Flutter bridge files uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 @@ -92,21 +97,56 @@ jobs: with: version: "${{ env.LLVM_VERSION }}" - - name: Install Flutter - uses: subosito/flutter-action@2783a3f08e1baf891508463f8c6653c258246225 # v2.12.0 - with: - channel: stable - flutter-version: "${{ env.FLUTTER_VERSION }}" - architecture: x64 + - name: Install hash-pinned Flutter SDK + shell: pwsh + run: | + python -m unittest discover -s deployment/windows -p test_pinned_flutter_sdk.py + if ($LASTEXITCODE -ne 0) { throw 'Pinned SDK rejection tests failed.' } + ./deployment/windows/install-pinned-flutter.ps1 -Destination (Join-Path $env:RUNNER_TEMP 'swan-flutter-sdk') -ExpectedVersion $env:FLUTTER_VERSION + New-Item -ItemType Directory -Path artifacts -Force | Out-Null + Copy-Item -LiteralPath (Join-Path $env:RUNNER_TEMP 'swan-flutter-sdk/sdk-identity.json') -Destination artifacts/flutter-sdk-identity.json - name: Replace Flutter engine with RustDesk Windows engine + if: inputs.engine == 'reviewed-upstream' shell: pwsh run: | flutter doctor -v flutter precache --windows Invoke-WebRequest -Uri https://github.com/rustdesk/engine/releases/download/main/windows-x64-release.zip -OutFile windows-x64-release.zip + if ((Get-FileHash windows-x64-release.zip -Algorithm SHA256).Hash -ne $env:FLUTTER_ENGINE_SHA256) { + throw 'Pinned Flutter engine hash mismatch. Review the upstream artifact before changing this pin.' + } Expand-Archive -Path windows-x64-release.zip -DestinationPath windows-x64-release - Move-Item -Path windows-x64-release\* -Destination C:\hostedtoolcache\windows\flutter\stable-${{ env.FLUTTER_VERSION }}-x64\bin\cache\artifacts\engine\windows-x64-release\ -Force + Move-Item -Path windows-x64-release\* -Destination (Join-Path $env:FLUTTER_ROOT 'bin/cache/artifacts/engine/windows-x64-release') -Force + + - name: Download the exact experimental source-engine build + if: inputs.engine == 'source-candidate-9f97ef0' + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: swan-flutter-engine-source-built-9f97ef0053d822bcf5cf3f5f03bf5dc571247d16 + run-id: 39716739857 + github-token: ${{ github.token }} + path: ./artifacts/engine-source + + - name: Download the independently reproduced experimental source engine + if: inputs.engine == 'source-candidate-05d5369' + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: swan-flutter-engine-source-built-05d5369c04804b9a9b2dc06df0cb85b0efe6f274 + run-id: 39753153773 + github-token: ${{ github.token }} + path: ./artifacts/engine-source + + - name: Select experimental engine with the matching SDK snapshot compiler + if: inputs.engine == 'source-candidate-9f97ef0' || inputs.engine == 'source-candidate-05d5369' + env: + SWAN_ENGINE_CANDIDATE: ${{ inputs.engine }} + shell: pwsh + run: | + flutter doctor -v + flutter precache --windows + python -m unittest discover -s deployment/windows -p test_engine_candidate.py + python deployment/windows/select-engine-candidate.py --candidate artifacts/engine-source --sdk "$env:FLUTTER_ROOT" --receipt artifacts/engine-selection.json --source-candidate "$env:SWAN_ENGINE_CANDIDATE" - name: Patch Flutter 3.24 dropdown support shell: bash @@ -115,6 +155,15 @@ jobs: cd "$(dirname "$(dirname "$(which flutter)")")" git apply flutter_3.24.4_dropdown_menu_enableFilter.diff + - name: Pin the named Flutter client-wrapper reply callable + shell: pwsh + run: | + python3 -m unittest discover -s deployment/windows -p test_flutter_client_wrapper.py + if ($LASTEXITCODE -ne 0) { throw 'Flutter reply-wrapper source trust regressions failed.' } + New-Item -ItemType Directory -Path artifacts -Force | Out-Null + python3 deployment/windows/patch-flutter-client-wrapper.py --sdk "$env:FLUTTER_ROOT" --receipt artifacts/flutter-client-wrapper-repro.json + if ($LASTEXITCODE -ne 0) { throw 'Reviewed Flutter reply-wrapper source patch failed.' } + - name: Install Rust toolchain uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # v1 with: @@ -122,17 +171,19 @@ jobs: targets: x86_64-pc-windows-msvc components: rustfmt - - name: Cache Rust build - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 - with: - prefix-key: windows-2022-swan-x64 - - name: Set up pinned vcpkg uses: lukka/run-vcpkg@b1a0dd252f06b9e25b3c022a9a03bd7a427fb6a2 # v11 with: vcpkgDirectory: C:\vcpkg vcpkgGitCommitId: "${{ env.VCPKG_COMMIT_ID }}" - doNotCache: false + doNotCache: true + + - name: Require fresh endpoint and native-library outputs + shell: pwsh + run: | + if ((Test-Path target) -or (Test-Path (Join-Path $env:VCPKG_ROOT 'installed/x64-windows-static/lib/aom.lib'))) { + throw 'Release verification requires a fresh runner without restored compiled endpoint or AV1 outputs.' + } - name: Install native dependencies env: @@ -146,18 +197,171 @@ jobs: exit 1 fi + - name: Preserve exact public build inputs including generated bridge source + shell: pwsh + run: | + python3 -m unittest discover -s deployment/windows -p test_build_source.py + if ($LASTEXITCODE -ne 0) { throw 'Corresponding source archive checks failed.' } + New-Item -ItemType Directory -Path artifacts -Force | Out-Null + python3 deployment/windows/archive-build-source.py . artifacts/SwanRemoteSupport-build-source.zip + if ($LASTEXITCODE -ne 0) { throw 'Exact build input archive failed.' } + + - name: Retain actual AV1 object and static-library inputs before linking + shell: pwsh + run: | + ./deployment/windows/Capture-NativeAomInputs.ps1 -OutputDirectory artifacts/native-aom-inputs + + - name: Record exact default MSVC compiler library and header inputs + shell: pwsh + run: | + ./deployment/windows/Record-MsvcToolchain.ps1 -Output artifacts/native-msvc-toolchain.json + - name: Build branded client shell: pwsh run: | python3 .\build.py --portable --flutter --skip-portable-pack --hwcodec --vram + if ($LASTEXITCODE -ne 0) { throw 'Native or Flutter application build failed.' } + $capture = Get-Content artifacts/native-aom-inputs/capture.json -Raw | ConvertFrom-Json + $installedLibraryHash = (Get-FileHash -LiteralPath $capture.installed_library_path).Hash.ToLowerInvariant() + if ($installedLibraryHash -cne $capture.files.'aom.lib'.sha256) { throw 'AV1 library changed during endpoint compilation.' } + $rustLibrary = Get-FileHash target/release/librustdesk.dll + $bundleLibrary = Get-FileHash flutter/build/windows/x64/runner/Release/librustdesk.dll + if ($rustLibrary.Hash -cne $bundleLibrary.Hash) { throw 'Bundled native DLL differs from the original compiled Rust DLL.' } + @{compiled_native_dll_sha256=$rustLibrary.Hash.ToLowerInvariant();bundled_native_dll_sha256=$bundleLibrary.Hash.ToLowerInvariant();static_library_unchanged=$true;shipping_bytes_rewritten=$false} | ConvertTo-Json | Set-Content artifacts/native-aom-inputs/endpoint-link-provenance.json -Encoding UTF8 Move-Item .\flutter\build\windows\x64\runner\Release .\swan-client - - name: Add signed topmost-window helper + - name: Preserve native input diagnosis when endpoint compilation fails + if: failure() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a + with: + name: native-aom-inputs-failed-native-build + path: artifacts/native-aom-inputs/** + if-no-files-found: warn + + - name: Keep pending customer approvals visible during reconnects + shell: pwsh + working-directory: flutter + run: | + flutter test --reporter expanded test/approval_window_auto_hide_test.dart test/company_window_title_test.dart 2>&1 | Tee-Object -FilePath ../artifacts/native-customer-approval-window-tests.log + if ($LASTEXITCODE -ne 0) { throw 'Customer approval-window regression failed.' } + if ((Get-Content ../artifacts/native-customer-approval-window-tests.log -Raw) -notmatch 'All tests passed!') { throw 'Expected customer approval tests did not complete.' } + + - name: Verify signed display branding preserves internal identity + shell: pwsh + run: | + cargo test --release --lib --features "flutter,swan_custom,hwcodec,vram" managed::branding_tests::signed_display_names_do_not_change_identity_or_authorize_expired_policy -- --exact 2>&1 | Tee-Object -FilePath artifacts/native-managed-branding-tests.log + if ($LASTEXITCODE -ne 0) { throw 'Signed company display branding regression failed.' } + if ((Get-Content artifacts/native-managed-branding-tests.log -Raw) -notmatch '1 passed; 0 failed') { throw 'Expected signed company branding test did not execute.' } + + - name: Reject unsigned legacy management policy in managed editions + shell: pwsh + run: | + cargo test --release --lib --features "flutter,swan_custom,hwcodec,vram" hbbs_http::sync::managed_sync_tests::managed_sync_rejects_legacy_endpoint_and_unsigned_options -- --exact 2>&1 | Tee-Object -FilePath artifacts/native-managed-sync-tests.log + if ($LASTEXITCODE -ne 0) { throw 'Managed legacy-sync regression failed.' } + if ((Get-Content artifacts/native-managed-sync-tests.log -Raw) -notmatch 'test result: ok\. 1 passed; 0 failed;') { throw 'Expected managed legacy-sync test did not run.' } + + - name: Reject upstream updater entry points in managed editions + shell: pwsh + run: | + cargo test --release --lib --features "flutter,swan_custom,hwcodec,vram" updater::managed_update_tests::managed_edition_rejects_every_legacy_update_entry_point -- --exact 2>&1 | Tee-Object -FilePath artifacts/native-managed-updater-tests.log + if ($LASTEXITCODE -ne 0) { throw 'Managed updater isolation regression failed.' } + if ((Get-Content artifacts/native-managed-updater-tests.log -Raw) -notmatch 'test result: ok\. 1 passed; 0 failed;') { throw 'Expected managed updater isolation test did not run.' } + + - name: Reject incoming capture helpers in the technician edition + shell: pwsh + run: | + cargo test --release --lib --features "flutter,swan_custom,hwcodec,vram" server::portable_service::client::edition_tests::outgoing_only_refuses_capture_helper_before_runtime_initialization -- --exact 2>&1 | Tee-Object -FilePath artifacts/native-technician-helper-tests.log + if ($LASTEXITCODE -ne 0) { throw 'Technician incoming-helper isolation regression failed.' } + if ((Get-Content artifacts/native-technician-helper-tests.log -Raw) -notmatch 'test result: ok\. 1 passed; 0 failed;') { throw 'Expected technician helper test did not run.' } + + - name: Reject indefinitely blocked file-transfer writes + shell: pwsh + run: | + cargo test --release --lib --features "flutter,swan_custom,hwcodec,vram" client::io_loop::transfer_write_tests:: -- --nocapture 2>&1 | Tee-Object -FilePath artifacts/native-transfer-write-timeout-tests.log + if ($LASTEXITCODE -ne 0) { throw 'File-transfer write timeout regression failed.' } + if ((Get-Content artifacts/native-transfer-write-timeout-tests.log -Raw) -notmatch 'test result: ok\. 3 passed; 0 failed;') { throw 'Expected all three transfer write tests to run.' } + + - name: Verify hardened DLL search supports delayed application libraries + shell: pwsh + run: | + cargo test --release --lib --features "flutter,swan_custom,hwcodec,vram" platform::windows::dll_search_tests::hardened_search_loads_application_dlls_but_rejects_cwd_and_path -- --exact 2>&1 | Tee-Object -FilePath artifacts/native-dll-search-tests.log + if ($LASTEXITCODE -ne 0) { throw 'Hardened application DLL search regression failed.' } + if ((Get-Content artifacts/native-dll-search-tests.log -Raw) -notmatch 'test result: ok\. 1 passed; 0 failed;') { throw 'Expected real Windows DLL-search test did not run.' } + + - name: Verify desktop launcher and plugin reproducibility metadata + shell: pwsh + run: | + python3 -m unittest discover -s deployment/windows -p test_reproducible_pe.py + if ($LASTEXITCODE -ne 0) { throw 'PE metadata verifier tests failed.' } + $targets = @((Get-Item swan-client/rustdesk.exe)) + @(Get-ChildItem swan-client -Filter '*_plugin.dll' -File) + if ($targets.Count -lt 2) { throw 'Expected the desktop launcher and native Flutter plugins.' } + $records = @() + foreach ($target in $targets) { + $record = python3 deployment/windows/verify-reproducible-pe.py $target.FullName + if ($LASTEXITCODE -ne 0) { throw ('Native desktop link metadata is not reproducible: '+$target.Name) } + $records += @{name=$target.Name;metadata=($record | ConvertFrom-Json)} + } + $records | ConvertTo-Json -Depth 5 | Set-Content artifacts/native-desktop-pe-metadata.json + + - name: Verify service launcher defers desktop DLL loading + shell: pwsh + run: | + python3 -m unittest discover -s deployment/windows -p test_service_imports.py + if ($LASTEXITCODE -ne 0) { throw 'Service import verifier tests failed.' } + $record = python3 deployment/windows/verify-service-imports.py swan-client/rustdesk.exe --bundle swan-client + if ($LASTEXITCODE -ne 0) { throw 'Service launcher eagerly loads desktop DLLs.' } + $record | Set-Content artifacts/native-service-imports.json + + - name: Verify deterministic managed application build metadata + shell: pwsh + run: | + cargo test --release --lib --features "flutter,swan_custom,hwcodec,vram" build_metadata::tests:: 2>&1 | Tee-Object -FilePath artifacts/native-build-metadata-tests.log + if ($LASTEXITCODE -ne 0) { throw 'Managed build metadata regressions failed.' } + if ((Get-Content artifacts/native-build-metadata-tests.log -Raw) -notmatch 'test result: ok\. 3 passed; 0 failed;') { throw 'Expected managed build metadata tests did not run.' } + + - name: Reject system-control packets from view-only managed sessions + shell: pwsh + run: | + cargo test --release --lib --features "flutter,swan_custom,hwcodec,vram" server::connection::test::managed_view_only_packets_cannot_change_system_control -- --exact 2>&1 | Tee-Object -FilePath artifacts/native-managed-control-tests.log + if ($LASTEXITCODE -ne 0) { throw 'Managed view-only control regression failed.' } + if ((Get-Content artifacts/native-managed-control-tests.log -Raw) -notmatch 'test result: ok\. 1 passed; 0 failed;') { throw 'Expected managed control test did not run.' } + + - name: Reject paused support and authorization reuse after resume + shell: pwsh + run: | + cargo test --release --lib --features "flutter,swan_custom,hwcodec,vram" managed::pause_tests::pause_denies_claims_and_resume_cannot_revive_old_authorization -- --exact 2>&1 | Tee-Object -FilePath artifacts/native-managed-pause-tests.log + if ($LASTEXITCODE -ne 0) { throw 'Managed support pause regression failed.' } + if ((Get-Content artifacts/native-managed-pause-tests.log -Raw) -notmatch 'test result: ok\. 1 passed; 0 failed;') { throw 'Expected managed support pause test did not run.' } + + - name: Reject authorization retries and inherited audit destinations + shell: pwsh + run: | + cargo test --release --lib --features "flutter,swan_custom,hwcodec,vram" client::managed_retry_tests::authorization_closure_never_retries_but_network_fallback_remains -- --exact 2>&1 | Tee-Object -FilePath artifacts/native-managed-retry-tests.log + if ($LASTEXITCODE -ne 0) { throw 'Managed authorization retry regression failed.' } + if ((Get-Content artifacts/native-managed-retry-tests.log -Raw) -notmatch 'test result: ok\. 1 passed; 0 failed;') { throw 'Expected managed authorization retry test did not run.' } + cargo test --release --lib --features "flutter,swan_custom,hwcodec,vram" common::tests::managed_edition_never_derives_legacy_audit_targets -- --exact 2>&1 | Tee-Object -FilePath artifacts/native-managed-audit-tests.log + if ($LASTEXITCODE -ne 0) { throw 'Managed legacy audit regression failed.' } + if ((Get-Content artifacts/native-managed-audit-tests.log -Raw) -notmatch 'test result: ok\. 1 passed; 0 failed;') { throw 'Expected managed legacy audit test did not run.' } + + - name: Reject cross-target and stale-login file-transfer handles + shell: pwsh + run: | + cargo test --release --lib --features "flutter,swan_custom,hwcodec,vram" managed::subsession_tests::file_transfer_handle_is_bound_to_target_company_and_current_login -- --exact 2>&1 | Tee-Object -FilePath artifacts/native-managed-subsession-tests.log + if ($LASTEXITCODE -ne 0) { throw 'Managed file-transfer handle regression failed.' } + if ((Get-Content artifacts/native-managed-subsession-tests.log -Raw) -notmatch 'test result: ok\. 1 passed; 0 failed;') { throw 'Expected managed file-transfer handle test did not run.' } + + - name: Add pinned topmost-window helper uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: topmostwindow-artifacts-x64 path: ./swan-client + - name: Retain helper corresponding source and exact build metadata + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: topmostwindow-source-x64 + path: ./artifacts/topmost-source + - name: Build self-extracting installer shell: bash run: | @@ -170,29 +374,189 @@ jobs: sed -i '/dpiAware/d' res/manifest.xml pushd ./libs/portable pip3 install -r requirements.txt + python3 -m unittest discover -s . -p test_generate.py + rustc --edition 2021 --test resource_order.rs -o resource-order-tests.exe + ./resource-order-tests.exe python3 ./generate.py -f ../../swan-client/ -o . -e ../../swan-client/rustdesk.exe + cargo test --locked --release popd mkdir -p ./artifacts # The portable packer opens RustDesk's one-page installer when the # filename ends in "install.exe". mv ./target/release/rustdesk-portable-packer.exe ./artifacts/SwanRemoteSupport-${{ env.VERSION }}-x64-unsigned-install.exe + # Both editions use unchanged project binaries. A pinned technician + # bootstrap selects its authenticated outgoing-only interface. The + # technician filename launches the portable app instead of customer setup. + cp ./artifacts/SwanRemoteSupport-${{ env.VERSION }}-x64-unsigned-install.exe ./artifacts/SwanRemoteSupport-${{ env.VERSION }}-technician-x64-unsigned.exe pushd ./artifacts sha256sum SwanRemoteSupport-*.exe | tee SHA256SUMS.txt popd - - name: Add express installation helpers + - name: Generate unsigned installed payload manifests + shell: pwsh + run: | + python3 -m unittest discover -s deployment/windows -p test_payload_manifest.py + python3 -m unittest discover -s deployment/windows -p test_customer_msi.py + python3 -m unittest discover -s deployment/windows -p test_customer_exe.py + & "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe" -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -File deployment/windows/test_customer_exe_native_output.ps1 -PythonExe (Get-Command python3 -CommandType Application).Source + if ($LASTEXITCODE -ne 0) { throw 'Customer EXE native-output regression failed.' } + & "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe" -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -File deployment/windows/test_company_setup_entrypoint.ps1 + if ($LASTEXITCODE -ne 0) { throw 'Company setup entry-point checks failed.' } + & "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe" -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -File deployment/windows/test_msi_install_mode.ps1 + if ($LASTEXITCODE -ne 0) { throw 'MSI version preflight checks failed.' } + & "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe" -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -File deployment/windows/test_company_watch_recovery.ps1 + if ($LASTEXITCODE -ne 0) { throw 'Company configuration watcher recovery checks failed.' } + & "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe" -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -File deployment/windows/test_msi_service_guard.ps1 + if ($LASTEXITCODE -ne 0) { throw 'Customer MSI recovery service guard checks failed.' } + & "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe" -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -File deployment/windows/test_update_recovery_task.ps1 + if ($LASTEXITCODE -ne 0) { throw 'Update recovery task ownership checks failed.' } + rustc --edition 2021 --test libs/portable/src/install_wait.rs -o portable-install-wait-tests.exe + if ($LASTEXITCODE -ne 0) { throw 'Portable installer wait tests did not compile.' } + ./portable-install-wait-tests.exe + if ($LASTEXITCODE -ne 0) { throw 'Portable installer completion tests failed.' } + rustc --edition 2021 --test libs/portable/src/runtime_cache.rs -o portable-runtime-cache-tests.exe + if ($LASTEXITCODE -ne 0) { throw 'Portable runtime cache tests did not compile.' } + ./portable-runtime-cache-tests.exe + if ($LASTEXITCODE -ne 0) { throw 'Portable runtime cache isolation tests failed.' } + python3 deployment/windows/generate-payload-manifest.py --edition customer --source swan-client --output artifacts/customer-installed-files.unsigned.json + python3 deployment/windows/generate-payload-manifest.py --edition technician --source artifacts/SwanRemoteSupport-${{ env.VERSION }}-technician-x64-unsigned.exe --output artifacts/technician-installed-files.unsigned.json + + - name: Build company management and distribution components + shell: pwsh + run: | + cargo test --manifest-path product/Cargo.toml --locked -p swan-agent update::tests::powershell_verifier_does_not_inherit_background_priority -- --exact + if ($LASTEXITCODE -ne 0) { throw 'Windows trust-helper scheduling regression failed.' } + cargo build --manifest-path product/Cargo.toml --workspace --release --locked + Copy-Item product/target/release/swan-*.exe artifacts/ + Copy-Item deployment/windows/Install-Company.ps1,deployment/windows/Open-Technician.ps1,deployment/windows/Uninstall-Technician.ps1,deployment/windows/Verify-Package.ps1,deployment/windows/Get-WindowsCompatibility.ps1 artifacts/ + Copy-Item deployment/windows/Install-Worker.ps1,deployment/windows/Uninstall-Worker.ps1,deployment/windows/Read-WorkerConfiguration.ps1,deployment/windows/Migrate-WorkerCredential.ps1,deployment/windows/Prepare-CustomerDirectory.ps1 artifacts/ + Copy-Item LICENCE artifacts/ + + - name: Build unsigned customer and technician MSIs with pinned WiX + id: package-clients + shell: pwsh + run: | + $toolPackage = Join-Path $env:RUNNER_TEMP 'wix-cli-x64-5.0.2.msi' + Invoke-WebRequest -Uri https://github.com/wixtoolset/wix/releases/download/v5.0.2/wix-cli-x64.msi -OutFile $toolPackage + $toolDirectory = Join-Path $env:RUNNER_TEMP 'swan-wix-5.0.2' + $wix = ./deployment/windows/Expand-WixBuildTools.ps1 -Package $toolPackage -OutputDirectory $toolDirectory -SevenZip 'C:/Program Files/7-Zip/7z.exe' + $output = 'artifacts/SwanRemoteSupport-${{ env.VERSION }}-technician-x64-unsigned.msi' + ./deployment/windows/Build-TechnicianMsi.ps1 -WixExe $wix -TechnicianExe 'artifacts/SwanRemoteSupport-${{ env.VERSION }}-technician-x64-unsigned.exe' -AgentExe artifacts/swan-agent.exe -Version '${{ env.VERSION }}' -Output $output -UnsignedTest -SourceDateEpoch $env:SOURCE_DATE_EPOCH + $hash = (Get-FileHash -LiteralPath $output -Algorithm SHA256).Hash.ToLowerInvariant() + Add-Content artifacts/SHA256SUMS.txt ($hash+' '+[IO.Path]::GetFileName($output)) + $customerOutput = 'artifacts/SwanRemoteSupport-${{ env.VERSION }}-customer-x64-unsigned.msi' + ./deployment/windows/Build-CustomerMsi.ps1 -WixExe $wix -SourceDirectory swan-client -AgentExe artifacts/swan-agent.exe -Version '${{ env.VERSION }}' -Output $customerOutput -UnsignedTest -SourceDateEpoch $env:SOURCE_DATE_EPOCH + $customerHash = (Get-FileHash -LiteralPath $customerOutput -Algorithm SHA256).Hash.ToLowerInvariant() + Add-Content artifacts/SHA256SUMS.txt ($customerHash+' '+[IO.Path]::GetFileName($customerOutput)) + $sourceHash = (Get-FileHash -LiteralPath artifacts/SwanRemoteSupport-build-source.zip -Algorithm SHA256).Hash.ToLowerInvariant() + Add-Content artifacts/SHA256SUMS.txt ($sourceHash+' SwanRemoteSupport-build-source.zip') + + - name: Verify MSI input staging and deterministic metadata guards + shell: powershell + run: | + ./deployment/windows/test_msi_staging.ps1 -WorkDirectory (Join-Path $env:RUNNER_TEMP 'swan-msi-staging-check') + $raw = 'artifacts/SwanRemoteSupport-${{ env.VERSION }}-technician-x64-unsigned.msi.raw-unsigned.msi' + ./deployment/windows/test_msi_metadata.ps1 -FirstMsi $raw -SecondMsi $raw -WorkDirectory (Join-Path $env:RUNNER_TEMP 'swan-msi-metadata-check') -SourceDateEpoch $env:SOURCE_DATE_EPOCH + + - name: Verify unsigned technician MSI installation repair and uninstall + shell: pwsh + run: | + ./deployment/windows/test_technician_msi_lifecycle.ps1 -Package 'artifacts/SwanRemoteSupport-${{ env.VERSION }}-technician-x64-unsigned.msi' -TechnicianExecutable 'artifacts/SwanRemoteSupport-${{ env.VERSION }}-technician-x64-unsigned.exe' -AgentExecutable artifacts/swan-agent.exe + + - name: Verify customer MSI starts a headless native service shell: pwsh run: | - Copy-Item .\scripts\Install-SwanRemoteSupport.ps1 .\artifacts\ - Copy-Item .\scripts\Install-SwanRemoteSupport.cmd .\artifacts\ - Copy-Item .\scripts\Install-SwanRemoteSupport-UNSIGNED-TEST.cmd .\artifacts\ - Copy-Item .\scripts\Run-Swan-Technician-UNSIGNED-TEST.cmd .\artifacts\ - Copy-Item .\scripts\Enable-SwanUnattendedAccess.ps1 .\artifacts\ - Copy-Item .\scripts\Disable-SwanUnattendedAccess.ps1 .\artifacts\ - Copy-Item .\docs\CUSTOMER_INSTALL.md .\artifacts\ - Copy-Item .\docs\UNSIGNED_SPARE_LAPTOP_TEST.md .\artifacts\ - Copy-Item .\docs\PRIVACY.md .\artifacts\ - Copy-Item .\docs\SOURCE_AND_LICENSE.md .\artifacts\ + ./deployment/windows/test_customer_service_startup.ps1 -Package 'artifacts/SwanRemoteSupport-${{ env.VERSION }}-customer-x64-unsigned.msi' -CustomerExecutable swan-client/rustdesk.exe + + - name: Preserve technician MSI lifecycle evidence + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: technician-msi-lifecycle + if-no-files-found: ignore + path: | + ${{ runner.temp }}/swan-msi-lifecycle-*/** + ${{ runner.temp }}/swan-customer-service-*/** + ${{ runner.temp }}/swan-msi-staging-check/result.json + ${{ runner.temp }}/swan-msi-metadata-check/result.json + + - name: Retain completed client inputs before independent server packaging + if: always() && steps.package-clients.conclusion == 'success' + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: SwanRemoteSupport-${{ env.VERSION }}-client-inputs-x64-UNSIGNED-TEST + if-no-files-found: error + path: | + artifacts/SwanRemoteSupport-*.exe + artifacts/SwanRemoteSupport-${{ env.VERSION }}-technician-x64-unsigned.msi + artifacts/SwanRemoteSupport-${{ env.VERSION }}-customer-x64-unsigned.msi + artifacts/SwanRemoteSupport-build-source.zip + artifacts/*.msi.build.json + artifacts/*.msi.installed-files.json + artifacts/SHA256SUMS.txt + artifacts/swan-*.exe + artifacts/*-installed-files.unsigned.json + artifacts/topmost-source/** + artifacts/engine-source/** + artifacts/engine-selection.json + artifacts/native-desktop-pe-metadata.json + artifacts/native-service-imports.json + artifacts/native-dll-search-tests.log + artifacts/native-transfer-write-timeout-tests.log + artifacts/native-customer-approval-window-tests.log + artifacts/native-managed-branding-tests.log + artifacts/native-msvc-toolchain.json + artifacts/native-aom-inputs/** + artifacts/flutter-sdk-identity.json + artifacts/flutter-client-wrapper-repro.json + artifacts/native-managed-updater-tests.log + artifacts/native-managed-pause-tests.log + artifacts/native-managed-subsession-tests.log + artifacts/Install-Company.ps1 + artifacts/Install-Worker.ps1 + artifacts/Read-WorkerConfiguration.ps1 + artifacts/Migrate-WorkerCredential.ps1 + artifacts/Prepare-CustomerDirectory.ps1 + artifacts/Uninstall-Worker.ps1 + artifacts/Open-Technician.ps1 + artifacts/Uninstall-Technician.ps1 + artifacts/Verify-Package.ps1 + artifacts/Get-WindowsCompatibility.ps1 + artifacts/LICENCE + + - name: Restore maintained Windows transport and corresponding source + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: swan-transport-candidate-windows-2022 + path: ${{ runner.temp }}/swan-maintained-transport + + - name: Build unsigned company server setup with pinned components + shell: powershell + run: | + $ErrorActionPreference = 'Stop' + $pins = Get-Content deployment/windows/server-components.json -Raw | ConvertFrom-Json + $https = Join-Path $env:RUNNER_TEMP 'swan-server-https.zip' + ./deployment/windows/test_pinned_archive.ps1 + ./deployment/windows/Get-PinnedArchive.ps1 -Uri $pins.https.url -Sha256 $pins.https.sha256 -Output $https | Out-Null + $components = Join-Path $env:RUNNER_TEMP 'swan-server-components' + ./deployment/windows/Prepare-ServerComponents.ps1 -TransportDirectory (Join-Path $env:RUNNER_TEMP 'swan-maintained-transport') -HttpsPackage $https -OutputDirectory $components + $compilerPin = Get-Content deployment/windows/server-compiler.json -Raw | ConvertFrom-Json + $references = Get-Content deployment/windows/server-references.json -Raw | ConvertFrom-Json + $compilerPackage = Join-Path $env:RUNNER_TEMP 'swan-server-roslyn-4.12.0.nupkg' + $frameworkPackage = Join-Path $env:RUNNER_TEMP 'swan-server-framework-references.nupkg' + $powershellPackage = Join-Path $env:RUNNER_TEMP 'swan-server-powershell-references.nupkg' + ./deployment/windows/Get-PinnedArchive.ps1 -Uri $compilerPin.url -Sha256 $compilerPin.sha256 -Output $compilerPackage | Out-Null + ./deployment/windows/Get-PinnedArchive.ps1 -Uri $references.framework.url -Sha256 $references.framework.sha256 -Output $frameworkPackage | Out-Null + ./deployment/windows/Get-PinnedArchive.ps1 -Uri $references.powershell.url -Sha256 $references.powershell.sha256 -Output $powershellPackage | Out-Null + $setup = ./deployment/windows/Build-ServerSetup.ps1 -ManagementExecutable artifacts/swan-management.exe -ComponentsDirectory $components -CompilerPackage $compilerPackage -FrameworkReferencesPackage $frameworkPackage -PowerShellReferencesPackage $powershellPackage -OutputDirectory artifacts/server-setup -UnsignedTest + $reproEvidence = Join-Path $env:RUNNER_TEMP 'swan-server-setup-repro' + ./deployment/windows/test_server_setup_reproducibility.ps1 -ManagementExecutable artifacts/swan-management.exe -ComponentsDirectory $components -CompilerPackage $compilerPackage -FrameworkReferencesPackage $frameworkPackage -PowerShellReferencesPackage $powershellPackage -WorkDirectory $reproEvidence + Copy-Item -LiteralPath (Join-Path $reproEvidence 'result.json') -Destination artifacts/server-setup-repro.json + Copy-Item -LiteralPath (Join-Path $reproEvidence 'packaged-preview.png') -Destination artifacts/server-setup-packaged-preview.png + $hash = (Get-FileHash -LiteralPath $setup -Algorithm SHA256).Hash.ToLowerInvariant() + Copy-Item -LiteralPath $setup -Destination artifacts/SwanServerSetup-UNSIGNED-TEST.exe + Copy-Item -LiteralPath artifacts/server-setup/SOURCE.json -Destination artifacts/SwanServerSetup-SOURCE.json + Add-Content artifacts/SHA256SUMS.txt ($hash+' SwanServerSetup-UNSIGNED-TEST.exe') - name: Upload unsigned installer and checksum uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 @@ -201,14 +565,40 @@ jobs: if-no-files-found: error path: | artifacts/SwanRemoteSupport-${{ env.VERSION }}-x64-unsigned-install.exe + artifacts/SwanRemoteSupport-${{ env.VERSION }}-technician-x64-unsigned.exe + artifacts/SwanRemoteSupport-${{ env.VERSION }}-technician-x64-unsigned.msi + artifacts/SwanRemoteSupport-${{ env.VERSION }}-customer-x64-unsigned.msi + artifacts/SwanServerSetup-UNSIGNED-TEST.exe + artifacts/SwanServerSetup-SOURCE.json + artifacts/server-setup-repro.json + artifacts/server-setup-packaged-preview.png + artifacts/SwanRemoteSupport-build-source.zip + artifacts/topmost-source/** + artifacts/engine-source/** + artifacts/engine-selection.json + artifacts/native-desktop-pe-metadata.json + artifacts/native-service-imports.json + artifacts/native-dll-search-tests.log + artifacts/native-transfer-write-timeout-tests.log + artifacts/native-customer-approval-window-tests.log + artifacts/native-managed-branding-tests.log + artifacts/native-msvc-toolchain.json + artifacts/native-aom-inputs/** + artifacts/*.msi.build.json + artifacts/flutter-sdk-identity.json + artifacts/flutter-client-wrapper-repro.json + artifacts/*.msi.installed-files.json artifacts/SHA256SUMS.txt - artifacts/Install-SwanRemoteSupport.ps1 - artifacts/Install-SwanRemoteSupport.cmd - artifacts/Install-SwanRemoteSupport-UNSIGNED-TEST.cmd - artifacts/Run-Swan-Technician-UNSIGNED-TEST.cmd - artifacts/Enable-SwanUnattendedAccess.ps1 - artifacts/Disable-SwanUnattendedAccess.ps1 - artifacts/CUSTOMER_INSTALL.md - artifacts/UNSIGNED_SPARE_LAPTOP_TEST.md - artifacts/PRIVACY.md - artifacts/SOURCE_AND_LICENSE.md + artifacts/swan-*.exe + artifacts/Install-Company.ps1 + artifacts/Install-Worker.ps1 + artifacts/Read-WorkerConfiguration.ps1 + artifacts/Migrate-WorkerCredential.ps1 + artifacts/Prepare-CustomerDirectory.ps1 + artifacts/Uninstall-Worker.ps1 + artifacts/Open-Technician.ps1 + artifacts/Uninstall-Technician.ps1 + artifacts/Verify-Package.ps1 + artifacts/Get-WindowsCompatibility.ps1 + artifacts/*-installed-files.unsigned.json + artifacts/LICENCE diff --git a/.github/workflows/third-party-RustDeskTempTopMostWindow.yml b/.github/workflows/third-party-RustDeskTempTopMostWindow.yml index 05c8731d5e5..422483217c3 100644 --- a/.github/workflows/third-party-RustDeskTempTopMostWindow.yml +++ b/.github/workflows/third-party-RustDeskTempTopMostWindow.yml @@ -1,6 +1,27 @@ name: build RustDeskTempTopMostWindow on: + workflow_dispatch: + inputs: + upload-artifact: + type: boolean + default: true + target: + type: choice + options: [windows-2022] + default: windows-2022 + configuration: + type: choice + options: [Release] + default: Release + platform: + type: choice + options: [x64] + default: x64 + target_version: + type: choice + options: [Windows10] + default: Windows10 workflow_call: inputs: upload-artifact: @@ -27,6 +48,9 @@ on: type: string default: 'Windows10' +permissions: + contents: read + env: project_path: WindowInjection/WindowInjection.vcxproj @@ -37,19 +61,59 @@ jobs: fail-fast: false env: build_output_dir: RustDeskTempTopMostWindow/WindowInjection/${{ inputs.platform }}/${{ inputs.configuration }} + CL: ${{ inputs.configuration == 'Release' && inputs.platform == 'x64' && '/Brepro' || '' }} + _LINK_: ${{ inputs.configuration == 'Release' && inputs.platform == 'x64' && '/Brepro /INCREMENTAL:NO /PDBALTPATH:%_PDB%' || '' }} steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + persist-credentials: false - name: Add MSBuild to PATH uses: microsoft/setup-msbuild@6fb02220983dee41ce7ae257b6f4d8f9bf5ed4ce # v2 - name: Download the source code run: | - git clone https://github.com/rustdesk-org/RustDeskTempTopMostWindow RustDeskTempTopMostWindow + git init RustDeskTempTopMostWindow + git -C RustDeskTempTopMostWindow remote add origin https://github.com/rustdesk-org/RustDeskTempTopMostWindow + git -C RustDeskTempTopMostWindow fetch --depth=1 origin ecd8d6a139eee76845ea66423fb739af450fda90 + git -C RustDeskTempTopMostWindow checkout --detach FETCH_HEAD - name: Build the project run: | cd RustDeskTempTopMostWindow && git checkout ecd8d6a139eee76845ea66423fb739af450fda90 msbuild ${{ env.project_path }} -p:Configuration=${{ inputs.configuration }} -p:Platform=${{ inputs.platform }} /p:TargetVersion=${{ inputs.target_version }} + - name: Verify release metadata and retain exact public helper source + if: inputs.configuration == 'Release' && inputs.platform == 'x64' + shell: pwsh + run: | + python -m unittest discover -s deployment/windows -p test_reproducible_pe.py + if ($LASTEXITCODE -ne 0) { throw 'PE metadata guard tests failed.' } + $dll = Join-Path $env:build_output_dir 'WindowInjection.dll' + $verification = & python deployment/windows/verify-reproducible-pe.py $dll --dll + if ($LASTEXITCODE -ne 0) { throw 'Helper lacks reproducible release metadata.' } + $metadata = $verification | ConvertFrom-Json + $revision = (& git -C RustDeskTempTopMostWindow rev-parse HEAD).Trim() + if ($LASTEXITCODE -ne 0 -or $revision -cne 'ecd8d6a139eee76845ea66423fb739af450fda90') { throw 'Helper source pin changed.' } + git -C RustDeskTempTopMostWindow diff --quiet HEAD + if ($LASTEXITCODE -ne 0) { throw 'Helper tracked source changed.' } + New-Item -ItemType Directory -Path helper-source | Out-Null + $archive = [IO.Path]::GetFullPath('helper-source/WindowInjection-original-source.zip') + git -C RustDeskTempTopMostWindow archive --format=zip --output=$archive HEAD + if ($LASTEXITCODE -ne 0) { throw 'Pinned helper source export failed.' } + Copy-Item .github/workflows/third-party-RustDeskTempTopMostWindow.yml helper-source/build-workflow.yml + Copy-Item deployment/windows/verify-reproducible-pe.py,deployment/windows/test_reproducible_pe.py helper-source/ + $record = @{schema=1;repository='https://github.com/rustdesk-org/RustDeskTempTopMostWindow';source_revision=$revision;workflow_repository=$env:GITHUB_REPOSITORY;workflow_source_revision=$env:GITHUB_SHA;workflow_run=$env:GITHUB_RUN_ID;platform='x64';configuration='Release';compiler_flags=$env:CL;linker_flags=$env:_LINK_;runner_image=$env:ImageOS;runner_image_version=$env:ImageVersion;binary=$metadata;source_files=@{};independent_rebuild_verified=$false;runtime_qualified=$false;production_approved=$false} + foreach ($file in Get-ChildItem -LiteralPath helper-source -File) { $record.source_files[$file.Name]=(Get-FileHash -LiteralPath $file.FullName).Hash.ToLowerInvariant() } + $record | ConvertTo-Json -Depth 6 | Set-Content -LiteralPath helper-source/WindowInjection-SOURCE.json -Encoding utf8 + + - name: Archive helper corresponding source separately from installed payload + if: inputs.configuration == 'Release' && inputs.platform == 'x64' && inputs.upload-artifact + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: topmostwindow-source-${{ inputs.platform }} + path: helper-source/ + if-no-files-found: error + - name: Archive build artifacts uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 if: ${{ inputs.upload-artifact }} diff --git a/.gitignore b/.gitignore index a7b63b966da..277006457d4 100644 --- a/.gitignore +++ b/.gitignore @@ -65,3 +65,10 @@ flutter/web/ # Working activity log required by the repository's AI-agent rules. docs/AI_CHANGELOG.md + +# Company server and build outputs. Runtime credentials never belong in Git. +product/target/ +swan-data/ +deployment/linux/company.env +*.swan-backup +deployment/windows/local-test.env diff --git a/Cargo.lock b/Cargo.lock index 57177174db5..c7645f065e0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1,6 +1,6 @@ # This file is automatically @generated by Cargo. # It is not intended for manual editing. -version = 3 +version = 4 [[package]] name = "ab_glyph" @@ -633,6 +633,58 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c4b4d0bd25bd0b74681c0ad21497610ce1b7c91b1022cd21c80c6fbdd9476b0" +[[package]] +name = "axum" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +dependencies = [ + "axum-core", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa 1.0.11", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json 1.0.118", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + [[package]] name = "backtrace" version = "0.3.73" @@ -990,7 +1042,7 @@ dependencies = [ [[package]] name = "cacao" version = "0.4.0-beta2" -source = "git+https://github.com/clslaid/cacao?branch=feat/set-file-urls#05e1536b0b43aaae308ec72c0eed703e875b7b95" +source = "git+https://github.com/clslaid/cacao?branch=feat%2Fset-file-urls#05e1536b0b43aaae308ec72c0eed703e875b7b95" dependencies = [ "bitmask-enum", "block2 0.2.0-alpha.6", @@ -1905,6 +1957,7 @@ dependencies = [ "cfg-if 1.0.0", "cpufeatures", "curve25519-dalek-derive", + "digest", "fiat-crypto", "rustc_version", "subtle", @@ -2329,7 +2382,7 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "330c60081dcc4c72131f8eb70510f1ac07223e5d4163db481a04a0befcffa412" dependencies = [ - "libloading 0.7.4", + "libloading 0.8.4", ] [[package]] @@ -2530,6 +2583,31 @@ dependencies = [ "signature 1.6.4", ] +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8", + "signature 2.2.0", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519 2.2.3", + "rand_core 0.6.4", + "serde 1.0.228", + "sha2", + "subtle", + "zeroize", +] + [[package]] name = "either" version = "1.13.0" @@ -2694,7 +2772,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -2820,7 +2898,7 @@ dependencies = [ [[package]] name = "filedescriptor" version = "0.8.2" -source = "git+https://github.com/rustdesk-org/wezterm?branch=rustdesk/pty_based_0.8.1#80174f8009f41565f0fa8c66dab90d4f9211ae16" +source = "git+https://github.com/rustdesk-org/wezterm?branch=rustdesk%2Fpty_based_0.8.1#80174f8009f41565f0fa8c66dab90d4f9211ae16" dependencies = [ "libc", "thiserror 1.0.61", @@ -3019,6 +3097,16 @@ dependencies = [ "time 0.1.45", ] +[[package]] +name = "fs2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9564fc758e15025b46aa6643b1b77d047d1a56a1aea6e01002ac0c7026876213" +dependencies = [ + "libc", + "winapi 0.3.9", +] + [[package]] name = "fsevent" version = "2.1.2" @@ -3943,6 +4031,12 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + [[package]] name = "humantime" version = "2.1.0" @@ -3975,6 +4069,7 @@ dependencies = [ "http", "http-body", "httparse", + "httpdate", "itoa 1.0.11", "pin-project-lite", "pin-utils", @@ -4494,7 +4589,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e310b3a6b5907f99202fcdb4960ff45b93735d7c7d96b760fcff8db2dc0e103d" dependencies = [ "cfg-if 1.0.0", - "windows-targets 0.48.5", + "windows-targets 0.52.6", ] [[package]] @@ -4717,6 +4812,12 @@ version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2532096657941c2fea9c289d370a250971c689d4f143798ff67113ec042024a5" +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + [[package]] name = "md-5" version = "0.10.6" @@ -4796,6 +4897,12 @@ dependencies = [ "objc", ] +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + [[package]] name = "minimal-lexical" version = "0.2.1" @@ -5963,7 +6070,7 @@ dependencies = [ [[package]] name = "pam-sys" version = "1.0.0-alpha4" -source = "git+https://github.com/rustdesk-org/pam-sys?branch=fix/v1.0.0-alpha4_gnuc_va_list#3337c9bb9a9c68d7497ec8c93cad2368c26091b7" +source = "git+https://github.com/rustdesk-org/pam-sys?branch=fix%2Fv1.0.0-alpha4_gnuc_va_list#3337c9bb9a9c68d7497ec8c93cad2368c26091b7" dependencies = [ "bindgen 0.59.2", "libc", @@ -6369,7 +6476,7 @@ checksum = "f84267b20a16ea918e43c6a88433c2d54fa145c92a811b5b047ccbe153674483" [[package]] name = "portable-pty" version = "0.8.1" -source = "git+https://github.com/rustdesk-org/wezterm?branch=rustdesk/pty_based_0.8.1#80174f8009f41565f0fa8c66dab90d4f9211ae16" +source = "git+https://github.com/rustdesk-org/wezterm?branch=rustdesk%2Fpty_based_0.8.1#80174f8009f41565f0fa8c66dab90d4f9211ae16" dependencies = [ "anyhow", "bitflags 1.3.2", @@ -7249,7 +7356,7 @@ dependencies = [ [[package]] name = "rustdesk" -version = "1.4.9" +version = "1.5.23" dependencies = [ "android-wakelock", "android_logger", @@ -7334,6 +7441,7 @@ dependencies = [ "shutdown_hooks", "softbuffer", "stunclient", + "swan-agent", "sys-locale", "system_shutdown", "tao", @@ -7362,7 +7470,7 @@ dependencies = [ [[package]] name = "rustdesk-portable-packer" -version = "1.4.9" +version = "1.5.23" dependencies = [ "brotli", "dirs 5.0.1", @@ -7434,7 +7542,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.11.0", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -7491,7 +7599,7 @@ dependencies = [ "security-framework 3.5.1", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -7747,6 +7855,17 @@ dependencies = [ "serde 1.0.228", ] +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa 1.0.11", + "serde 1.0.228", + "serde_core", +] + [[package]] name = "serde_repr" version = "0.1.19" @@ -8071,7 +8190,7 @@ version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e26be3acb6c2d9a7aac28482586a7856436af4cfe7100031d219de2d2ecb0028" dependencies = [ - "ed25519", + "ed25519 1.5.3", "libc", "libsodium-sys", "serde 1.0.228", @@ -8263,6 +8382,41 @@ version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" +[[package]] +name = "swan-agent" +version = "0.1.0" +dependencies = [ + "anyhow", + "axum", + "base64 0.22.1", + "ed25519-dalek", + "fs2", + "hex", + "rand 0.8.5", + "reqwest", + "serde 1.0.228", + "serde_json 1.0.118", + "sha2", + "swan-protocol", + "tokio", + "windows-sys 0.61.2", +] + +[[package]] +name = "swan-protocol" +version = "0.1.0" +dependencies = [ + "anyhow", + "base64 0.22.1", + "ed25519-dalek", + "hex", + "rand 0.8.5", + "serde 1.0.228", + "serde_json 1.0.118", + "sha2", + "url", +] + [[package]] name = "syn" version = "0.15.44" @@ -8545,7 +8699,7 @@ dependencies = [ [[package]] name = "tfc" version = "0.7.0" -source = "git+https://github.com/rustdesk-org/The-Fat-Controller?branch=history/rebase_upstream_20240722#78bb80a8e596e4c14ae57c8448f5fca75f91f2b0" +source = "git+https://github.com/rustdesk-org/The-Fat-Controller?branch=history%2Frebase_upstream_20240722#78bb80a8e596e4c14ae57c8448f5fca75f91f2b0" dependencies = [ "anyhow", "core-graphics 0.23.2", @@ -8921,6 +9075,7 @@ dependencies = [ "tokio", "tower-layer", "tower-service", + "tracing", ] [[package]] @@ -10868,7 +11023,7 @@ dependencies = [ [[package]] name = "x11-clipboard" version = "0.8.1" -source = "git+https://github.com/clslaid/x11-clipboard?branch=feat/store-batch#5fc2e73bc01ada3681159b34cf3ea8f0d14cd904" +source = "git+https://github.com/clslaid/x11-clipboard?branch=feat%2Fstore-batch#5fc2e73bc01ada3681159b34cf3ea8f0d14cd904" dependencies = [ "x11rb 0.12.0", ] diff --git a/Cargo.toml b/Cargo.toml index 509d2e07efa..f7589ccc615 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,12 +1,12 @@ [package] name = "rustdesk" -version = "1.4.9" +version = "1.5.23" authors = ["rustdesk "] edition = "2021" build= "build.rs" description = "RustDesk Remote Desktop" default-run = "rustdesk" -rust-version = "1.75" +rust-version = "1.90" [lib] name = "librustdesk" @@ -31,7 +31,7 @@ hwcodec = ["scrap/hwcodec"] vram = ["scrap/vram"] mediacodec = ["scrap/mediacodec"] plugin_framework = [] -swan_custom = [] +swan_custom = ["dep:swan-agent"] linux-pkg-config = ["magnum-opus/linux-pkg-config", "scrap/linux-pkg-config"] unix-file-copy-paste = [ "dep:x11-clipboard", @@ -45,6 +45,7 @@ screencapturekit = ["cpal/screencapturekit"] # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] +swan-agent = { path = "product/agent", optional = true } async-trait = "0.1" scrap = { path = "libs/scrap", features = ["wayland"] } hbb_common = { path = "libs/hbb_common" } @@ -117,6 +118,7 @@ winapi = { version = "0.3", features = [ "cfgmgr32", "ioapiset", "winspool", + "winsvc", ] } windows = { version = "0.61", features = [ "Win32", @@ -203,7 +205,7 @@ android-wakelock = { git = "https://github.com/rustdesk-org/android-wakelock" } [workspace] members = ["libs/scrap", "libs/hbb_common", "libs/enigo", "libs/clipboard", "libs/virtual_display", "libs/virtual_display/dylib", "libs/portable", "libs/remote_printer"] -exclude = ["vdi/host", "examples/custom_plugin"] +exclude = ["vdi/host", "examples/custom_plugin", "product", "product/agent", "product/protocol", "product/management", "product/worker"] # Patch libxdo-sys to use a stub implementation that doesn't require libxdo # This allows building and running on systems without libxdo installed (e.g., Wayland-only) diff --git a/README.md b/README.md index 93062e5e1de..1c8ecc9d2af 100644 --- a/README.md +++ b/README.md @@ -9,11 +9,11 @@

> [!IMPORTANT] -> This branch contains the **Swan Remote Support** managed client maintained by Swan Computing. It is pinned to RustDesk `1.4.9`, uses Swan branding, embeds the public Swan server routing/trust defaults, provides an express consented-unattended installation, and hides the customer-facing ID/password after setup. Customer release is blocked until the public-source, privacy, Tailnet-policy, testing, and trusted code-signing gates pass. Start with the [Swan build/deployment guide](docs/SWAN_CUSTOM_BUILD.md), [Tailscale deployment](docs/TAILSCALE_DEPLOYMENT.md), [code-signing policy](docs/CODE_SIGNING_POLICY.md), [privacy statement](docs/PRIVACY.md), and [source/licensing notes](docs/SOURCE_AND_LICENSE.md). +> **Swan Remote Support** is evolving into a configurable AGPL product that each company hosts independently. Its server manages branding, enrollment, technician authorization and approved updates; customer and technician apps use signed company configuration. Start with the [company product build/deployment guide](product/README.md) and [acceptance report](docs/ACCEPTANCE.md). This is a development branch, not a production release. Native sessions, installers, recovery and the complete Windows matrix still require verification. The original Swan/Tailscale deployment is preserved at tag `swan-single-company-baseline-1.4.9`. -Continuing on the live server? Use the evidence-first [server continuation prompt](docs/SERVER_CONTINUATION_PROMPT.md). +Maintaining the preserved Swan/Tailscale deployment? Use its historical [server continuation prompt](docs/SERVER_CONTINUATION_PROMPT.md). -**[Code signing policy](docs/CODE_SIGNING_POLICY.md):** Free code signing provided by [SignPath.io](https://about.signpath.io/), certificate by [SignPath Foundation](https://signpath.org/). +**[Code signing policy](docs/CODE_SIGNING_POLICY.md):** Production publication requires an approved signing provider, valid signatures and completed release checks. No signing approval or free signing of arbitrary company builds is assumed. > [!Caution] > **Misuse Disclaimer:**
diff --git a/SECURITY.md b/SECURITY.md index 8430238277f..5097ff14f79 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -12,10 +12,11 @@ Include the affected version/commit, Windows version, reproducible steps, impact ## Deployment security boundaries -- The server address and Ed25519 public key embedded in the client are public values. -- The RustDesk server private key, customer unattended passwords, Tailscale enrollment credentials, and signing keys are secrets and must never enter source control. -- Tailscale tag policy limits network reachability but does not replace the unique Swan password or customer authorization. -- RustDesk Server OSS does not provide a Swan technician identity/MFA authorization layer. Anyone who obtains a device ID and its password can attempt access from an allowed network device. +- Company HTTPS addresses and public trust keys are public bootstrap information. Private transport/configuration keys, worker credentials, account secrets, device identities, signing keys and backups must stay outside source control and public downloads. +- Managed editions require company enrollment, technician authorization and short-lived device-bound grants enforced by the receiving device. UI restrictions or network reachability alone are insufficient. See the acceptance record for verified behavior and outstanding gates. +- Each company hosts its own infrastructure. The preserved single-company baseline has different historical password and Tailscale boundaries; do not treat its network restrictions as managed authorization. +- Earlier development Windows worker installers stored credentials in service registry environment settings. Server 2016 testing found additional app-container read permissions. Revoke affected worker registrations, stop legacy workers and remove those registry secrets before replacement. Current development source requires protected private-file configuration for service mode; Server 2016 lab migration, standard-user read denial, package generation and reboot checks passed. The complete Windows matrix and production release qualification remain required. +- Before restoring a backup, reconcile revoked worker registrations: an old database can restore their credential hashes. Do not expose the restored deployment until legacy registrations have been revoked again. - Never ask a customer to disable antivirus, SmartScreen, Windows Firewall, or MFA to install this software. See [Code-signing policy](docs/CODE_SIGNING_POLICY.md), [Privacy](docs/PRIVACY.md), and [Tailscale deployment](docs/TAILSCALE_DEPLOYMENT.md). diff --git a/branding/swan-support-mark.svg b/branding/swan-support-mark.svg new file mode 100644 index 00000000000..c2062f576a4 --- /dev/null +++ b/branding/swan-support-mark.svg @@ -0,0 +1 @@ + diff --git a/build.rs b/build.rs index bf38b320a51..24ddcd49048 100644 --- a/build.rs +++ b/build.rs @@ -77,42 +77,33 @@ fn install_android_deps() { println!("cargo:rustc-link-lib=OpenSLES"); } -fn validate_swan_build_environment() { - if std::env::var_os("CARGO_FEATURE_SWAN_CUSTOM").is_none() { - return; - } +#[path = "src/build_metadata.rs"] +mod build_metadata; - // These are public routing/trust values, not credentials. Environment - // variables can override them for a test build, while ordinary Swan - // builds remain zero-configuration and pinned to the private Tailscale - // deployment. - const DEFAULT_RENDEZVOUS_SERVER: &str = "100.82.236.84"; - const DEFAULT_RELAY_SERVER: &str = "100.82.236.84"; - const DEFAULT_RS_PUB_KEY: &str = "I0s1JvqJ19WDNKBLFY+FC5HekcOqNWPpH+6xGmKUQLI="; - - for (variable, default_value) in [ - ("RENDEZVOUS_SERVER", DEFAULT_RENDEZVOUS_SERVER), - ("RELAY_SERVER", DEFAULT_RELAY_SERVER), - ("RS_PUB_KEY", DEFAULT_RS_PUB_KEY), - ] { - println!("cargo:rerun-if-env-changed={variable}"); - let value = std::env::var(variable) - .ok() - .filter(|value| !value.trim().is_empty()) - .unwrap_or_else(|| default_value.to_owned()); - println!("cargo:rustc-env={variable}={value}"); +fn generate_version() -> Result<(), Box> { + println!("cargo:rerun-if-env-changed=SWAN_BUILD"); + println!("cargo:rerun-if-env-changed=SOURCE_DATE_EPOCH"); + println!("cargo:rerun-if-changed=Cargo.toml"); + println!("cargo:rerun-if-changed=src/build_metadata.rs"); + if std::env::var_os("CARGO_FEATURE_SWAN_CUSTOM").is_some() + || std::env::var("SWAN_BUILD").as_deref() == Ok("1") + { + let epoch = std::env::var("SOURCE_DATE_EPOCH")?; + let version = std::env::var("CARGO_PKG_VERSION")?; + let metadata = build_metadata::render_version(&version, &epoch) + .map_err(|message| std::io::Error::new(std::io::ErrorKind::InvalidInput, message))?; + std::fs::write("src/version.rs", metadata)?; + } else { + hbb_common::gen_version(); } - - println!("cargo:rerun-if-env-changed=API_SERVER"); - println!( - "cargo:rustc-env=API_SERVER={}", - std::env::var("API_SERVER").unwrap_or_default() - ); + Ok(()) } fn main() { - validate_swan_build_environment(); - hbb_common::gen_version(); + if let Err(error) = generate_version() { + eprintln!("Cannot generate application build metadata: {error}"); + std::process::exit(1); + } install_android_deps(); #[cfg(all(windows, feature = "inline"))] build_manifest(); diff --git a/deployment/linux/07bb8d0-component-reproduction.json b/deployment/linux/07bb8d0-component-reproduction.json new file mode 100644 index 00000000000..d4be8ac7379 --- /dev/null +++ b/deployment/linux/07bb8d0-component-reproduction.json @@ -0,0 +1,67 @@ +{ + "utc": "2026-10-28T09:10:41.827281+00:00", + "project_source": "07bb8d0b99e2be002a30e5d97eebe67a9c2794ad", + "independent_pr_merge": "1ba02cc849fcf55fbbc68d2757f56b35d9e783d0", + "project_trees_identical": true, + "builds": [ + { + "run_id": 40647659405, + "artifact": 12797593547, + "digest": "sha256:0dbf6abc6ff30442cef41049a83bfc446d9a49d3a7716c9949c654338a35eb64", + "bytes": 9561318, + "expected_packaging_source": "07bb8d0b99e2be002a30e5d97eebe67a9c2794ad", + "public_source_files_verified": 221, + "declared_text_checkout_conversions": [], + "rustc": "rustc 1.90.0 (1159e78c4 2025-09-14)\nbinary: rustc\ncommit-hash: 1159e78c4747b02ef996e55082b704c09b970588\ncommit-date: 2025-09-14\nhost: x86_64-unknown-linux-gnu\nrelease: 1.90.0\nLLVM version: 20.1.8\n", + "files": { + "swan-management": "89bd490871eeedbec72fb7c53491762ee1cb776af7f0dfeb77ba40301c97d5ab", + "swan-agent": "cb3624bc9f29e9520d47c3bb591fea9dfb3e37105b72ca3a77b5f548b2f42c66", + "swan-worker": "e2be24f49dc930423926b84ee4fccdd16d2e2ad0c00999b6b864de2e055bb23e", + "CompanyComponents-source.zip": "6c17b840cc0daf8714b2e53b40630c57ea430973064bc177dcf6ba44e1c177cf" + }, + "executables_run": false + }, + { + "run_id": 40647664923, + "artifact": 12798243526, + "digest": "sha256:0b6458ae74d8ba278f6fa0ed99ea48a9f4647a9a2c96d5ce15e10ed0c5e7d00d", + "bytes": 9561317, + "expected_packaging_source": "1ba02cc849fcf55fbbc68d2757f56b35d9e783d0", + "public_source_files_verified": 221, + "declared_text_checkout_conversions": [], + "rustc": "rustc 1.90.0 (1159e78c4 2025-09-14)\nbinary: rustc\ncommit-hash: 1159e78c4747b02ef996e55082b704c09b970588\ncommit-date: 2025-09-14\nhost: x86_64-unknown-linux-gnu\nrelease: 1.90.0\nLLVM version: 20.1.8\n", + "files": { + "swan-management": "89bd490871eeedbec72fb7c53491762ee1cb776af7f0dfeb77ba40301c97d5ab", + "swan-agent": "cb3624bc9f29e9520d47c3bb591fea9dfb3e37105b72ca3a77b5f548b2f42c66", + "swan-worker": "e2be24f49dc930423926b84ee4fccdd16d2e2ad0c00999b6b864de2e055bb23e", + "CompanyComponents-source.zip": "285a0ff41456e2d60d4fa894bf84acb6bf8db5c7c856d3057b605f37516e8b1e" + }, + "executables_run": false + } + ], + "original_linux_binaries": { + "swan-management": { + "identical": true, + "first_sha256": "89bd490871eeedbec72fb7c53491762ee1cb776af7f0dfeb77ba40301c97d5ab", + "second_sha256": "89bd490871eeedbec72fb7c53491762ee1cb776af7f0dfeb77ba40301c97d5ab", + "normalized": false + }, + "swan-agent": { + "identical": true, + "first_sha256": "cb3624bc9f29e9520d47c3bb591fea9dfb3e37105b72ca3a77b5f548b2f42c66", + "second_sha256": "cb3624bc9f29e9520d47c3bb591fea9dfb3e37105b72ca3a77b5f548b2f42c66", + "normalized": false + }, + "swan-worker": { + "identical": true, + "first_sha256": "e2be24f49dc930423926b84ee4fccdd16d2e2ad0c00999b6b864de2e055bb23e", + "second_sha256": "e2be24f49dc930423926b84ee4fccdd16d2e2ad0c00999b6b864de2e055bb23e", + "normalized": false + } + }, + "all_original_binaries_identical": true, + "exact_public_sources_verified": true, + "executables_run": false, + "runtime_qualified": false, + "production_publication": false +} diff --git a/deployment/linux/07bb8d0-maintained-transport-native-relay-and-customer-pause.json b/deployment/linux/07bb8d0-maintained-transport-native-relay-and-customer-pause.json new file mode 100644 index 00000000000..acc7680e084 --- /dev/null +++ b/deployment/linux/07bb8d0-maintained-transport-native-relay-and-customer-pause.json @@ -0,0 +1,96 @@ +{ + "recorded_utc": "2026-10-28T21:21:32.338938+00:00", + "application_source": "07bb8d0b99e2be002a30e5d97eebe67a9c2794ad", + "transport_recipe_source": "650361413543baf7ef150f5960544154ec77ddeb", + "transport_image": "sha256:3e879d6f3954ff2bc750ab02cdea43e05894e75cd4f8d288c411450d2fae93ca", + "both_running_transport_container_images_verified": true, + "receiver_relay_request_utc": "2026-10-28T21:12:47.567975+00:00", + "matching_relay_pair_utc": "2026-10-28T21:12:47.125530+00:00", + "native_video_service_utc": "2026-10-28T21:13:04.192738+00:00", + "matching_receiver_and_maintained_relay_connection_id_verified": true, + "real_native_live_desktop_after_customer_accept": true, + "remote_pointer_changed_customer_server_manager_to_local_server": true, + "receiving_viewer_independently_showed_changed_page": true, + "claimed_attended_grant_with_subsequent_lease_renewal": true, + "technician_native_payload": { + "source": "07bb8d0b99e2be002a30e5d97eebe67a9c2794ad", + "flutter_and_rust_libraries_loaded": true, + "native_child_process_running": true, + "version": "1.5.7", + "utc": "2026-10-28T21:15:36.6770984Z", + "native_process_count": 1, + "no_new_application_crash_event": true, + "source_payload_hashes": { + "rustdesk.exe": "00db5cea227670af716d8dac88b7cb9fd5c86dab15f1dbfbed3c91a040733985", + "librustdesk.dll": "a79de5449074eb4d463eb72706015e70d5ec9f04f4c009a42336efa9adf45da4", + "flutter_windows.dll": "c87ba5a71afa7b7100c1c9ccc1289009dab812f9de2acc38f9d5103e48c1f204" + }, + "gui_mfa_and_inventory_observed_separately": false, + "remote_session_qualified": false + }, + "customer_pause": { + "consent_revision": 0, + "version": "1.5.7", + "closed_grant_renewal_status": 403, + "profile_revision": 14, + "sequence": 9, + "utc": "2026-10-28T21:20:24.5173317Z", + "service_pid": 1196, + "support_paused": true, + "same_service_process_and_watch_running": true, + "new_msi_events": 0, + "shipping_verifier_passed": true, + "standard_account_test": false, + "unattended_consent_off": true, + "original_claimed_grant_closed": true, + "closed_grant_retains_historical_lease_timestamp": true + }, + "customer_resume": { + "consent_revision": 0, + "version": "1.5.7", + "closed_grant_renewal_status": 403, + "profile_revision": 14, + "sequence": 9, + "utc": "2026-10-28T21:20:54.1280944Z", + "service_pid": 1196, + "support_paused": false, + "same_service_process_and_watch_running": true, + "new_msi_events": 0, + "shipping_verifier_passed": true, + "standard_account_test": false, + "unattended_consent_off": true, + "original_claimed_grant_closed": true, + "closed_grant_retains_historical_lease_timestamp": true, + "identity_credential_and_trust_preserved_across_resume": true + }, + "gui_findings": { + "incoming_prompt_used_legacy_peer_display_name": true, + "observed_peer_display_name": "Administrator", + "authenticated_technician_display_name_on_customer_prompt_not_qualified": true + }, + "original_private_log_sha256": { + "receiver-native.private.log": "f9e50f9af32660b2ed735e30c18561b9950f360a58fc92c00db1080b83448d84", + "hbbr.private.log": "bd8e6a526eb2977323dedbd6e53b79e0b794af44d99845318a40b3e9be527637" + }, + "limits": [ + "Loopback company endpoints and separate NAT lab VMs; no public Internet or direct-transport qualification.", + "Customer controls used owned Administrator desktop; no ordinary-account active-session termination claim.", + "No real clipboard, file transfer, remote UAC, multiple monitors or authorization-outage expiry qualification from this test.", + "No production signing or complete Windows compatibility qualification." + ], + "native_technician_mfa_and_inventory": { + "actual_installed_company_branding_and_https_origin_observed": true, + "native_username_password_totp_login_observed": true, + "independent_server_login_audit_matches": true, + "visible_approved_device_count": 5, + "technician_os": "Windows Server 2019 x64 17763.3650", + "customer_os": "Windows Server 2016 x64 14393", + "owned_administrator_desktops": true, + "ordinary_account_active_session_test": false + }, + "verification_correction": { + "initial_checker_incorrectly_required_zero_historical_lease_timestamp": true, + "failure_preserved_privately": true, + "closed_flag_and_actual_renewal_403_verified_instead": true + } +} diff --git a/deployment/linux/Caddyfile b/deployment/linux/Caddyfile new file mode 100644 index 00000000000..0ebc4c5d703 --- /dev/null +++ b/deployment/linux/Caddyfile @@ -0,0 +1,7 @@ +{$SWAN_HOST} { + reverse_proxy management:8080 + header Strict-Transport-Security "max-age=31536000" + request_body { + max_size 512MB + } +} diff --git a/deployment/linux/Dockerfile b/deployment/linux/Dockerfile new file mode 100644 index 00000000000..2d676a1632b --- /dev/null +++ b/deployment/linux/Dockerfile @@ -0,0 +1,18 @@ +FROM rust:1.90.0-bookworm@sha256:3914072ca0c3b8aad871db9169a651ccfce30cf58303e5d6f2db16d1d8a7e58f AS builder +ARG CARGO_BUILD_JOBS=2 +ENV CARGO_BUILD_JOBS=${CARGO_BUILD_JOBS} +WORKDIR /source +COPY product ./product +COPY branding ./branding +COPY deployment ./deployment +COPY LICENCE ./LICENCE +RUN cargo build --manifest-path product/Cargo.toml --locked --release -p swan-management + +FROM debian:bookworm-slim@sha256:3783cc01769c7b2b1b83a5c5ad96c815348e28ed7da68e2e3687004faa906251 +COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt +RUN useradd --system --uid 10001 --create-home swan && mkdir -p /var/lib/swan && chown swan:swan /var/lib/swan +COPY --from=builder /source/product/target/release/swan-management /usr/local/bin/swan-management +USER swan +ENV SWAN_DATA_DIR=/var/lib/swan SWAN_LISTEN=0.0.0.0:8080 SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt +EXPOSE 8080 +ENTRYPOINT ["swan-management"] diff --git a/deployment/linux/Dockerfile.dockerignore b/deployment/linux/Dockerfile.dockerignore new file mode 100644 index 00000000000..971311b1709 --- /dev/null +++ b/deployment/linux/Dockerfile.dockerignore @@ -0,0 +1,86 @@ +# Deny by default: Git ignore rules do not apply to Docker build contexts. +# Keep company environment files, keys, databases, packages and build caches out +# of both the final image and intermediate layers sent to the build daemon. +** +!LICENCE +!product/ +product/** +!product/Cargo.toml +!product/Cargo.lock +!product/protocol/ +product/protocol/** +!product/protocol/Cargo.toml +!product/protocol/src/ +product/protocol/src/** +!product/protocol/src/lib.rs +!product/protocol/src/installation.rs +!product/agent/ +product/agent/** +!product/agent/Cargo.toml +!product/agent/src/ +product/agent/src/** +!product/agent/src/lib.rs +!product/agent/src/main.rs +!product/agent/src/payload.rs +!product/agent/src/reporting.rs +!product/agent/src/technician.rs +!product/agent/src/technician_runtime.rs +!product/agent/src/update.rs +!product/agent/src/verifier_process.rs +!product/management/ +product/management/** +!product/management/Cargo.toml +!product/management/src/ +product/management/src/** +!product/management/src/main.rs +!product/management/src/service.rs +!product/management/src/installation_reporting.rs +!product/management/src/network.rs +!product/management/src/network_probe.rs +!product/management/src/backup.rs +!product/management/src/backup_stream.rs +!product/management/src/windows.rs +!product/management/src/components.rs +!product/management/src/tests.rs +!product/management/src/schema.sql +!product/management/web/ +product/management/web/** +!product/management/web/index.html +!product/management/web/app.js +!product/management/web/style.css +!product/worker/ +product/worker/** +!product/worker/Cargo.toml +!product/worker/src/ +product/worker/src/** +!product/worker/src/main.rs +!product/worker/src/configuration.rs +!product/worker/src/windows.rs +!branding/ +branding/** +!branding/swan-support-mark.svg +!deployment/ +deployment/** +!deployment/windows/ +deployment/windows/** +!deployment/windows/Install-Company.ps1 +!deployment/windows/Install-SwanRemoteSupport.cmd +!deployment/windows/Open-Technician.ps1 +!deployment/windows/Uninstall-Technician.ps1 +!deployment/windows/Verify-MsiRegistration.ps1 +!deployment/windows/Verify-Package.ps1 +!deployment/windows/Get-MsiIdentity.ps1 +!deployment/windows/Get-MsiInstallMode.ps1 +!deployment/windows/Get-WindowsCompatibility.ps1 +!deployment/windows/Restore-ReleaseMsi.ps1 +!deployment/windows/Restore-CustomerExe.ps1 +!deployment/windows/Restore-Configuration.ps1 +!deployment/windows/Restart-Configuration.ps1 +!deployment/windows/Restart-CustomerUi.ps1 +!deployment/windows/Run-SetupUiTask.ps1 +!deployment/windows/Remove-Configuration.ps1 +!deployment/windows/Update-RecoveryTask.ps1 +!deployment/windows/Update-UninstallCancellation.ps1 +!deployment/windows/Commit-Uninstall.ps1 +!deployment/windows/Prepare-CustomerDirectory.ps1 +!deployment/windows/Read-WorkerConfiguration.ps1 diff --git a/deployment/linux/README.md b/deployment/linux/README.md new file mode 100644 index 00000000000..1b0ce0c52a2 --- /dev/null +++ b/deployment/linux/README.md @@ -0,0 +1,113 @@ +# Company Linux deployment + +Each company operates its own management, rendezvous and relay services. The +project does not supply hosting. These deployment files still require the +release checks in [ACCEPTANCE.md](../../docs/ACCEPTANCE.md); intended Windows +compatibility and production signing are not verified claims. + +From this directory, copy `company.env.example` to `company.env`, set the +company's public hostname and trusted project release verification public key, +then run: + +```sh +docker compose --env-file company.env up -d --build +``` + +The company supplies DNS, inbound TCP 80/443 for HTTPS provisioning, TCP +21115/21116/21117 and UDP 21116 for native transport. Office hosting may need +port forwarding or a separately reachable company relay under CGNAT. Setup +diagnostics run from the company server and do not prove external connectivity. +Do not alter an existing production deployment to rehearse these instructions. + +Open `https://YOUR_COMPANY_HOSTNAME` for the first-run administration wizard. +Retrieve the one-time setup token privately on the company server: + +```sh +docker compose --env-file company.env exec management cat /var/lib/swan/setup-token.txt +``` + +Keep the token out of shared logs and issues. Enter the company identity, +branding, HTTPS and transport addresses, administrator credentials and an +authenticator code in the wizard. The transport public key can be read from +`/root/id_ed25519.pub` in the `hbbs` container; the corresponding private key +must remain on the company server. Endpoints receive public bootstrap +configuration and require company approval after enrollment. A Linux server +also needs a company-controlled Windows worker for Windows installation bundles. + +Management runs as UID 10001 with a read-only root filesystem and private +persistent storage. Docker's SIGTERM requests a graceful shutdown. Preserve +all management, transport and Caddy volumes. Follow [SERVER_BACKUP.md](../../docs/SERVER_BACKUP.md) +for encrypted database, configuration, transport and full TLS storage backup +and restore; backing up only the management volume is insufficient. + +## Build and deployment checks + +The management image builds with two Cargo jobs by default to limit simultaneous +compiler memory use. Larger build hosts can override `CARGO_BUILD_JOBS` as a +Docker build argument. This setting applies only to the builder stage. + +The root and management Dockerfile both carry matching deny-by-default context +rules, including explicit child exclusions for older Docker engines. They admit +the named Cargo manifests, source, embedded scripts and branding assets while +excluding local environment files, keys, databases, installers and caches from +the build daemon and intermediate layers. Add newly required public build +inputs explicitly when changing the embedded source. + +The Rust builder, Debian runtime, Caddy HTTPS proxy and RustDesk transport images +are pinned by manifest digest. +Update those digests deliberately and rerun these checks when applying base-image +security updates. The runtime copies its public certificate trust bundle from +the pinned builder, rather than installing packages from live repositories. +Rustls reads that bundle through `SSL_CERT_FILE`; HTTPS certificate verification +remains enabled. An isolated offline rebuild of the exact `8cfdd8f` source, +using the pinned builder with a verified-empty target directory, produced the +same management executable SHA-256 as its original release build. Docker target +volumes must use `volume-nocopy`; otherwise Docker populates them with compiled +files from the builder image and the check reuses artifacts. This establishes +that Linux management binary comparison only. OCI image timestamps, other +components and native Windows builds still require separate reproducibility +checks; ordinary deployment tests do not establish them. + +From the repository root, with a working Linux Docker engine and OpenSSL: + +```sh +python3 deployment/linux/test_build_context.py +docker build --file deployment/linux/Dockerfile --tag swan-management:test . +python3 deployment/linux/test_container_startup.py swan-management:test +python3 deployment/linux/test_container_stack.py swan-management:test +``` + +The context test uses tracked public source and synthetic canaries, never local +credentials. The container test uses uniquely named disposable storage and a +loopback-only listener. It checks fresh setup, MFA and replay denial, required +device approval, policy denial, graceful shutdown and persisted company/device +identity. It also uses the real CLI for encrypted management backup and a second +volume for restore, checking wrong-password rejection before writes, retained +branding/policy/enrollment/consent, private key permissions, MFA replay denial +and revocation. Passphrases enter the CLI through stdin and stay out of arguments +and Docker container metadata. The test removes only its own containers and +volumes. Management-only restore does not prove transport or TLS-volume recovery. +These are actual Docker checks, but are not evidence for public +HTTPS, transport sessions, worker-generated installations, certificate renewal +or the Windows compatibility matrix. + +The component-stack test runs the pinned Caddy and RustDesk images on a private +Docker network with loopback-only published ports. It adds an internal test +issuer to a disposable copy of the proxy recipe, disables trust-store +installation, and verifies HTTPS using that CA explicitly. Default system trust +must reject the connection. It verifies real signed profiles, tamper/wrong-key +rejection, setup, MFA/enrollment/logout and retained company/transport trust +after restart. Native NAT-test replies prove local rendezvous protocol readiness; +they do not demonstrate NAT traversal or an authorized remote desktop session. +The test also stops the components, exports management, actual transport keys +and database, private deployment settings and Caddy storage into one encrypted +archive, then restores into separate volumes. A wrong password must fail before +creating restore files. Replacement services must retain the original HTTPS +CA, signed company profile, transport identity, enrollment and consent. MFA +replay, logged-out credentials and revoked device access remain denied; revoking +a device in the replacement must leave the original deployment unchanged. +Private keys remain inside volumes and the backup passphrase enters through +stdin. Empty storage directories need not appear in the archive; every original +TLS file must match the recovered copy. +Public ACME, UDP and external direct/relay sessions remain separate gates. +The company-product CI runs all three checks. diff --git a/deployment/linux/automatic-branding-sync-evidence.json b/deployment/linux/automatic-branding-sync-evidence.json new file mode 100644 index 00000000000..8d74d452694 --- /dev/null +++ b/deployment/linux/automatic-branding-sync-evidence.json @@ -0,0 +1,17 @@ +{ + "signed_profile_revision": 2, + "signature_independently_verified_against_original_pin": true, + "automatic_customer_watcher_sync": true, + "explicit_sync_command_used": false, + "installer_rebuilt_or_reinstalled": false, + "device_identity_preserved": true, + "company_identity_preserved": true, + "consent_preserved": true, + "software_release_sequence_preserved": true, + "customer_service_running": true, + "original_proof_sha256": "3e3ac64f208723835ae6ac5a6a32761362b6177bac8b6e91905c2e91002e071f", + "native_visible_ui_or_offline_behavior_qualified": false, + "date": "2026-10-26", + "application_source": "da0c621845057bed321a98a1886ef15891e431a9", + "scope": "Installed Server2019 customer configuration watcher synchronized a newly signed company branding revision without an explicit sync command or reinstall. Native visible UI, offline operation, restart and upgrade persistence remain separate." +} diff --git a/deployment/linux/company-full-backup-restore-evidence.json b/deployment/linux/company-full-backup-restore-evidence.json new file mode 100644 index 00000000000..a20d4c88182 --- /dev/null +++ b/deployment/linux/company-full-backup-restore-evidence.json @@ -0,0 +1,34 @@ +{ + "management_source": "761b5c56cca8cd7eecb65d3438de0d6f0075bd4e", + "customer_bootstrap_unchanged": true, + "company_identity_and_branding_preserved": true, + "restored_signed_profile_original_pin_verified": true, + "encrypted_archive": { + "sha256": "d9440d79d10287bcf2650888bef0f394123c1e915c0c8daf21f368a96cf2e709", + "bytes": 62837628, + "passphrase_stored_separately": true + }, + "limitations": [ + "Initial HTTPS verification through an internal-only Docker network timed out. The original attempt and restored storage were retained; a separate ordinary Docker bridge with loopback-only HTTPS passed without changing TLS trust or restored data.", + "Transport startup and exact restored keys/storage were checked. No native desktop session, public NAT traversal or Internet certificate renewal was exercised.", + "The existing Windows customer credential was read privately on that guest and used for a device-status API check against the restored endpoint; its configured server and credential were not changed.", + "This qualifies the independent Linux lab recovery path. Full Windows company-server recovery remains separate." + ], + "proxy_and_deployment_settings_match": true, + "wrong_password_rejected_before_writes": true, + "private_plaintext_keys_copied_to_host": false, + "restored_https_original_root_and_hostname_validated": true, + "complete_tls_file_tree_matches": true, + "both_original_download_links_and_hashes_work": true, + "restored_administrator_mfa_login": true, + "full_product_acceptance": false, + "original_company_endpoints_unchanged": true, + "recovery_clone_stopped": true, + "transport_key_pair_matches": true, + "both_installer_artifact_files_match": true, + "existing_windows_customer_credential_authenticates": true, + "restored_transport_components_running": true, + "logout_revocation_after_restore": true, + "date": "2026-10-26", + "device_identity_approval_and_consent_preserved": true +} diff --git a/deployment/linux/company-worker-bundle-evidence.json b/deployment/linux/company-worker-bundle-evidence.json new file mode 100644 index 00000000000..5ff02965c18 --- /dev/null +++ b/deployment/linux/company-worker-bundle-evidence.json @@ -0,0 +1,43 @@ +{ + "worker_signed_sha256": "9a3b67ee8a90944c7443d3cfa00a4ac921ddbb44ab98f0f9670ed23aa7cba84a", + "full_product_acceptance": false, + "worker_unsigned_current_source_byte_identity_verified": true, + "independent_linux_company": true, + "production_signing": false, + "test_channel_only": true, + "management_source": "761b5c56cca8cd7eecb65d3438de0d6f0075bd4e", + "editions": [ + { + "signed_payload_hashes_verified": true, + "seven_source_texts_exact": true, + "download_status": 200, + "anonymous_status": 200, + "edition": "customer", + "bundle_sha256": "f276b6e74adf33ce990684cfb505803211fcc9dc67185d2f28160bcdb0a6f84c", + "bytes": 32508760, + "profile_and_release_signatures_verified": true, + "public_bootstrap_only": true, + "tamper_rejection_verified": true + }, + { + "signed_payload_hashes_verified": true, + "seven_source_texts_exact": true, + "download_status": 200, + "anonymous_status": 401, + "edition": "technician", + "bundle_sha256": "21ae1dcf0239debfd096cfed652e09be50196f7c25d3ecd90755b992d5f9a4ec", + "bytes": 30301818, + "profile_and_release_signatures_verified": true, + "public_bootstrap_only": true, + "tamper_rejection_verified": true + } + ], + "application_source": "da0c621845057bed321a98a1886ef15891e431a9", + "limitations": [ + "Lab-signed Server2019-only test releases; no production signing or public Internet reachability claimed.", + "Completed bundle generation and download checks do not establish customer installation, enrollment, native sessions, automatic rollout or the remaining OS matrix." + ], + "worker_os": "Windows Server 2019 x64 build 17763.3650", + "updates_paused": true, + "date": "2026-10-26" +} diff --git a/deployment/linux/company.env.example b/deployment/linux/company.env.example new file mode 100644 index 00000000000..19a28689294 --- /dev/null +++ b/deployment/linux/company.env.example @@ -0,0 +1,3 @@ +SWAN_HOST=support.example.com +# Public key only. Never place the project signing private key on a company server. +SWAN_RELEASE_PUBLIC_KEY=REPLACE_WITH_PROJECT_RELEASE_PUBLIC_KEY diff --git a/deployment/linux/compose.maintained-transport.yaml b/deployment/linux/compose.maintained-transport.yaml new file mode 100644 index 00000000000..ba965600309 --- /dev/null +++ b/deployment/linux/compose.maintained-transport.yaml @@ -0,0 +1,13 @@ +# Explicit candidate deployment; never run against existing company volumes +# without the documented backup and migration procedure. +services: + hbbs: + image: swan-transport:maintained-test + build: + context: ../.. + dockerfile: deployment/transport/Dockerfile + hbbr: + image: swan-transport:maintained-test + build: + context: ../.. + dockerfile: deployment/transport/Dockerfile diff --git a/deployment/linux/compose.yaml b/deployment/linux/compose.yaml new file mode 100644 index 00000000000..7a61919d8f7 --- /dev/null +++ b/deployment/linux/compose.yaml @@ -0,0 +1,48 @@ +name: swan-company-server +services: + management: + build: + context: ../.. + dockerfile: deployment/linux/Dockerfile + restart: unless-stopped + environment: + SWAN_RELEASE_PUBLIC_KEY: ${SWAN_RELEASE_PUBLIC_KEY:?Set the trusted project release public key} + volumes: + - management-data:/var/lib/swan + networks: [management] + security_opt: [no-new-privileges:true] + read_only: true + tmpfs: [/tmp] + https: + image: caddy:2.10.2@sha256:c3d7ee5d2b11f9dc54f947f68a734c84e9c9666c92c88a7f30b9cba5da182adb + restart: unless-stopped + environment: + SWAN_HOST: ${SWAN_HOST:?Set the public company server hostname} + ports: ["80:80", "443:443", "443:443/udp"] + volumes: + - ./Caddyfile:/etc/caddy/Caddyfile:ro + - caddy-data:/data + - caddy-config:/config + networks: [management] + hbbs: + image: rustdesk/rustdesk-server:1.1.15@sha256:10818ec05b179039c6660f4d8e74b303f0db2858bbad2b18e24992ea22d54cd6 + command: ["hbbs", "-r", "${SWAN_HOST}:21117", "-k", "_"] + restart: unless-stopped + ports: ["21115:21115", "21116:21116", "21116:21116/udp"] + volumes: ["transport-data:/root"] + networks: [transport] + hbbr: + image: rustdesk/rustdesk-server:1.1.15@sha256:10818ec05b179039c6660f4d8e74b303f0db2858bbad2b18e24992ea22d54cd6 + command: ["hbbr", "-k", "_"] + restart: unless-stopped + ports: ["21117:21117"] + volumes: ["transport-data:/root"] + networks: [transport] +networks: + management: + transport: +volumes: + management-data: + transport-data: + caddy-data: + caddy-config: diff --git a/deployment/linux/customer-offline-controls-evidence.json b/deployment/linux/customer-offline-controls-evidence.json new file mode 100644 index 00000000000..3fbee9796df --- /dev/null +++ b/deployment/linux/customer-offline-controls-evidence.json @@ -0,0 +1,31 @@ +{ + "os": "Windows Server2019 x64 build 17763.3650", + "installed_hash_and_signature_valid_after_resume": true, + "customer_window_initially_not_responding": true, + "customer_window_later_responsive_while_offline": true, + "full_product_acceptance": false, + "management_offline_started_utc": "2026-10-26T19:51:37.281183Z", + "application_source": "da0c621845057bed321a98a1886ef15891e431a9", + "resume_control_restored_customer_service": true, + "stop_control_changed_to_resume": true, + "device_identity_preserved": true, + "profile_revision_after_resume": 2, + "guest_free_memory_mib_at_observation": 444, + "customer_service_absent_after_stop": true, + "company_identity_preserved": true, + "management_restored_utc": "2026-10-26T21:56:44.1364+02:00", + "consent_preserved": true, + "worker_preserved_during_stop": true, + "limitations": [ + "The customer window initially became unresponsive, then recovered. Support-control state changes were delayed. Root cause and acceptable latency remain unresolved.", + "This was a 2GiB disposable guest with 444MiB free at the recorded observation. Resource pressure is a hypothesis, not an established cause.", + "No active remote session was running. Immediate session termination, standard-user controls, offline authorization denial and complete restart/upgrade behavior are not qualified.", + "The original Linux management container restarted automatically and retained its identity and branding revision. This is a service-restart check, not encrypted backup/restore." + ], + "guest_process_health_observation_utc": "2026-10-26T19:55:27.9961828Z", + "cached_updated_branding_visible": true, + "date": "2026-10-26", + "native_customer_started_during_offline_window": true, + "release_sequence_after_resume": 1, + "original_resume_record_sha256": "76ab1f00db976dbf252d1f18ac247858231548e8372608b45b50f8a5dbab660c" +} diff --git a/deployment/linux/export_transport_candidate.py b/deployment/linux/export_transport_candidate.py new file mode 100644 index 00000000000..540f722e0f0 --- /dev/null +++ b/deployment/linux/export_transport_candidate.py @@ -0,0 +1,69 @@ +"""Retain public candidate binaries and their exact source from the tested image.""" +import hashlib +import json +from pathlib import Path, PurePosixPath +import subprocess +import sys +import zipfile + +from test_container_stack import ROOT, maintained_transport_image +from test_container_startup import docker + + +def main(): + if len(sys.argv) != 2: + raise SystemExit("Expected a new candidate artifact directory") + output = Path(sys.argv[1]).resolve() + if output.exists(): + raise SystemExit("Candidate artifact directory already exists") + image = maintained_transport_image() + revision = subprocess.check_output(["git", "-C", str(ROOT), "rev-parse", "HEAD"], text=True).strip() + output.mkdir(parents=True) + container = docker("create", "--network", "none", image) + # No candidate executable runs during extraction. Remove only this owned, + # stopped extraction container; failed output remains available for diagnosis. + try: + files = {"hbbs": "/usr/local/bin/hbbs", "hbbr": "/usr/local/bin/hbbr"} + files.update({name: "/usr/share/doc/swan-transport/" + name for name in + ["SWAN-SOURCE.json", "transport-build-source.zip", "LICENCE"]}) + for name, remote in files.items(): + docker("cp", container + ":" + remote, str(output / name)) + path = output / name + if path.is_symlink() or not path.is_file() or not 0 < path.stat().st_size <= 128 * 1024**2: + raise RuntimeError("Unexpected candidate artifact") + for name in ["hbbs", "hbbr"]: + with (output / name).open("rb") as binary: + header = binary.read(20) + if header[:6] != b"\x7fELF\x02\x01" or header[18:20] != b"\x3e\x00": + raise RuntimeError("Expected Linux x86-64 transport executable") + receipt = json.loads((output / "SWAN-SOURCE.json").read_text()) + with zipfile.ZipFile(output / "transport-build-source.zip") as archive: + entries = archive.infolist() + if len(entries) != len({entry.filename for entry in entries}) or sum(entry.file_size for entry in entries) > 64 * 1024**2: + raise RuntimeError("Duplicate or oversized transport source") + for entry in entries: + path = PurePosixPath(entry.filename) + if path.is_absolute() or ".." in path.parts or "\\" in entry.filename or entry.is_dir(): + raise RuntimeError("Unsafe transport source archive entry") + if archive.testzip() is not None: + raise RuntimeError("Transport source CRC mismatch") + if json.loads(archive.read("rustdesk-server/SWAN-SOURCE.json")) != receipt: + raise RuntimeError("Source archive and executable image receipts differ") + udp = archive.read("rustdesk-server/libs/hbb_common/src/udp.rs").replace(b"\r\n", b"\n") + if hashlib.sha256(udp).hexdigest() != receipt["patched_udp_sha256"]: + raise RuntimeError("Archived source lacks the reviewed UDP patch") + manifest = {"schema": 1, "candidate_only": True, "production_approved": False, + "repository": "https://github.com/rijff24/SwanRemoteSupport", "image_id": image, + "packaging_revision": revision, "transport_recipe_revision": receipt["project_revision"], + "dockerfile_sha256": hashlib.sha256((ROOT / "deployment/transport/Dockerfile").read_bytes()).hexdigest(), + "files": {name: {"sha256": hashlib.sha256((output / name).read_bytes()).hexdigest(), + "bytes": (output / name).stat().st_size} for name in files}, + "scope": "Public unsigned Linux candidate build inputs and outputs; not binary reproducibility or release approval"} + (output / "SOURCE.json").write_text(json.dumps(manifest, indent=2), encoding="utf-8") + print("Retained Linux x86-64 candidate binaries, reviewed patched source, receipt and license.") + finally: + docker("rm", container) + + +if __name__ == "__main__": + main() diff --git a/deployment/linux/fresh-company-lab-evidence.json b/deployment/linux/fresh-company-lab-evidence.json new file mode 100644 index 00000000000..ff917958bfe --- /dev/null +++ b/deployment/linux/fresh-company-lab-evidence.json @@ -0,0 +1,30 @@ +{ + "date": "2026-10-26", + "source_revision": "761b5c56cca8cd7eecb65d3438de0d6f0075bd4e", + "tracked_source_archive_sha256": "af80b7e34db21d642907bdc30b17d884f7617dbe0387bbb3d5a051b3c20d831e", + "management_image_build_exit": 0, + "cargo_build_jobs": 2, + "fresh_independent_company": true, + "existing_company_keys_copied": false, + "host_published_ports_loopback_only": true, + "verified": { + "https_certificate_and_hostname": true, + "host_default_trust_rejects_lab_ca": true, + "signed_profile_and_custom_branding": true, + "administrator_totp_login": true, + "invalid_totp_rejected": true, + "totp_replay_rejected": true, + "fresh_inventory_empty": true, + "anonymous_inventory_denied": true, + "protected_worker_registration": true, + "anonymous_worker_inventory_denied": true, + "logged_out_token_denied": true + }, + "limitations": [ + "An internal lab HTTPS issuer was trusted explicitly by the test connection only. Public ACME and Internet reachability remain untested.", + "New lab-only accounts, company keys and release issuer are isolated from the preserved existing company. Private inputs stay outside Git.", + "This verifies the Linux stack and management API; Windows server setup and full Windows restore remain separate requirements.", + "A Windows worker has not yet consumed the new credential. Company package generation, native enrollment, sessions and automatic rollout remain unqualified for this company." + ], + "full_product_acceptance": false +} diff --git a/deployment/linux/generated-customer-enrollment-evidence.json b/deployment/linux/generated-customer-enrollment-evidence.json new file mode 100644 index 00000000000..94b2d3b874e --- /dev/null +++ b/deployment/linux/generated-customer-enrollment-evidence.json @@ -0,0 +1,26 @@ +{ + "downloaded_generated_bundle": true, + "guest_install_command_exit": 0, + "company_identity_matches": true, + "device_identity_matches_server": true, + "server_approval_state": "pending", + "grant_before_approval_status": 403, + "customer_service_running": true, + "worker_service_preserved": true, + "unattended_consent": false, + "pending_install_absent": true, + "profile_revision": 1, + "release_sequence": 1, + "manual_address_edit": false, + "tailscale_required": false, + "guest_proof_sha256": "46e3d7b4c4d8c8d9c82d01277d205f4c55074bfa03c8ff40292b1e6ee61f3372", + "native_session_qualified": false, + "date": "2026-10-26", + "application_source": "da0c621845057bed321a98a1886ef15891e431a9", + "os": "Windows Server 2019 x64 build 17763.3650", + "limitations": [ + "Lab CA installed only on the disposable guest; no public Internet or production signing claim.", + "Installation invoked the generated company script from an administrator session with explicit lab company confirmation; customer UAC and standard-user technician behavior remain separate.", + "Pre-approval grant denial is a management API check; receiving native protocol enforcement, approved sessions, public direct/relay connectivity and updates remain open." + ] +} diff --git a/deployment/linux/generated-technician-setup-evidence.json b/deployment/linux/generated-technician-setup-evidence.json new file mode 100644 index 00000000000..78b4b718ff9 --- /dev/null +++ b/deployment/linux/generated-technician-setup-evidence.json @@ -0,0 +1,17 @@ +{ + "release_sequence": 2, + "current_branding_received": true, + "native_session_tested": false, + "company_identity_matches": true, + "no_device_token_in_technician_state": true, + "edition": "technician", + "installed_signature_valid": true, + "pending_install_absent": true, + "technician_login_tested": false, + "standard_user_qualified": false, + "profile_revision": 2, + "date": "2026-10-26", + "scope": "Generated company technician bundle installed per-user in owned administrator profile on Server2019. This is not standard-user installation or a technician login/session test.", + "application_source": "da0c621845057bed321a98a1886ef15891e431a9", + "private_original_record_sha256": "7f0fc56ba8655b1b54757c449122c032b0c83c512dfa0ca238d90ac3f3d58876" +} diff --git a/deployment/linux/installed-profile-key-rotation-evidence.json b/deployment/linux/installed-profile-key-rotation-evidence.json new file mode 100644 index 00000000000..65f3915845d --- /dev/null +++ b/deployment/linux/installed-profile-key-rotation-evidence.json @@ -0,0 +1,103 @@ +{ + "schema": 1, + "observed_utc": "2026-10-26T21:23:15.229005+00:00", + "environment": "Independent Linux company, loopback HTTPS with owned Server 2019 Desktop endpoint", + "management_source": "761b5c56cca8cd7eecb65d3438de0d6f0075bd4e", + "installed_application_source": "da0c621845057bed321a98a1886ef15891e431a9", + "before_profile_revision": 3, + "prepared_profile_revision": 4, + "activated_profile_revision": 5, + "both_editions_cached_old_key_signed_transition_before_activation": true, + "both_installed_editions_automatically_adopted_new_key": true, + "manual_agent_sync_used_for_installed_editions": false, + "customer_unattended_consent_remained_revoked": true, + "customer_consent_revision": 2, + "software_release_sequences_preserved": { + "customer": 1, + "technician": 2 + }, + "missed_transition_shipping_agent_rejected_new_signature": true, + "missed_transition_cached_state_byte_identical": true, + "negative_harness_note": "PowerShell stopped on expected native signature error; a separate read-only inspection verified unchanged cached state without rerunning sync.", + "pre_rotation_encrypted_backup": { + "utc": "2026-10-26T21:16:35.635462+00:00", + "bytes": 62837995, + "sha256": "5c8830582da1f56e6c58c0f02a5a556146b3aadb678d082f89e988bd874ed11d", + "separate_passphrase": true + }, + "worker_pin_updated_by_operator_from_trusted_transition": true, + "old_both_bundle_links_returned_404": true, + "replacement_test_bundles": "Both worker jobs completed; downloaded and independently verified", + "production_signing": false, + "limitations": [ + "No active desktop session during rotation; native session termination unqualified", + "Only owned Server 2019 endpoint; broader Windows matrix remains open", + "Loopback lab HTTPS does not qualify public Internet deployment", + "No interrupted activation recovery; restored native transport/session behavior remains unqualified" + ], + "replacement_bundle_verification": [ + { + "edition": "customer", + "bundle_sha256": "89e7d0989d00a97bad4ae4560ca7a24c70e9ce3ebba30ca6f918dcdfa0461dd7", + "bytes": 32508756, + "public_bootstrap_only": true, + "profile_and_release_signatures_verified": true, + "tamper_rejection_verified": true, + "seven_source_texts_exact": true, + "signed_payload_hashes_verified": true, + "anonymous_status": 200, + "download_status": 200 + }, + { + "edition": "technician", + "bundle_sha256": "2cda243b34fb5647eba32851784df6ca90f18cef5963f128fa4cdf533703f4f1", + "bytes": 30301813, + "public_bootstrap_only": true, + "profile_and_release_signatures_verified": true, + "tamper_rejection_verified": true, + "seven_source_texts_exact": true, + "signed_payload_hashes_verified": true, + "anonymous_status": 401, + "download_status": 200 + } + ], + "post_rotation_encrypted_backup": { + "utc": "2026-10-26T21:29:37.159260+00:00", + "bytes": 125642740, + "sha256": "934ed759b19a6fe364c51fe8eaf33f86dda9620122aa26cf918f7828dd95aa0d", + "separate_passphrase": true + }, + "rotated_company_restore": { + "utc": "2026-10-26T21:30:48.244867+00:00", + "restored_https_original_ca_and_hostname": true, + "restored_active_signing_key_from_database": true, + "legacy_seed_unchanged": true, + "profile_revision": 5, + "administrator_mfa": true, + "device_approval_identity_and_revoked_consent_preserved": true, + "replacement_downloads": [ + { + "edition": "customer", + "bundle_sha256": "89e7d0989d00a97bad4ae4560ca7a24c70e9ce3ebba30ca6f918dcdfa0461dd7" + }, + { + "edition": "technician", + "bundle_sha256": "2cda243b34fb5647eba32851784df6ca90f18cef5963f128fa4cdf533703f4f1" + } + ], + "old_downloads_still_withdrawn": true, + "no_native_transport_started": true, + "native_session_qualified": false + }, + "restored_existing_device_credential": { + "company_and_device_identity_match": true, + "approval_state": "approved", + "existing_customer_credential_authenticated_to_restored_server": true, + "utc": "2026-10-26T21:31:15.5723682Z", + "original_https_trust_used": true, + "credential_value_disclosed": false, + "native_session_tested": false, + "customer_bootstrap_and_credential_unchanged": true + }, + "recovery_services_stopped": true +} diff --git a/deployment/linux/maintained-transport-and-current-restore-20261028.json b/deployment/linux/maintained-transport-and-current-restore-20261028.json new file mode 100644 index 00000000000..a93a8204704 --- /dev/null +++ b/deployment/linux/maintained-transport-and-current-restore-20261028.json @@ -0,0 +1,144 @@ +{ + "evidence_date": "2026-10-28", + "project_source": "07bb8d0b99e2be002a30e5d97eebe67a9c2794ad", + "transport_recipe": "650361413543baf7ef150f5960544154ec77ddeb", + "upstream_transport": "9bae9f2f39d92c4b4ba2e28e089da5071897b22e", + "shared_source": "83419b6549636ee39dacef7776c473f5802e08d6", + "source_verification": { + "artifact": 12798079841, + "run_id": 40647659405, + "original_public_git_blobs_verified": 132, + "reviewed_patched_udp_verified": true, + "exact_public_recipe_files_verified": 6, + "artifact_hashes_verified": true, + "elf_linux_x64_verified": true + }, + "independent_compilation": { + "workflow_runs": [ + 40647659405, + 40647664923 + ], + "independent_jobs": { + "40647659405": { + "job_id": 122680134783, + "name": "linux-deployment", + "required_steps_passed": [ + "Build maintained transport from pinned public source with corresponding source", + "Preserve maintained Linux transport binaries and exact patched source", + "Rehearse maintained transport with HTTPS, enrollment and complete backup recovery" + ] + }, + "40647664923": { + "job_id": 122680152595, + "name": "linux-deployment", + "required_steps_passed": [ + "Build maintained transport from pinned public source with corresponding source", + "Preserve maintained Linux transport binaries and exact patched source", + "Rehearse maintained transport with HTTPS, enrollment and complete backup recovery" + ] + } + }, + "independent_pr_merge": "1ba02cc849fcf55fbbc68d2757f56b35d9e783d0", + "project_trees_identical": true, + "public_tree": "40d789382280e6577f4f2615ce73ff9cbd9289ef", + "source_archive_entries_identical": 140, + "original_elf_binaries": { + "hbbs": { + "sha256": "6bacfa973533248aa24c2917469149d3e08246140f5423160a6da521c3dd018a", + "bytes": 9543512, + "original_bytes_identical": true, + "normalized": false + }, + "hbbr": { + "sha256": "132ab0c989a110a3335220d1afbbdf022afa559cf9bec23b8419f473d04388a3", + "bytes": 3269592, + "original_bytes_identical": true, + "normalized": false + } + }, + "oci_images_or_zip_metadata_identical_not_claimed": true + }, + "isolated_runtime": { + "verified_public_executable_hashes": true, + "embedded_exact_source_verified": true, + "private_key_file_mode": "600", + "private_key_directory_mode": "700", + "host_ports_published": false, + "new_isolated_keys_only": true, + "private_key_exported": false, + "original_company_container_ids_unchanged": true, + "original_company_healthy": true + }, + "current_full_backup": { + "bytes": 439647643, + "sha256": "61e947aee5cedbcc73b4fd50cc68446dc8eb76c99d15653fdf1a34eba9eb5e76", + "installer_artifact_count": 14, + "installer_artifact_bytes": 439582017, + "profile_revision": 14, + "separate_passphrase": true + }, + "independent_restore": { + "restored_https_original_ca_and_hostname": true, + "restored_active_signing_key_from_database": true, + "legacy_seed_unchanged": true, + "profile_revision": 14, + "administrator_mfa": true, + "device_approval_identity_and_revoked_consent_preserved": true, + "all_four_device_records_preserved": true, + "all_fourteen_installer_artifacts_readable_by_service_uid": true, + "current_proxy_configuration_and_complete_tls_match": true, + "current_bundle_downloads": [ + { + "edition": "customer", + "authenticated": false, + "status": 200, + "hash_matched": true + }, + { + "edition": "customer", + "authenticated": true, + "status": 200, + "hash_matched": true + }, + { + "edition": "technician", + "authenticated": false, + "status": 401, + "hash_matched": false + }, + { + "edition": "technician", + "authenticated": true, + "status": 200, + "hash_matched": true + } + ], + "release_approvals_match_backup": true, + "failed_152_downloads_still_withdrawn": true, + "no_native_transport_started": true + }, + "owned_lab_migration": { + "candidate_image": "sha256:3e879d6f3954ff2bc750ab02cdea43e05894e75cd4f8d288c411450d2fae93ca", + "full_restore_and_current_downloads_verified": true, + "original_company_vm_or_host_rustdesk_changed": false, + "only_owned_fresh_linux_lab": true, + "private_keys_copied_to_host": false, + "original_and_restored_private_public_keys_unchanged": true, + "profile_transport_pin_matches": true, + "management_https_container_ids_unchanged": true, + "devices_consent_and_release_approvals_unchanged": true, + "existing_company_healthy": true, + "candidate_transport_active_in_owned_lab": true, + "native_direct_relay_and_udp_qualification_pending": true + }, + "tcp_startup_only_not_native_session_proof": true, + "machine_configuration_or_credentials_in_receipt": false, + "normal_production_compose_pins_changed": false, + "production_publication": false, + "remaining": [ + "Actual authorized native direct and relay sessions through this maintained Linux transport.", + "Negative receiving-device authorization tests on both transports.", + "Public Internet reachability, NAT traversal and CGNAT qualification.", + "Production release approval and signing." + ] +} diff --git a/deployment/linux/native-technician-interface-evidence.json b/deployment/linux/native-technician-interface-evidence.json new file mode 100644 index 00000000000..c709a4c7ca6 --- /dev/null +++ b/deployment/linux/native-technician-interface-evidence.json @@ -0,0 +1,21 @@ +{ + "os": "Windows Server 2019 x64 build 17763.3650", + "generated_technician_bundle_installed": true, + "original_verified_launcher_used": true, + "actual_desktop_session": 1, + "company_branding_visible": true, + "updated_signed_branding_visible": true, + "company_https_origin_visible": true, + "username_password_and_totp_entered_only_in_native_form": true, + "gui_mfa_sign_in_succeeded": true, + "authorized_device_and_group_visible": true, + "session_history_visible": true, + "independent_server_login_audit_matches": true, + "same_machine_customer_and_technician": true, + "native_remote_session_exercised": false, + "standard_user_install_qualified": false, + "full_product_acceptance": false, + "date": "2026-10-26", + "gui_sign_out_clears_inventory_and_secret_fields": true, + "scope": "Original verified technician launcher on owned administrator console. Company branding, MFA form sign-in, authorized device/group, history and sign-out observed in the real Flutter interface. Same-machine endpoint; no remote desktop session or standard-user installation claimed." +} diff --git a/deployment/linux/native-unattended-consent-evidence.json b/deployment/linux/native-unattended-consent-evidence.json new file mode 100644 index 00000000000..2259037fdf6 --- /dev/null +++ b/deployment/linux/native-unattended-consent-evidence.json @@ -0,0 +1,29 @@ +{ + "native_confirmation_identifies_company_and_signin_screen_access": true, + "native_revocation_persisted_locally": true, + "server_consent_false_after_revoke": true, + "company_policy_allows_unattended": true, + "subsequent_unattended_grant_status": 403, + "signed_policy_revision": 3, + "customer_service_remains_running": true, + "local_consent_revision_after_optin": 1, + "date": "2026-10-26", + "grant_signature_and_identity_bindings_verified": true, + "device_initially_requires_customer_approval": true, + "limitations": [ + "These native GUI consent operations were performed in the owned administrator desktop. Standard-user consent controls remain separate.", + "The signed unattended grant was never claimed by a receiving native device and had expired before the final closure inspection. No active-session or unexpired-lease termination is qualified.", + "Company policy and device consent, identity-bound grant issuance and subsequent management denial were tested. Actual unattended desktop, reboot/logon-screen access, UAC and public transport remain separate requirements." + ], + "application_source": "da0c621845057bed321a98a1886ef15891e431a9", + "prior_unused_grant_closed_and_lease_cleared": true, + "full_product_acceptance": false, + "consent_revision_increases_on_revoke": true, + "prior_grant_still_time_valid_at_final_check": false, + "unattended_grant_before_device_consent_status": 403, + "os": "Windows Server2019 x64 build 17763.3650", + "software_release_sequence_preserved": true, + "server_records_device_consent": true, + "authorized_unattended_grant_after_consent_status": 200, + "native_explicit_optin_persisted": true +} diff --git a/deployment/linux/pre-153-company-backup-restore-evidence.json b/deployment/linux/pre-153-company-backup-restore-evidence.json new file mode 100644 index 00000000000..23c4ea214a8 --- /dev/null +++ b/deployment/linux/pre-153-company-backup-restore-evidence.json @@ -0,0 +1,109 @@ +{ + "schema": 1, + "observed_utc": "2026-10-27T07:55:08.505526+00:00", + "management_source": "761b5c56cca8cd7eecb65d3438de0d6f0075bd4e", + "purpose": "Fresh Linux company recovery checkpoint before 1.5.3 runtime qualification", + "backup": { + "bytes": 251256558, + "sha256": "3bb8ebefd8d1e838dcafed89383414e27d5ec2366515cba127c1e26000ff09ae", + "encrypted": true, + "passphrase_stored_separately": true, + "includes": [ + "management database and configuration", + "company trust keys", + "transport keys", + "HTTPS storage", + "deployment environment", + "generated installer artifacts" + ] + }, + "preconditions": { + "no_claimed_live_session": true, + "no_queued_or_running_installer_job": true, + "failed_152_releases_unapproved": true + }, + "restore": { + "utc": "2026-10-27T07:53:57.615325+00:00", + "restored_https_original_ca_and_hostname": true, + "restored_active_signing_key_from_database": true, + "legacy_seed_unchanged": true, + "profile_revision": 5, + "administrator_mfa": true, + "device_approval_identity_and_revoked_consent_preserved": true, + "withdrawn_downloads": [ + { + "edition": "customer", + "withdrawn_download_status": 404 + }, + { + "edition": "technician", + "withdrawn_download_status": 404 + } + ], + "both_device_records_preserved": true, + "release_approvals_match_backup": true, + "failed_152_downloads_still_withdrawn": true, + "no_native_transport_started": true, + "native_session_qualified": false + }, + "after": { + "utc": "2026-10-27T07:55:08.505526+00:00", + "recovery_containers": { + "management": "exited", + "https": "exited" + }, + "original_https_healthy": true, + "original_signed_profile_current": true, + "profile_revision": 5, + "updates_paused": true, + "original_company_volumes_not_replaced": true + }, + "source_receipts": { + "pre-loaderfix153-backup-start.json": "9570b70a86c68b85f7a2b9441bd9ef75be0104e612c7314611d8aadbbb1a70a0", + "pre-loaderfix153-backup-completed.json": "273771adfb5f96ffd14e686375739fc998fbeaa5775d9b8855712233e2fe5650", + "loaderfix153-restore-verified.json": "ecce3beff8d0eb9967fd20bca81162adc32057b8b53afe48dd859efd13b05b36", + "loaderfix153-restore-stopped.json": "9b8cb17e7e861bb3ae7857ef8ba17532f5c1e0d1b7a1b4d9607e090250c06417", + "loaderfix153-restore-independent-stop-health.json": "baf298f6840770067250820cc7e31f6dfbd9be1dc37a38b7b35cbd5b0ef7bfc0", + "pre153-restored-customer-credential-bounded-result.json": "38448d1398e347e695c3833dc622f0aac2adfbb4ace666597f35411c90b11683", + "post-recovery-win10-restored-native-token-verified.json": "556b438e0424c1c0c489e5737b70ff3b6689ffd6c401b1498e4f5677e96454fb", + "post-recovery-win10-restored-native-token-clone-stopped.json": "5215e28a5f5677dbac844ce4880f0025c9c79f7e7a012d5a66d2d4ac3c7edc0b" + }, + "limits": [ + "Isolated Linux company restore only; does not qualify the separate Windows server restore path.", + "Restored transport was not started; no native connection to the clone was attempted.", + "No 1.5.3 binaries installed or automatic-update recovery exercised by this test.", + "Lab signing and private HTTPS trust do not establish production signing or public Internet reachability." + ], + "production_publication": false, + "installed_customer_credential_reuse": { + "utc": "2026-10-27T08:12:54.478715+00:00", + "server2019_existing_customer_credential_restore": { + "company_and_device_identity_match": true, + "approval_state": "approved", + "existing_customer_credential_authenticated_to_restored_server": true, + "utc": "2026-10-27T07:59:07.0361530Z", + "original_https_trust_used": true, + "credential_value_disclosed": false, + "native_session_tested": false, + "customer_bootstrap_and_credential_unchanged": true, + "endpoint_os": "Windows Server 2019" + }, + "windows10_result": "Not tested: SSH timed out; subsequent trace authenticated but session command startup stalled. Guest normal shutdown requested, not forced.", + "recovery_containers_independently_confirmed_stopped": true, + "private_credentials_exported": false, + "both_customer_credentials_verified": false, + "native_session_tested": false + }, + "windows10_credential_after_normal_recovery": { + "company_and_device_identity_match": true, + "approval_state": "approved", + "existing_customer_credential_authenticated_to_restored_server": true, + "utc": "2026-10-27T08:17:30.6749819Z", + "original_https_trust_used": true, + "credential_value_disclosed": false, + "native_session_tested": false, + "customer_bootstrap_and_credential_unchanged": true + }, + "both_installed_customer_credentials_authenticated_after_restore": true, + "normal_power_cycle_not_full_kernel_restart": true +} diff --git a/deployment/linux/shipping-agent-cached-policy-negative-evidence.json b/deployment/linux/shipping-agent-cached-policy-negative-evidence.json new file mode 100644 index 00000000000..d875968ea77 --- /dev/null +++ b/deployment/linux/shipping-agent-cached-policy-negative-evidence.json @@ -0,0 +1,72 @@ +{ + "schema": 1, + "application_source": "da0c621845057bed321a98a1886ef15891e431a9", + "shipping_agent_sha256": "c7bfac26344a8f46aa2238b631e3be25785eaaeb5fd721cbf844c438be4d6206", + "environment": "Owned Server 2019 Desktop lab endpoint", + "command": "verify-bootstrap with process-local SWAN_STATE_DIR and isolated public-only state copies", + "network_used": false, + "existing_installed_state_modified": false, + "cached_validation": { + "utc": "2026-10-26T21:26:20.2022527Z", + "shipping_agent_sha256": "c7bfac26344a8f46aa2238b631e3be25785eaaeb5fd721cbf844c438be4d6206", + "cases": [ + { + "accepted": true, + "case": "valid-control", + "error": "", + "state_unchanged": true, + "exit_code": 0 + }, + { + "accepted": false, + "case": "tampered-payload", + "error": "Error: Invalid signature\n\nCaused by:\n 0: signature error: Verification equation was not satisfied\n 1: Verification equation was not satisfied", + "state_unchanged": true, + "exit_code": 1 + }, + { + "accepted": false, + "case": "wrong-company", + "error": "Error: Wrong company or schema", + "state_unchanged": true, + "exit_code": 1 + }, + { + "accepted": false, + "case": "older-revision", + "error": "Error: Replayed profile", + "state_unchanged": true, + "exit_code": 1 + } + ], + "network_not_used": true + }, + "expiration_validation": { + "installed_company_state_unchanged": true, + "utc": "2026-10-26T21:27:29.1362011Z", + "cases": [ + { + "accepted": true, + "case": "signed-valid-control", + "error": "", + "state_unchanged": true, + "exit_code": 0 + }, + { + "accepted": false, + "case": "signed-expired", + "error": "Error: Expired or future profile", + "state_unchanged": true, + "exit_code": 1 + } + ], + "fresh_disposable_fixture_issuer_only": true, + "network_not_used": true + }, + "expiration_issuer": "Fresh disposable test key; private key never persisted or transferred; valid and expired controls signed by the same fixture issuer", + "limitations": [ + "Does not prove incoming hostile HTTPS profile rejection", + "Does not prove session receiver authorization or live session termination", + "Does not qualify non-administrator execution or other Windows versions" + ] +} diff --git a/deployment/linux/technician-authorization-evidence.json b/deployment/linux/technician-authorization-evidence.json new file mode 100644 index 00000000000..e1dcf359597 --- /dev/null +++ b/deployment/linux/technician-authorization-evidence.json @@ -0,0 +1,18 @@ +{ + "actual_native_enrolled_device_approved": true, + "technician_totp_logins": 2, + "assigned_technician_inventory_contains_device": true, + "unassigned_technician_inventory_empty": true, + "unassigned_grant_status": 403, + "technician_admin_worker_creation_status": 401, + "unattended_without_consent_status": 403, + "authorized_attended_grant_signature_verified": true, + "grant_company_device_technician_proof_and_expiry_verified": true, + "group_revocation_denies_inventory_and_new_grants": true, + "authorized_group_access_restored": true, + "resumed_test_sessions_logged_out": true, + "first_attempt_unused_tokens_expire_normally": true, + "native_protocol_session_or_lease_qualified": false, + "date": "2026-10-26", + "scope": "Management API authorization against the device enrolled by the generated native customer installer. No receiving native session or lease was exercised." +} diff --git a/deployment/linux/test_backup_memory.py b/deployment/linux/test_backup_memory.py new file mode 100644 index 00000000000..4921e249302 --- /dev/null +++ b/deployment/linux/test_backup_memory.py @@ -0,0 +1,170 @@ +"""Run the real backup/restore CLI above its former cap with a bounded address space. + +Uses generated, disposable company state only. Never accepts a live data path. +""" +import hashlib +import json +import os +from pathlib import Path +import resource +import secrets +import shutil +import signal +import sqlite3 +import subprocess +import sys +import tempfile +import time +import uuid + + +def main(): + binary = Path(sys.argv[1]).resolve(strict=True) + output = Path(sys.argv[2]) + assert not output.exists(), "Preserve the existing qualification receipt" + memory_limit = 512 * 1024**2 + chunk_size = 1024**2 + assert shutil.disk_usage(tempfile.gettempdir()).free >= 4 * 1024**3 + + def bound_memory(): + resource.setrlimit(resource.RLIMIT_AS, (memory_limit, memory_limit)) + + def bound_file_size(): + bound_memory() + # Exercise a real write error without exhausting the runner's disk. + # Keep SIGXFSZ ignored across exec so the CLI can run its error cleanup. + signal.signal(signal.SIGXFSZ, signal.SIG_IGN) + resource.setrlimit(resource.RLIMIT_FSIZE, (2 * chunk_size, 2 * chunk_size)) + + with tempfile.TemporaryDirectory(prefix="swan-backup-memory-") as temporary: + root = Path(temporary) + data = root / "company" + data.mkdir(mode=0o700) + with sqlite3.connect(data / "management.sqlite3") as db: + db.executescript("CREATE TABLE identity(value TEXT); INSERT INTO identity VALUES('generated company fixture');") + key = secrets.token_bytes(32) + (data / "profile-key.hex").write_text(key.hex(), encoding="ascii") + (data / "setup-token.txt").write_text(secrets.token_urlsafe(32), encoding="ascii") + artifacts = data / "artifacts" + artifacts.mkdir(mode=0o700) + name = str(uuid.uuid4()) + ".zip" + expected = hashlib.sha256() + with (artifacts / name).open("xb") as file: + for _ in range(513): + chunk = secrets.token_bytes(chunk_size) + file.write(chunk) + expected.update(chunk) + environment = os.environ.copy() + environment.update(SWAN_DATA_DIR=str(data), SWAN_BACKUP_PASSPHRASE=secrets.token_urlsafe(48)) + backup = root / "company.swan-backup" + target = root / "restored" + + def run(arguments): + result = subprocess.run([str(binary), *arguments], env=environment, + capture_output=True, timeout=600, preexec_fn=bound_memory) + # Do not echo potentially sensitive command output or environment. + assert result.returncode == 0, "Memory-limited management CLI failed (exit %s)" % result.returncode + + run(["backup", str(backup), "--installer-artifacts-directory", str(artifacts)]) + assert backup.stat().st_size > 512 * 1024**2 + with backup.open("rb") as file: + assert file.read(7) == b"SWANBK2" + environment["SWAN_DATA_DIR"] = str(target) + run(["restore", str(backup)]) + actual = hashlib.sha256() + received = target / "artifacts" / name + assert received.stat().st_size == 513 * chunk_size + with received.open("rb") as file: + while chunk := file.read(chunk_size): + actual.update(chunk) + assert actual.digest() == expected.digest() + assert (target / "profile-key.hex").read_text(encoding="ascii") == key.hex() + assert (target / "setup-token.txt").read_bytes() == (data / "setup-token.txt").read_bytes() + with sqlite3.connect(target / "management.sqlite3") as db: + assert db.execute("PRAGMA quick_check").fetchone()[0] == "ok" + assert db.execute("SELECT value FROM identity").fetchone()[0] == "generated company fixture" + assert target.stat().st_mode & 0o777 == 0o700 + assert (target / "profile-key.hex").stat().st_mode & 0o777 == 0o600 + assert not list(root.glob(".swan-backup-*")) + assert not list(data.glob(".swan-backup-*")) + + environment["SWAN_DATA_DIR"] = str(data) + failed_export = root / "write-failure.swan-backup" + failed = subprocess.run([str(binary), "backup", str(failed_export), + "--installer-artifacts-directory", str(artifacts)], + env=environment, capture_output=True, timeout=60, + preexec_fn=bound_file_size, restore_signals=False) + assert failed.returncode > 0, "Write-limit failure must return an ordinary CLI error" + assert not failed_export.exists() + assert not list(data.glob(".swan-backup-*")) + assert not list(data.glob("backup-*.sqlite3")) + + interrupted_target = root / "interrupted-restore" + environment["SWAN_DATA_DIR"] = str(interrupted_target) + child = subprocess.Popen([str(binary), "restore", str(backup)], env=environment, + stdout=subprocess.PIPE, stderr=subprocess.PIPE, + preexec_fn=bound_memory) + # Wait for authenticated plaintext to reach private staging, rather than + # killing an arbitrary startup phase or claiming a simulated interruption. + try: + deadline = time.monotonic() + 60 + while True: + staged = list(root.glob(".swan-backup-*/archive.zip")) + if any(path.stat().st_size >= chunk_size for path in staged): + assert child.poll() is None, "Restore finished before the interruption" + child.kill() + break + assert child.poll() is None, "Restore exited before a staged block was observed" + assert time.monotonic() < deadline, "No authenticated restore block observed" + time.sleep(0.01) + child.communicate(timeout=30) + assert child.returncode == -signal.SIGKILL + finally: + if child.poll() is None: + child.kill() + child.communicate(timeout=30) + assert not interrupted_target.exists(), "Interrupted contents must not be published" + remnants = list(root.glob(".swan-backup-*")) + assert remnants, "A forced stop must actually leave the observed private staging" + for directory in remnants: + assert directory.is_dir() and not directory.is_symlink() + assert directory.stat().st_mode & 0o777 == 0o700 + for file in directory.rglob("*"): + assert not file.is_symlink() + if file.is_file(): + assert file.stat().st_mode & 0o777 == 0o600 + run(["restore", str(backup)]) + retried = hashlib.sha256() + with (interrupted_target / "artifacts" / name).open("rb") as file: + while chunk := file.read(chunk_size): + retried.update(chunk) + assert retried.digest() == expected.digest() + assert (interrupted_target / "profile-key.hex").read_text(encoding="ascii") == key.hex() + assert set(root.glob(".swan-backup-*")) == set(remnants), "Retry must not erase other operations' private staging" + # Only this test's observed remnants, under its own private disposable root. + for directory in remnants: + assert directory.resolve().parent == root.resolve() + assert directory.name.startswith(".swan-backup-") and not directory.is_symlink() + shutil.rmtree(directory) + receipt = {"actual_management_cli": True, "format": "SWANBK2", + "maximum_virtual_address_space_bytes_per_cli_process": memory_limit, + "incompressible_artifact_bytes": received.stat().st_size, + "encrypted_backup_bytes": backup.stat().st_size, + "artifact_sha256_preserved": actual.hexdigest(), + "identity_keys_and_setup_token_preserved": True, + "sqlite_integrity_passed": True, "private_unix_permissions_passed": True, + "write_size_failure_rejected_and_cleaned": True, + "restore_sigkill_after_authenticated_staging": True, + "interrupted_restore_target_unpublished": True, + "retry_preserved_artifact_hash_and_identity": True, + "forced_stop_remnants_private_and_preserved_until_explicit_fixture_cleanup": True, + "temporary_archive_cleanup_passed": True, "production_qualification": False} + output.parent.mkdir(parents=True, exist_ok=True) + with output.open("x", encoding="utf-8") as file: + json.dump(receipt, file, indent=2) + print("Real backup and restore passed above 512 MiB under a 512 MiB address-space cap.") + print("Write-error cleanup and forced restore interruption/retry passed on generated fixture data.") + + +if __name__ == "__main__": + main() diff --git a/deployment/linux/test_build_context.py b/deployment/linux/test_build_context.py new file mode 100644 index 00000000000..7880a062ad0 --- /dev/null +++ b/deployment/linux/test_build_context.py @@ -0,0 +1,95 @@ +"""Check the shipped ignore rules with Docker, using public source and canaries. + +No local environment files, Git credentials or runtime data are copied into the +fixture. The scratch exporter runs no commands and downloads no base image. +""" +import pathlib +import re +import shutil +import subprocess +import tempfile + + +ROOT = pathlib.Path(__file__).resolve().parents[2] +CANARIES = ( + ".env", + ".git/config", + "deployment/linux/company.env", + "deployment/windows/local-test.env", + "deployment/windows/custom-company.ps1", + "product/management/src/local-test.env", + "product/management/src/company-private.rs", + "product/worker/src/worker.json", + "product/worker/src/worker.private.json", + "deployment/windows/worker.private.json", + "product/target/stale-build.exe", + "branding/company-private.key", + "swan-data/profile-key.hex", + "swan-data/management.sqlite3", +) + + +def prepare_fixture(context): + tracked = subprocess.check_output( + ["git", "ls-files", "-z", "product", "deployment", "branding", "LICENCE"], + cwd=ROOT, + ).decode("utf-8").split("\0") + required = {"LICENCE"} + sources = [] + for relative in filter(None, tracked): + source = ROOT / relative + if source.is_symlink() or not source.is_file(): + raise RuntimeError("Build fixture requires regular tracked source files") + destination = context / relative + destination.parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(source, destination) + if relative.startswith("product/"): + if source.suffix == ".rs": + sources.append(source) + required.add(relative) + elif source.name in ("Cargo.toml", "Cargo.lock"): + required.add(relative) + for source in sources: + for embedded in re.findall(r'include_(?:str|bytes)!\(\s*"([^"]+)"', source.read_text(encoding="utf-8")): + relative = (source.parent / embedded).resolve().relative_to(ROOT).as_posix() + if not (context / relative).is_file(): + raise RuntimeError("Embedded build input is not tracked public source: " + relative) + required.add(relative) + for relative in CANARIES: + destination = context / relative + if destination.exists(): + raise RuntimeError("Canary would overwrite tracked source") + destination.parent.mkdir(parents=True, exist_ok=True) + destination.write_text("Swan build context exclusion canary; no actual credentials.\n", encoding="utf-8") + return required + + +def main(): + with tempfile.TemporaryDirectory(prefix="swan-docker-context-") as temporary: + lab = pathlib.Path(temporary) + context = lab / "context" + context.mkdir() + required = prepare_fixture(context) + rules = ROOT / "deployment/linux/Dockerfile.dockerignore" + if (ROOT / ".dockerignore").read_text(encoding="utf-8") != rules.read_text(encoding="utf-8"): + raise RuntimeError("Root and Dockerfile-specific context exclusions must agree") + shutil.copyfile(ROOT / ".dockerignore", context / ".dockerignore") + probe = context / "probe.Dockerfile" + probe.write_text("FROM scratch\nCOPY . /\n", encoding="utf-8") + shutil.copyfile(rules, pathlib.Path(str(probe) + ".dockerignore")) + output = lab / "export" + subprocess.run( + ["docker", "build", "--file", str(probe), "--output", "type=local,dest=" + str(output), str(context)], + check=True, + timeout=300, + ) + actual = {path.relative_to(output).as_posix() for path in output.rglob("*") if path.is_file()} + missing = required - actual + unexpected = actual - required + if missing or unexpected: + raise RuntimeError("Docker context mismatch; missing=" + repr(sorted(missing)) + "; unexpected=" + repr(sorted(unexpected))) + print("Docker exported every required input and excluded all configuration/key/cache canaries (" + str(len(actual)) + " public files).") + + +if __name__ == "__main__": + main() diff --git a/deployment/linux/test_container_stack.py b/deployment/linux/test_container_stack.py new file mode 100644 index 00000000000..5829a1da08f --- /dev/null +++ b/deployment/linux/test_container_stack.py @@ -0,0 +1,509 @@ +"""Rehearse pinned company components with isolated loopback HTTPS. + +An internal test CA replaces public ACME only in the disposable proxy config. +No system trust store is changed. TCP startup is not native remote-session, +UDP traversal, public reachability or production certificate-renewal evidence. +""" +import base64 +import argparse +import json +import ipaddress +import pathlib +import re +import secrets +import socket +import ssl +import subprocess +import tempfile +import time +import urllib.error +import urllib.request +import uuid + +from test_container_startup import authenticator, docker, secret_command + + +ROOT = pathlib.Path(__file__).resolve().parents[2] + + +def complete_backup(image, prefix, volumes, lab, proxy_image, transport_image, context, https_port, + signed, status, administrator, enrolled, login, secret): + """Export stopped components and authenticate a restore into a fresh volume. + + Private keys stay inside Docker volumes; the passphrase travels over stdin. + Restart replacement services using the original HTTPS and transport trust. + """ + management, transport, tls, _ = volumes + restored = prefix + "-archive-restore" + docker("volume", "create", restored) + replacement_volumes = [] + replacement_containers = [] + network = prefix + "-recovered" + network_created = False + password = secrets.token_hex(32) + common = ["run", "--rm", "-i", "--user", "0", "--read-only", "--network", "none", + "--tmpfs", "/tmp", "--security-opt", "no-new-privileges:true"] + read_password = "IFS= read -r SWAN_BACKUP_PASSPHRASE; export SWAN_BACKUP_PASSPHRASE; " + try: + # Distinct exports exercise the same stopped, read-only transport/TLS + # sources repeatedly. A failed export is not retried or overwritten. + for filename in ["complete.swanbackup", "export-check-1.swanbackup", "export-check-2.swanbackup"]: + secret_command(common + ["--mount", "type=volume,src=" + management + ",dst=/var/lib/swan", + "--mount", "type=volume,src=" + transport + ",dst=/transport,readonly", + "--mount", "type=volume,src=" + tls + ",dst=/tls,readonly", + "--mount", "type=bind,src=" + str(lab / "local-test.env") + ",dst=/local-test.env,readonly", + "--entrypoint", "sh", image, "-ec", read_password + + "exec swan-management backup /var/lib/swan/" + filename + " --transport-directory /transport --tls-directory /tls --deployment-env /local-test.env"], password) + restore = common + ["--mount", "type=volume,src=" + management + ",dst=/input,readonly", + "--mount", "type=volume,src=" + restored + ",dst=/var/lib/swan", + "--env", "SWAN_DATA_DIR=/var/lib/swan/restored", "--entrypoint", "sh", image, "-ec", + read_password + "exec swan-management restore /input/complete.swanbackup"] + secret_command(restore, secrets.token_hex(32), expected_success=False) + docker(*common[:2], "--user", "0", "--network", "none", "--read-only", + "--mount", "type=volume,src=" + restored + ",dst=/restored,readonly", + "--entrypoint", "sh", image, "-ec", "test ! -e /restored/restored") + secret_command(restore, password) + # Compare actual private artifacts without returning their contents. + docker("run", "--rm", "--user", "0", "--read-only", "--network", "none", + "--mount", "type=volume,src=" + restored + ",dst=/restore,readonly", + "--mount", "type=volume,src=" + transport + ",dst=/transport,readonly", + "--mount", "type=volume,src=" + tls + ",dst=/tls,readonly", + "--mount", "type=bind,src=" + str(lab / "local-test.env") + ",dst=/local-test.env,readonly", + "--entrypoint", "sh", image, "-ec", + "test -s /restore/restored/management.sqlite3 || { echo missing-database >&2; exit 1; }; " + "cmp -s /transport/id_ed25519 /restore/restored/transport-id_ed25519 || { echo private-key-mismatch >&2; exit 1; }; " + "cmp -s /transport/id_ed25519.pub /restore/restored/transport-id_ed25519.pub || { echo public-key-mismatch >&2; exit 1; }; " + "cmp -s /local-test.env /restore/restored/deployment.env || { echo environment-mismatch >&2; exit 1; }; " + "find /tls -type f -exec sh -ec 'for file do relative=${file#/tls/}; cmp -s \"$file\" \"/restore/restored/tls-storage/$relative\" || exit 1; done' sh {} +; test $(find /tls -type f | wc -l) = $(find /restore/restored/tls-storage -type f | wc -l); " + "test $(stat -c %a /restore/restored) = 700 || { echo directory-mode >&2; exit 1; }; " + "test $(stat -c %a /restore/restored/transport-id_ed25519) = 600 || { echo key-mode >&2; exit 1; }") + for role in ["transport", "tls", "proxy"]: + volume = prefix + "-recovered-" + role + docker("volume", "create", volume) + replacement_volumes.append(volume) + recovered_transport, recovered_tls, recovered_proxy = replacement_volumes + docker("run", "--rm", "--user", "0", "--read-only", "--network", "none", + "--mount", "type=volume,src=" + restored + ",dst=/restore", + "--mount", "type=volume,src=" + recovered_transport + ",dst=/transport", + "--mount", "type=volume,src=" + recovered_tls + ",dst=/tls", + "--entrypoint", "sh", image, "-ec", + "cp /restore/restored/transport-id_ed25519 /transport/id_ed25519; " + "cp /restore/restored/transport-id_ed25519.pub /transport/id_ed25519.pub; " + "if test -f /restore/restored/transport-db.sqlite3; then cp /restore/restored/transport-db.sqlite3 /transport/db_v2.sqlite3; fi; " + "chmod 700 /transport /tls; chmod 600 /transport/*; " + "cp -a /restore/restored/tls-storage/. /tls/; " + "chown swan:swan /restore/restored /restore/restored/management.sqlite3 /restore/restored/profile-key.hex /restore/restored/setup-token.txt") + docker("network", "create", network) + network_created = True + names = {role: network + "-" + role for role in ["management", "hbbs", "hbbr", "https"]} + docker("create", "--name", names["management"], "--network", network, "--network-alias", "management", + "--read-only", "--tmpfs", "/tmp", "--security-opt", "no-new-privileges:true", + "--mount", "type=volume,src=" + restored + ",dst=/var/lib/swan", + "--env", "SWAN_DATA_DIR=/var/lib/swan/restored", image) + replacement_containers.append(names["management"]) + for role, services, command in [("hbbs", ["21115", "21116"], ["hbbs", "-r", "hbbr:21117", "-k", "_"]), + ("hbbr", ["21117"], ["hbbr", "-k", "_"])]: + arguments = ["create", "--name", names[role], "--network", network, "--network-alias", role, + "--mount", "type=volume,src=" + recovered_transport + ",dst=/root"] + for service in services: + arguments += ["--publish", "127.0.0.1::" + service + "/tcp"] + docker(*arguments, transport_image, *command) + replacement_containers.append(names[role]) + docker("create", "--name", names["https"], "--network", network, "--read-only", "--tmpfs", "/tmp", + "--security-opt", "no-new-privileges:true", "--env", "SWAN_HOST=localhost", + "--mount", "type=bind,src=" + str(lab / "Caddyfile") + ",dst=/etc/caddy/Caddyfile,readonly", + "--mount", "type=volume,src=" + recovered_tls + ",dst=/data", + "--mount", "type=volume,src=" + recovered_proxy + ",dst=/config", + "--publish", "127.0.0.1:" + str(https_port) + ":443/tcp", proxy_image) + replacement_containers.append(names["https"]) + for role in ["management", "hbbs", "hbbr", "https"]: + docker("start", names[role]) + endpoint = Endpoint(names["https"], context) + endpoint.ready() + recovered = verify_profile(endpoint.request("/api/v1/profile"), status["profile_public_key"], lab) + for key in signed: + if key not in ["issued_at", "expires_at"] and recovered[key] != signed[key]: + raise RuntimeError("Replacement changed trusted company profile") + public_file(names["hbbs"], "/root/id_ed25519.pub", lab / "recovered-transport.pub") + if (lab / "recovered-transport.pub").read_bytes() != (lab / "transport.pub").read_bytes(): + raise RuntimeError("Replacement changed transport identity") + for service in ["21115/tcp", "21116/tcp"]: + container_nat_probe(names["hbbs"], service) + with socket.create_connection(("127.0.0.1", port(names["hbbr"], "21117/tcp")), timeout=5): + pass + endpoint.request("/api/v1/devices", token=administrator, expected=401) + endpoint.request("/api/v1/login", "POST", body=login, expected=401) + deadline = time.monotonic() + 65 + while authenticator(secret, 1) == login["totp_code"]: + if time.monotonic() >= deadline: + raise RuntimeError("No fresh MFA code available for replacement") + time.sleep(0.2) + fresh_login = dict(login, totp_code=authenticator(secret, 1)) + token = endpoint.request("/api/v1/login", "POST", body=fresh_login)["token"] + endpoint.request("/api/v1/login", "POST", body=fresh_login, expected=401) + inventory = endpoint.request("/api/v1/devices", token=token) + if len(inventory) != 1 or inventory[0]["id"] != enrolled["device_id"] or inventory[0]["state"] != "pending" or inventory[0]["unattended"]: + raise RuntimeError("Replacement lost enrollment or customer consent") + endpoint.request("/api/v1/devices/" + enrolled["device_id"] + "/state", "PUT", token, + {"state": "revoked", "group": "recovery-test"}) + endpoint.request("/api/v1/device/consent", "PUT", enrolled["device_token"], + {"unattended": True, "revision": 1}, expected=401) + endpoint.request("/api/v1/logout", "POST", token) + endpoint.request("/api/v1/devices", token=token, expected=401) + finally: + for name in reversed(replacement_containers): + docker("rm", "--force", name) + if network_created: + docker("network", "rm", network) + for volume in reversed(replacement_volumes): + docker("volume", "rm", volume) + docker("volume", "rm", restored) + + +def pinned_images(): + compose = (ROOT / "deployment/linux/compose.yaml").read_text(encoding="utf-8") + images = re.findall(r"^\s+image:\s+(\S+)\s*$", compose, re.MULTILINE) + proxies = [value for value in images if value.startswith("caddy:")] + transport = json.loads((ROOT / "deployment/transport-source.json").read_text(encoding="utf-8"))["image"] + if len(proxies) != 1 or re.fullmatch(r"caddy:[0-9.]+@sha256:[0-9a-f]{64}", proxies[0]) is None: + raise RuntimeError("HTTPS proxy must use one pinned manifest") + if re.fullmatch(r"rustdesk/rustdesk-server:[0-9.]+@sha256:[0-9a-f]{64}", transport) is None or images.count(transport) != 2: + raise RuntimeError("Both transport components must match the source manifest") + return proxies[0], transport + + +def ensure_pinned_image(image): + # Finish public downloads before creating any private company resources. + # Retry only a completed pull's explicit temporary HTTP error, never a + # timeout whose daemon-side download state has not been established. + if re.fullmatch(r"[A-Za-z0-9./:_-]+@sha256:[0-9a-f]{64}", image) is None: + raise RuntimeError("An immutable public image digest is required") + inspected = subprocess.run(["docker", "image", "inspect", image], capture_output=True, text=True, timeout=30) + if inspected.returncode == 0: + return + temporary = ("500 internal server error", "502 bad gateway", "503 service unavailable", + "504 gateway timeout", "429 too many requests", "toomanyrequests:") + for attempt in range(3): + result = subprocess.run(["docker", "pull", image], capture_output=True, text=True, timeout=180) + if result.returncode == 0: + return + detail = (result.stdout + result.stderr).lower() + if not any(label in detail for label in temporary) or attempt == 2: + raise RuntimeError("Pinned public image pull failed; no company resources were created") + print("Pinned image pull returned a temporary registry HTTP error; retrying the same digest.") + time.sleep(2 * (attempt + 1)) + + +def maintained_transport_image(): + # Resolve once, then run the immutable image ID throughout the rehearsal. + # A mutable tag must not select different transport builds during restore. + image = docker("image", "inspect", "--format", "{{.Id}}", "swan-transport:maintained-test") + if re.fullmatch(r"sha256:[0-9a-f]{64}", image) is None: + raise RuntimeError("Maintained transport image identity missing") + receipt = json.loads(docker("run", "--rm", "--network", "none", "--read-only", + "--entrypoint", "cat", image, "/usr/share/doc/swan-transport/SWAN-SOURCE.json")) + expected = json.loads((ROOT / "deployment/transport/source.json").read_text(encoding="utf-8")) + if (receipt.get("upstream") != expected or receipt.get("stage") != "fix" + or receipt.get("project_revision") != "650361413543baf7ef150f5960544154ec77ddeb" + or receipt.get("patched_udp_sha256") != expected["patches"][-1]["after_sha256"] + or receipt.get("unsigned_test") is not True): + raise RuntimeError("Maintained transport recipe or source provenance differs") + return image + + +def port(name, service): + mapping = docker("port", name, service) + if re.fullmatch(r"127\.0\.0\.1:[0-9]+", mapping) is None: + raise RuntimeError("Stack test endpoint must be restricted to loopback") + return int(mapping.rsplit(":", 1)[1]) + + +def public_file(name, remote, destination): + deadline = time.monotonic() + 60 + while time.monotonic() < deadline: + result = subprocess.run(["docker", "cp", name + ":" + remote, str(destination)], capture_output=True, timeout=10) + if result.returncode == 0 and destination.is_file(): + return + time.sleep(0.2) + raise RuntimeError("Component did not publish its public trust file") + + +def container_nat_probe(name, service): + # Docker's loopback publisher may present a loopback peer to hbbs, whose + # NAT-test port interprets that peer as an administrative text connection. + # Probe the owned bridge endpoint; published host sockets are checked apart. + address = docker("inspect", "--format", "{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}", name) + parsed = ipaddress.ip_address(address) + if not parsed.is_private or parsed.is_loopback or parsed.is_unspecified: + raise RuntimeError("NAT probe requires a disposable private bridge endpoint") + if service not in ["21115/tcp", "21116/tcp"]: + raise RuntimeError("Unexpected NAT-test port") + try: + deadline = time.monotonic() + 30 + while True: + try: + nat_probe(int(service.split("/")[0]), address) + break + except ConnectionRefusedError: + # Published proxy sockets and the public-key file can become + # ready before hbbs binds its own listener. Wait on the same + # owned container; malformed replies are never retried. + if time.monotonic() >= deadline: + raise + time.sleep(0.2) + except OSError as error: + # Preserve startup evidence before the fixture removes its containers. + # hbbs output contains its public transport identity, not management + # credentials; never collect environment/configuration or private files. + state = docker("inspect", "--format", "{{json .State}}", name) + logs = docker("logs", "--tail", "30", name) + raise RuntimeError("Owned hbbs NAT probe failed on " + service + "; state=" + state + "; log=" + logs) from error + + +def nat_probe(listener, host="127.0.0.1"): + # TestNatRequest field 20, serial=0, from hbb_common/protos/rendezvous.proto. + # The one-byte length header follows hbb_common/src/bytes_codec.rs. + def exact(connection, size): + result = bytearray() + while len(result) < size: + part = connection.recv(size - len(result)) + if not part: + raise RuntimeError("NAT probe connection closed before a response") + result.extend(part) + return bytes(result) + + def integer(data, offset): + value = 0 + for shift in range(0, 35, 7): + if offset >= len(data): + break + byte = data[offset] + offset += 1 + value |= (byte & 127) << shift + if byte < 128: + return value, offset + raise RuntimeError("Invalid NAT response integer") + + with socket.create_connection((host, listener), timeout=5) as connection: + connection.sendall(b"\x0c\xa2\x01\x00") + for _ in range(3): + first = exact(connection, 1) + header = first + exact(connection, first[0] & 3) + length = int.from_bytes(header, "little") >> 2 + if length > 65536: + raise RuntimeError("NAT response exceeds probe limit") + frame = exact(connection, length) + if frame.startswith(b"\xca\x01"): + continue # Native NAT discovery also skips KeyExchange. + if not frame.startswith(b"\xaa\x01"): + raise RuntimeError("Rendezvous did not return TestNatResponse") + size, start = integer(frame, 2) + if start + size != len(frame) or frame[start:start + 1] != b"\x08": + raise RuntimeError("Invalid NAT response payload") + observed, _ = integer(frame, start + 1) + if not 0 < observed <= 65535: + raise RuntimeError("NAT probe did not report a peer port") + return + raise RuntimeError("NAT probe did not receive its protocol response") + + +class Endpoint: + def __init__(self, proxy, context): + self.base = "https://localhost:" + str(port(proxy, "443/tcp")) + self.context = context + + def ready(self): + deadline = time.monotonic() + 30 + while time.monotonic() < deadline: + try: + if self.request("/health") == {"status": "ok", "product": "Swan Remote Support"}: + return + except (OSError, RuntimeError): + pass + time.sleep(0.2) + raise RuntimeError("HTTPS proxy did not reach healthy management") + + def request(self, endpoint, method="GET", token=None, body=None, expected=200): + headers = {"Content-Type": "application/json"} + if token is not None: + headers["Authorization"] = "Bearer " + token + data = None if body is None else json.dumps(body).encode("utf-8") + query = urllib.request.Request(self.base + endpoint, data=data, headers=headers, method=method) + try: + response = urllib.request.urlopen(query, context=self.context, timeout=10) + except urllib.error.HTTPError as error: + response = error + with response: + if response.status != expected or response.headers.get("Cache-Control") != "no-store": + raise RuntimeError("Unexpected HTTPS response for " + endpoint) + if response.headers.get("Strict-Transport-Security") != "max-age=31536000": + raise RuntimeError("Proxy lost the shipped HTTPS policy") + content = response.read(2 * 1024 * 1024) + return json.loads(content) if "application/json" in response.headers.get("Content-Type", "") else content.decode("utf-8") + + +def verify_profile(envelope, public_key, directory): + payload = base64.b64decode(envelope["payload"], validate=True) + signature = base64.b64decode(envelope["signature"], validate=True) + key = base64.b64decode(public_key, validate=True) + if len(key) != 32 or len(signature) != 64: + raise RuntimeError("Invalid profile signing identity") + key_file, data_file, signature_file = [directory / name for name in ["public.der", "profile.json", "signature.bin"]] + key_file.write_bytes(bytes.fromhex("302a300506032b6570032100") + key) + data_file.write_bytes(payload) + signature_file.write_bytes(signature) + arguments = ["openssl", "pkeyutl", "-verify", "-rawin", "-pubin", "-keyform", "DER", "-inkey", str(key_file), "-in", str(data_file), "-sigfile", str(signature_file)] + if subprocess.run(arguments, capture_output=True).returncode != 0: + raise RuntimeError("Actual company profile signature failed") + data_file.write_bytes(payload + b" ") + if subprocess.run(arguments, capture_output=True).returncode == 0: + raise RuntimeError("Tampered company profile was accepted") + data_file.write_bytes(payload) + key_file.write_bytes(bytes.fromhex("302a300506032b6570032100") + bytes([key[0] ^ 1]) + key[1:]) + if subprocess.run(arguments, capture_output=True).returncode == 0: + raise RuntimeError("Wrong company signing key was accepted") + return json.loads(payload) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("image", nargs="?", default="swan-management:test") + parser.add_argument("--maintained-transport", action="store_true") + arguments = parser.parse_args() + image = arguments.image + proxy_image, transport_image = pinned_images() + if arguments.maintained_transport: + transport_image = maintained_transport_image() + else: + ensure_pinned_image(transport_image) + ensure_pinned_image(proxy_image) + prefix = "swan-stack-test-" + uuid.uuid4().hex + names = {role: prefix + "-" + role for role in ["management", "https", "hbbs", "hbbr"]} + volumes = [] + containers = [] + network_created = False + try: + with tempfile.TemporaryDirectory(prefix="swan-stack-config-") as temporary: + lab = pathlib.Path(temporary) + original = (ROOT / "deployment/linux/Caddyfile").read_text(encoding="utf-8") + if original.count("reverse_proxy management:8080") != 1: + raise RuntimeError("Recheck the test override against the proxy recipe") + config = lab / "Caddyfile" + config.write_text("{\n skip_install_trust\n}\n" + original.replace("reverse_proxy management:8080", "tls internal\n reverse_proxy management:8080"), encoding="utf-8") + (lab / "local-test.env").write_text("SWAN_HOST=localhost\nSWAN_MANAGEMENT_IMAGE=" + image + "\n", encoding="utf-8") + docker("network", "create", prefix) + network_created = True + for role in ["management", "transport", "tls", "proxy-config"]: + volume = prefix + "-" + role + "-data" + docker("volume", "create", volume) + volumes.append(volume) + management_data, transport_data, tls_data, proxy_data = volumes + docker("create", "--name", names["management"], "--network", prefix, "--network-alias", "management", + "--read-only", "--tmpfs", "/tmp", "--security-opt", "no-new-privileges:true", + "--mount", "type=volume,src=" + management_data + ",dst=/var/lib/swan", image) + containers.append(names["management"]) + docker("create", "--name", names["hbbs"], "--network", prefix, "--network-alias", "hbbs", + "--mount", "type=volume,src=" + transport_data + ",dst=/root", + "--publish", "127.0.0.1::21115/tcp", "--publish", "127.0.0.1::21116/tcp", + transport_image, "hbbs", "-r", "hbbr:21117", "-k", "_") + containers.append(names["hbbs"]) + docker("create", "--name", names["hbbr"], "--network", prefix, "--network-alias", "hbbr", + "--mount", "type=volume,src=" + transport_data + ",dst=/root", + "--publish", "127.0.0.1::21117/tcp", transport_image, "hbbr", "-k", "_") + containers.append(names["hbbr"]) + with socket.socket() as available: + available.bind(("127.0.0.1", 0)) + https_port = available.getsockname()[1] + with (lab / "local-test.env").open("a", encoding="utf-8") as environment: + environment.write("SWAN_HTTPS_TEST_PORT=" + str(https_port) + "\n") + docker("create", "--name", names["https"], "--network", prefix, "--read-only", "--tmpfs", "/tmp", + "--security-opt", "no-new-privileges:true", "--env", "SWAN_HOST=localhost", + "--mount", "type=bind,src=" + str(config) + ",dst=/etc/caddy/Caddyfile,readonly", + "--mount", "type=volume,src=" + tls_data + ",dst=/data", + "--mount", "type=volume,src=" + proxy_data + ",dst=/config", + "--publish", "127.0.0.1:" + str(https_port) + ":443/tcp", proxy_image) + containers.append(names["https"]) + docker("start", names["management"]) + docker("start", names["hbbs"]) + transport_public = lab / "transport.pub" + public_file(names["hbbs"], "/root/id_ed25519.pub", transport_public) + transport_key = transport_public.read_text().strip() + if len(base64.b64decode(transport_key, validate=True)) != 32: + raise RuntimeError("Transport did not generate a valid public identity") + docker("start", names["hbbr"]) + docker("start", names["https"]) + authority = lab / "root.crt" + public_file(names["https"], "/data/caddy/pki/authorities/local/root.crt", authority) + context = ssl.create_default_context(cafile=str(authority)) + endpoint = Endpoint(names["https"], context) + endpoint.ready() + try: + urllib.request.urlopen(endpoint.base + "/health", timeout=10) + except urllib.error.URLError as error: + if not isinstance(error.reason, ssl.SSLCertVerificationError): + raise RuntimeError("Expected actual untrusted-certificate rejection") from error + else: + raise RuntimeError("Lab issuer unexpectedly entered system trust") + for role, service in [("hbbs", "21115/tcp"), ("hbbs", "21116/tcp"), ("hbbr", "21117/tcp")]: + with socket.create_connection(("127.0.0.1", port(names[role], service)), timeout=5): + pass + for service in ["21115/tcp", "21116/tcp"]: + container_nat_probe(names["hbbs"], service) + status = endpoint.request("/api/v1/status") + branding = endpoint.request("/api/v1/default-branding") + branding["display_name"] = "HTTPS Stack Test Company" + secret = secrets.token_bytes(20) + password = secrets.token_hex(24) + profile = {"schema": 1, "company_id": "setup", "revision": 1, "issued_at": int(time.time()), "expires_at": int(time.time()) + 3600, + "management_url": endpoint.base, "rendezvous": "hbbs:21116", "relay": "hbbr:21117", "transport_public_key": transport_key, + "customer": branding, "technician": branding, "allow_unattended": False, "updates_paused": True, "rollout_percent": 0, + "maintenance_start_utc": 0, "maintenance_end_utc": 0, "update_channel": "test", "next_profile_public_key": None} + setup_token = docker("exec", names["management"], "cat", "/var/lib/swan/setup-token.txt") + company = endpoint.request("/api/v1/setup", "POST", setup_token, + {"profile": profile, "username": "stack-admin", "password": password, "totp_secret": base64.b32encode(secret).decode("ascii"), "totp_code": authenticator(secret)}) + login = {"username": "stack-admin", "password": password, "totp_code": authenticator(secret, 1)} + administrator = endpoint.request("/api/v1/login", "POST", body=login)["token"] + endpoint.request("/api/v1/login", "POST", body=login, expected=401) + signed = verify_profile(endpoint.request("/api/v1/profile"), status["profile_public_key"], lab) + if signed["company_id"] != company["company_id"] or signed["transport_public_key"] != transport_key or signed["customer"]["display_name"] != branding["display_name"]: + raise RuntimeError("Signed HTTPS profile lost company or transport identity") + enrolled = endpoint.request("/api/v1/enroll", "POST", body={"name": "HTTPS test device", "rustdesk_id": "123456789", "unattended_consent": False}) + if enrolled["state"] != "pending": + raise RuntimeError("HTTPS enrollment bypassed approval") + endpoint.request("/api/v1/device/consent", "PUT", enrolled["device_token"], {"unattended": True, "revision": 1}, expected=401) + endpoint.request("/api/v1/logout", "POST", administrator) + endpoint.request("/api/v1/devices", token=administrator, expected=401) + for role in ["https", "management", "hbbs", "hbbr"]: + docker("stop", "--time", "10", names[role]) + complete_backup(image, prefix, volumes, lab, proxy_image, transport_image, context, https_port, + signed, status, administrator, enrolled, login, secret) + for role in ["management", "hbbs", "hbbr", "https"]: + docker("start", names[role]) + endpoint = Endpoint(names["https"], context) + endpoint.ready() + restored = verify_profile(endpoint.request("/api/v1/profile"), status["profile_public_key"], lab) + after_public = lab / "transport-after.pub" + public_file(names["hbbs"], "/root/id_ed25519.pub", after_public) + if after_public.read_bytes() != transport_public.read_bytes() or restored["company_id"] != company["company_id"] or restored["management_url"] != endpoint.base: + raise RuntimeError("Stack restart changed company or transport trust") + endpoint.request("/api/v1/devices", token=administrator, expected=401) + for service in ["21115/tcp", "21116/tcp"]: + container_nat_probe(names["hbbs"], service) + original_login = dict(login, totp_code=authenticator(secret, 1)) + original_token = endpoint.request("/api/v1/login", "POST", body=original_login)["token"] + original_inventory = endpoint.request("/api/v1/devices", token=original_token) + if len(original_inventory) != 1 or original_inventory[0]["id"] != enrolled["device_id"] or original_inventory[0]["state"] != "pending": + raise RuntimeError("Replacement revocation mutated original deployment") + endpoint.request("/api/v1/logout", "POST", original_token) + print("PASS: pinned HTTPS/rendezvous/relay startup, local native NAT-test responses, explicit CA validation and untrusted rejection, actual profile signatures, MFA/enrollment/logout over HTTPS, complete encrypted archive recovery and replacement startup with original trust. Native sessions, UDP traversal, public ACME and external reachability remain unverified.") + finally: + for name in reversed(containers): + docker("rm", "--force", name) + for volume in reversed(volumes): + docker("volume", "rm", volume) + if network_created: + docker("network", "rm", prefix) + + +if __name__ == "__main__": + main() diff --git a/deployment/linux/test_container_startup.py b/deployment/linux/test_container_startup.py new file mode 100644 index 00000000000..4e097bd70d9 --- /dev/null +++ b/deployment/linux/test_container_startup.py @@ -0,0 +1,266 @@ +"""Exercise the real management image with fresh, disposable company storage. + +Loopback HTTP is used only inside this CI lab. This checks container deployment +and persistence, not public HTTPS, RustDesk transport or native endpoint sessions. +Credentials stay in memory and are never printed or passed as command arguments. +""" +import base64 +import hashlib +import hmac +import json +import re +import secrets +import struct +import subprocess +import sys +import time +import urllib.error +import urllib.request +import uuid + + +def docker(*arguments): + return subprocess.check_output(["docker", *arguments], text=True, timeout=90).strip() + + +def authenticator(secret, offset=0): + counter = int(time.time()) // 30 + offset + digest = hmac.new(secret, struct.pack(">Q", counter), hashlib.sha1).digest() + start = digest[-1] & 15 + value = struct.unpack(">I", digest[start:start + 4])[0] & 0x7FFFFFFF + return str(value % 1000000).zfill(6) + + +def request(base, endpoint, method="GET", token=None, body=None, expected=200): + headers = {"Content-Type": "application/json"} + if token is not None: + headers["Authorization"] = "Bearer " + token + data = None if body is None else json.dumps(body).encode("utf-8") + query = urllib.request.Request(base + endpoint, data=data, headers=headers, method=method) + try: + response = urllib.request.urlopen(query, timeout=10) + except urllib.error.HTTPError as error: + response = error + with response: + if response.status != expected: + raise RuntimeError("Unexpected HTTP status for " + endpoint + ": " + str(response.status)) + raw = response.read(1024 * 1024) + if response.headers.get("Cache-Control") != "no-store": + raise RuntimeError("Management responses must not be cached") + return json.loads(raw) if "application/json" in response.headers.get("Content-Type", "") else raw.decode("utf-8") + + +def ready(base): + deadline = time.monotonic() + 30 + while time.monotonic() < deadline: + try: + result = request(base, "/health") + if result == {"status": "ok", "product": "Swan Remote Support"}: + return + except (OSError, RuntimeError): + time.sleep(0.2) + raise RuntimeError("Management container did not become healthy") + + +def container_base(name): + port = docker("port", name, "8080/tcp") + if re.fullmatch(r"127\.0\.0\.1:[0-9]+", port) is None: + raise RuntimeError("Lab container was not restricted to loopback") + return "http://" + port + + +def secret_command(arguments, passphrase, expected_success=True): + # Supply only stdin, never a password in Docker arguments or container metadata. + result = subprocess.run(["docker", *arguments], input=passphrase + "\n", + capture_output=True, text=True, timeout=90) + if (result.returncode == 0) != expected_success: + # Report only known error categories, never captured output: diagnostics + # can contain local paths, deployment settings or Docker arguments. + categories = { + "management_open": "Open management database for backup", + "management_snapshot": "Snapshot management database for backup", + "transport_open": "Open transport database for backup", + "transport_snapshot": "Snapshot transport database for backup", + "sqlite_readonly": "attempt to write a readonly database", + "sqlite_open": "unable to open database file", + "sqlite_locked": "database is locked", + "sqlite_io": "disk I/O error", + "readonly_filesystem": "Read-only file system", + "sqlite_malformed": "database disk image is malformed", + "permission": "Permission denied", + "missing_file": "No such file or directory", + "tls_path": "Unsupported TLS storage path", + "backup_exists": "Refusing to overwrite an existing backup", + "authentication": "Backup password incorrect or backup modified", + } + matched = [name for name, text in categories.items() if text.casefold() in result.stderr.casefold()] + codes = re.findall(r"(?:Error code |os error )(\d+)", result.stderr) + raise RuntimeError("Unexpected encrypted backup/restore command result; exit=" + + str(result.returncode) + "; categories=" + repr(matched) + "; codes=" + repr(codes)) + if not expected_success and "Backup password incorrect or backup modified" not in result.stderr: + raise RuntimeError("Restore failed before demonstrating backup authentication rejection") + + +def restore_management(image, original, source_volume, status, administrator, enrolled, login_body, secret): + name = "swan-restore-test-" + uuid.uuid4().hex + helper = name + "-helper" + volume = name + "-data" + created_volume = False + created_container = False + try: + before = request(container_base(original), "/api/v1/profile") + passphrase = secrets.token_hex(32) + shell = "IFS= read -r SWAN_BACKUP_PASSPHRASE; export SWAN_BACKUP_PASSPHRASE; exec swan-management " + secret_command(["exec", "-i", original, "sh", "-ec", + shell + "backup /var/lib/swan/rehearsal.swanbackup"], passphrase) + docker("exec", original, "sh", "-ec", + 'test "$(stat -c %a /var/lib/swan/rehearsal.swanbackup)" = 600; ' + 'test "$(head -c 7 /var/lib/swan/rehearsal.swanbackup)" = SWANBK2') + docker("volume", "create", volume) + created_volume = True + restore = ["run", "--rm", "--name", helper, "-i", "--read-only", "--network", "none", + "--tmpfs", "/tmp", "--security-opt", "no-new-privileges:true", + "--mount", "type=volume,src=" + source_volume + ",dst=/input,readonly", + "--mount", "type=volume,src=" + volume + ",dst=/var/lib/swan", + "--env", "SWAN_DATA_DIR=/var/lib/swan/restored", "--entrypoint", "sh", image, + "-ec", shell + "restore /input/rehearsal.swanbackup"] + secret_command(restore, secrets.token_hex(32), expected_success=False) + docker("run", "--rm", "--name", helper, "--read-only", "--network", "none", + "--mount", "type=volume,src=" + volume + ",dst=/var/lib/swan", "--entrypoint", "sh", + image, "-ec", "test ! -e /var/lib/swan/restored") + secret_command(restore, passphrase) + docker("create", "--name", name, "--read-only", "--tmpfs", "/tmp", + "--security-opt", "no-new-privileges:true", "--mount", "type=volume,src=" + volume + ",dst=/var/lib/swan", + "--env", "SWAN_DATA_DIR=/var/lib/swan/restored", "--publish", "127.0.0.1::8080", image) + created_container = True + docker("start", name) + base = container_base(name) + ready(base) + if request(base, "/api/v1/status") != status: + raise RuntimeError("Restored company trust or configuration state changed") + after = request(base, "/api/v1/profile") + prior_profile = json.loads(base64.b64decode(before["payload"], validate=True)) + restored_profile = json.loads(base64.b64decode(after["payload"], validate=True)) + for profile in [prior_profile, restored_profile]: + profile.pop("issued_at") + profile.pop("expires_at") + if restored_profile != prior_profile: + raise RuntimeError("Restore changed company branding or policy") + inventory = request(base, "/api/v1/devices", token=administrator) + if len(inventory) != 1 or inventory[0]["id"] != enrolled["device_id"] or inventory[0]["state"] != "approved" or inventory[0]["unattended"] is not False: + raise RuntimeError("Restore lost enrollment, approval or consent") + docker("exec", name, "sh", "-ec", + 'test "$(id -u)" = 10001; test "$(stat -c %a /var/lib/swan/restored)" = 700; ' + 'test "$(stat -c %a /var/lib/swan/restored/profile-key.hex)" = 600; ' + 'test "$(stat -c %a /var/lib/swan/restored/setup-token.txt)" = 600') + request(base, "/api/v1/login", "POST", body=login_body, expected=401) + deadline = time.monotonic() + 65 + while authenticator(secret, 1) == login_body["totp_code"]: + if time.monotonic() >= deadline: + raise RuntimeError("No fresh authenticator code available for restore verification") + time.sleep(0.2) + restored_login = dict(login_body, totp_code=authenticator(secret, 1)) + token = request(base, "/api/v1/login", "POST", body=restored_login)["token"] + request(base, "/api/v1/login", "POST", body=restored_login, expected=401) + request(base, "/api/v1/devices/" + enrolled["device_id"] + "/state", "PUT", token, {"state": "revoked", "group": "container-test"}) + request(base, "/api/v1/device/consent", "PUT", enrolled["device_token"], {"unattended": False, "revision": 1}, expected=401) + original_inventory = request(container_base(original), "/api/v1/devices", token=administrator) + if len(original_inventory) != 1 or original_inventory[0]["state"] != "approved": + raise RuntimeError("Restored deployment mutated the original company volume") + request(base, "/api/v1/logout", "POST", token) + request(base, "/api/v1/logout", "POST", administrator) + request(base, "/api/v1/devices", token=administrator, expected=401) + print("PASS: encrypted management backup, wrong-password rejection without writes, restored company/device/consent identity, private keys, MFA/replay and revocation. Transport and TLS backup remain separate tests.") + finally: + if docker("ps", "-aq", "--filter", "name=^/" + helper + "$"): + docker("rm", "--force", helper) + if created_container: + docker("rm", "--force", name) + if created_volume: + docker("volume", "rm", volume) + + +def main(): + image = sys.argv[1] if len(sys.argv) == 2 else "swan-management:test" + name = "swan-company-test-" + uuid.uuid4().hex + volume = name + "-data" + created = False + started = False + try: + docker("volume", "create", volume) + created = True + docker("create", "--name", name, "--read-only", "--tmpfs", "/tmp", + "--security-opt", "no-new-privileges:true", "--mount", "type=volume,src=" + volume + ",dst=/var/lib/swan", + "--publish", "127.0.0.1::8080", image) + started = True + docker("start", name) + base = container_base(name) + ready(base) + if docker("exec", name, "id", "-u") != "10001": + raise RuntimeError("Management container must run as the service account") + docker("exec", name, "sh", "-ec", + 'test "$SSL_CERT_FILE" = /etc/ssl/certs/ca-certificates.crt; ' + 'test -s "$SSL_CERT_FILE"; test -r "$SSL_CERT_FILE"; ' + 'test "$(stat -c %a /var/lib/swan)" = 700; ' + 'test "$(stat -c %a /var/lib/swan/profile-key.hex)" = 600; ' + 'test "$(stat -c %a /var/lib/swan/setup-token.txt)" = 600; ' + 'test -f /var/lib/swan/management.sqlite3') + status = request(base, "/api/v1/status") + if status["configured"] is not False or len(base64.b64decode(status["profile_public_key"], validate=True)) != 32: + raise RuntimeError("Expected a fresh company with its own public trust key") + if "Swan Remote Support" not in request(base, "/"): + raise RuntimeError("Administration assets were not embedded in the image") + request(base, "/api/v1/devices", expected=401) + branding = request(base, "/api/v1/default-branding") + branding["display_name"] = "Container Test Company" + secret = secrets.token_bytes(20) + password = secrets.token_hex(24) + # A known public RFC 8032 test key; no signing private key is supplied. + transport_key = base64.b64encode(bytes.fromhex("d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a")).decode("ascii") + profile = {"schema": 1, "company_id": "setup", "revision": 1, "issued_at": int(time.time()), + "expires_at": int(time.time()) + 3600, "management_url": "https://support.example.com", + "rendezvous": "support.example.com:21116", "relay": "support.example.com:21117", + "transport_public_key": transport_key, "customer": branding, "technician": branding, + "allow_unattended": False, "updates_paused": True, "rollout_percent": 0, + "maintenance_start_utc": 0, "maintenance_end_utc": 0, "update_channel": "test", + "next_profile_public_key": None} + setup_token = docker("exec", name, "cat", "/var/lib/swan/setup-token.txt") + company = request(base, "/api/v1/setup", "POST", setup_token, + {"profile": profile, "username": "container-admin", "password": password, + "totp_secret": base64.b32encode(secret).decode("ascii"), "totp_code": authenticator(secret)}) + login_body = {"username": "container-admin", "password": password, "totp_code": "invalid"} + request(base, "/api/v1/login", "POST", body=login_body, expected=401) + login_body["totp_code"] = authenticator(secret, 1) + administrator = request(base, "/api/v1/login", "POST", body=login_body)["token"] + request(base, "/api/v1/login", "POST", body=login_body, expected=401) + enrolled = request(base, "/api/v1/enroll", "POST", body={"name": "Container test device", "rustdesk_id": "987654321", "unattended_consent": False}) + if enrolled["state"] != "pending" or enrolled["company_id"] != company["company_id"]: + raise RuntimeError("Fresh enrollment must require company approval") + request(base, "/api/v1/device/consent", "PUT", enrolled["device_token"], {"unattended": True, "revision": 1}, expected=401) + request(base, "/api/v1/devices/" + enrolled["device_id"] + "/state", "PUT", administrator, {"state": "approved", "group": "container-test"}) + request(base, "/api/v1/device/consent", "PUT", enrolled["device_token"], {"unattended": True, "revision": 1}, expected=403) + docker("stop", "--time", "10", name) + if docker("inspect", "--format", "{{.State.ExitCode}}", name) != "0": + raise RuntimeError("Management did not shut down cleanly on Docker SIGTERM") + docker("start", name) + base = container_base(name) + ready(base) + after = request(base, "/api/v1/status") + if after["configured"] is not True or after["profile_public_key"] != status["profile_public_key"]: + raise RuntimeError("Container restart changed company identity") + inventory = request(base, "/api/v1/devices", token=administrator) + if len(inventory) != 1 or inventory[0]["id"] != enrolled["device_id"] or inventory[0]["state"] != "approved" or inventory[0]["unattended"] is not False: + raise RuntimeError("Container restart lost device, permission or consent state") + restore_management(image, name, volume, after, administrator, enrolled, login_body, secret) + request(base, "/api/v1/logout", "POST", administrator) + request(base, "/api/v1/devices", token=administrator, expected=401) + print("PASS: non-root/read-only container startup, private storage, company setup, MFA/replay denial, enrollment approval and restart persistence. HTTPS and native transport remain separate tests.") + finally: + if started: + docker("rm", "--force", name) + if created: + docker("volume", "rm", volume) + + +if __name__ == "__main__": + main() diff --git a/deployment/linux/test_pinned_images.py b/deployment/linux/test_pinned_images.py new file mode 100644 index 00000000000..86f60fa251b --- /dev/null +++ b/deployment/linux/test_pinned_images.py @@ -0,0 +1,56 @@ +"""Bounded public-image pull policy; Docker/network calls are mocked here.""" +import subprocess +import unittest +from unittest.mock import patch +import test_container_stack as stack + + +class PinnedImageTests(unittest.TestCase): + image = "example.test/fixture:1@sha256:" + "a" * 64 + + def result(self, status, error=""): + return subprocess.CompletedProcess([], status, stdout="", stderr=error) + + def test_cached_digest_needs_no_download(self): + with patch.object(stack.subprocess, "run", return_value=self.result(0)) as run: + stack.ensure_pinned_image(self.image) + self.assertEqual(run.call_count, 1) + + def test_temporary_completed_http_failure_retries_identical_pin(self): + with patch.object(stack.subprocess, "run", side_effect=[self.result(1), self.result(1, "received unexpected HTTP status: 500 Internal Server Error"), self.result(0)]) as run, patch.object(stack.time, "sleep") as sleep: + stack.ensure_pinned_image(self.image) + self.assertEqual(run.call_count, 3) + self.assertEqual(run.call_args_list[1].args[0], run.call_args_list[2].args[0]) + sleep.assert_called_once_with(2) + + def test_retries_are_bounded(self): + with patch.object(stack.subprocess, "run", return_value=self.result(1, "503 Service Unavailable")) as run, patch.object(stack.time, "sleep") as sleep: + with self.assertRaises(RuntimeError): + stack.ensure_pinned_image(self.image) + self.assertEqual(run.call_count, 4) + self.assertEqual(sleep.call_count, 2) + + def test_authentication_and_missing_pin_fail_without_retry(self): + for detail in ("unauthorized: authentication required", "manifest unknown"): + with patch.object(stack.subprocess, "run", side_effect=[self.result(1), self.result(1, detail)]) as run, patch.object(stack.time, "sleep") as sleep: + with self.assertRaises(RuntimeError): + stack.ensure_pinned_image(self.image) + self.assertEqual(run.call_count, 2) + sleep.assert_not_called() + + def test_timeout_never_starts_another_potentially_live_download(self): + with patch.object(stack.subprocess, "run", side_effect=[self.result(1), subprocess.TimeoutExpired("docker", 180)]) as run, patch.object(stack.time, "sleep") as sleep: + with self.assertRaises(subprocess.TimeoutExpired): + stack.ensure_pinned_image(self.image) + self.assertEqual(run.call_count, 2) + sleep.assert_not_called() + + def test_mutable_tag_rejected_before_docker(self): + with patch.object(stack.subprocess, "run") as run: + with self.assertRaises(RuntimeError): + stack.ensure_pinned_image("example.test/fixture:latest") + run.assert_not_called() + + +if __name__ == "__main__": + unittest.main() diff --git a/deployment/transport-source.json b/deployment/transport-source.json new file mode 100644 index 00000000000..4fbdfa1f293 --- /dev/null +++ b/deployment/transport-source.json @@ -0,0 +1,7 @@ +{ + "repository": "https://github.com/rustdesk/rustdesk-server", + "tag": "1.1.15", + "commit": "9bae9f2f39d92c4b4ba2e28e089da5071897b22e", + "image": "rustdesk/rustdesk-server:1.1.15@sha256:10818ec05b179039c6660f4d8e74b303f0db2858bbad2b18e24992ea22d54cd6", + "authorization_boundary": "Company authorization is enforced by the managed receiving app on every transport, including direct connections. Upstream transport registration itself is not an authenticated company enrollment API." +} diff --git a/deployment/transport/Dockerfile b/deployment/transport/Dockerfile new file mode 100644 index 00000000000..56bf064a272 --- /dev/null +++ b/deployment/transport/Dockerfile @@ -0,0 +1,34 @@ +# Candidate only: build the maintained transport from immutable public inputs. +# No company configuration, credentials or local build context enter this image. +FROM rust:1.90.0-bookworm@sha256:3914072ca0c3b8aad871db9169a651ccfce30cf58303e5d6f2db16d1d8a7e58f AS builder +RUN apt-get update && apt-get install -y --no-install-recommends git python3 pkg-config libssl-dev libsodium-dev && rm -rf /var/lib/apt/lists/* +WORKDIR /build +RUN git init recipe && git -C recipe remote add origin https://github.com/rijff24/SwanRemoteSupport && \ + git -C recipe fetch --depth 1 origin 650361413543baf7ef150f5960544154ec77ddeb && \ + git -C recipe checkout --detach FETCH_HEAD && \ + test "$(git -C recipe rev-parse HEAD)" = 650361413543baf7ef150f5960544154ec77ddeb +RUN git init transport && git -C transport remote add origin https://github.com/rustdesk/rustdesk-server && \ + git -C transport fetch --depth 1 origin 9bae9f2f39d92c4b4ba2e28e089da5071897b22e && \ + git -C transport checkout --detach FETCH_HEAD && git -C transport submodule update --init --recursive +RUN python3 recipe/deployment/transport/prepare_source.py transport tests && \ + python3 recipe/deployment/transport/prepare_source.py transport fix +WORKDIR /build/transport +# SQLx compilation opens the tracked schema database and can change its journal +# header. Preserve the actual pinned inputs before any such build-time writes. +RUN git diff --cached --quiet HEAD && git diff --quiet HEAD -- . ':!libs/hbb_common' && \ + git -C libs/hbb_common diff --cached --quiet HEAD && \ + git -C libs/hbb_common diff --quiet HEAD -- . ':!src/udp.rs' && \ + python3 ../recipe/deployment/transport/archive_source.py . /build/transport-build-source.zip +RUN cargo build --release --locked --bin hbbs --bin hbbr + +FROM debian:bookworm-slim@sha256:3783cc01769c7b2b1b83a5c5ad96c815348e28ed7da68e2e3687004faa906251 +RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates libssl3 libsodium23 && rm -rf /var/lib/apt/lists/* +COPY --from=builder /build/transport/target/release/hbbs /build/transport/target/release/hbbr /usr/local/bin/ +COPY --from=builder /build/transport/SWAN-SOURCE.json /build/transport-build-source.zip /usr/share/doc/swan-transport/ +COPY --from=builder /build/recipe/LICENCE /usr/share/doc/swan-transport/LICENCE +LABEL org.opencontainers.image.source="https://github.com/rijff24/SwanRemoteSupport" \ + org.opencontainers.image.revision="650361413543baf7ef150f5960544154ec77ddeb" \ + org.opencontainers.image.licenses="AGPL-3.0" \ + com.swan.transport.status="candidate-not-production-approved" +WORKDIR /root +CMD ["hbbs"] diff --git a/deployment/transport/README.md b/deployment/transport/README.md new file mode 100644 index 00000000000..2c9b1c00b4d --- /dev/null +++ b/deployment/transport/README.md @@ -0,0 +1,61 @@ +# Candidate maintained transport + +The Windows lab reproduced `WSAEFAULT` (10014) when sending an IPv4 sockaddr +through a dual-stack IPv6 UDP socket. Sending to the equivalent IPv4-mapped +IPv6 destination succeeded. The candidate patch normalizes that destination in +both shared `FramedSocket::send` and `send_raw`; IPv4 sockets, native IPv6 +destinations and SOCKS transport keep their existing paths. + +`source.json` pins the public server commit, shared submodule commit, upstream +Cargo lock, regression patch and implementation patch with input/output hashes. +No company profile supplies source, commands or patches. Existing installer +component pins and deployments are unchanged until candidate validation passes. + +The `swan-transport.yml` workflow prepares a clean pinned checkout and adds the +regression first. Windows must reproduce error 10014 in the real Rust UDP path +while the IPv4 and IPv6 controls pass. Linux must pass the baseline. The workflow +then applies the implementation patch, requires all three network tests to pass, +and builds both executables using Rust 1.90.0 and `--locked`. Logs and results are +retained independently of build success. Candidate binaries and exact tracked +source, patched shared code and the public recipe are archived together. + +These are unsigned test artifacts. Passing shared UDP tests does not prove an +authorized native session, Windows Server 2016 compatibility, Internet NAT +traversal or relay fallback. Validate those on the isolated lab before changing +distribution pins. Production publication still requires release approval, +valid signatures and corresponding public source; this workflow publishes no +production release and makes no claim of binary reproducibility. + +## Linux candidate deployment and integration gate + +`Dockerfile` builds the patched rendezvous/relay from the same immutable public +server and shared-submodule pins. The public recipe is pinned independently to +project revision `650361413543baf7ef150f5960544154ec77ddeb`; changing the current +checkout cannot silently change those transport inputs. Rust and both base +images are pinned, Cargo uses its verified lockfile, and the image contains +`/usr/share/doc/swan-transport/transport-build-source.zip`, the exact patched +source, recipe, receipt and AGPL notice. This establishes input provenance; +apt repositories and compiler output are not claimed bit-for-bit reproducible. + +For a **fresh isolated test deployment**, explicitly select the override: + +```sh +docker compose -f deployment/linux/compose.yaml -f deployment/linux/compose.maintained-transport.yaml up --build -d +``` + +Do not apply it to existing company volumes before backup/migration verification. +The normal deployment remains on its previously recorded component pins pending +candidate acceptance. No project registry image or production release is +published by this build. The company-component CI now also builds this candidate +and runs the existing real HTTPS, enrollment and encrypted complete backup/restore +rehearsal against its immutable local image ID. It validates the embedded public +recipe receipt before creating the rehearsal stack. These integration checks +still do not prove native direct/relay sessions or public reachability. + +After a successful rehearsal, CI retains +`MaintainedTransport-Linux-x64-UNSIGNED-TEST`: both Linux x86-64 binaries, +`transport-build-source.zip`, `SWAN-SOURCE.json`, AGPL license and a hash manifest +binding them to the immutable image ID and packaging revision. Extraction uses +an owned stopped container, checks executable architecture and the archived UDP +patch, and runs no candidate executable. This artifact remains a test input; +it is not a signed release or a published production container image. diff --git a/deployment/transport/archive_source.py b/deployment/transport/archive_source.py new file mode 100644 index 00000000000..fb6f9b9d4a8 --- /dev/null +++ b/deployment/transport/archive_source.py @@ -0,0 +1,48 @@ +"""Archive tracked transport inputs, patched shared code and the public recipe.""" +import hashlib +import json +import subprocess +import sys +import zipfile +from pathlib import Path, PurePosixPath + +source = Path(sys.argv[1]).resolve(strict=True) +output = Path(sys.argv[2]).resolve() +project = Path(__file__).resolve().parent +receipt = json.loads((source / "SWAN-SOURCE.json").read_text()) +if receipt["stage"] != "fix": + raise SystemExit("Refusing to archive an unpatched transport candidate") +pins = json.loads((project / "source.json").read_text()) +shared = source / "libs/hbb_common" +if (receipt["upstream"] != pins + or subprocess.check_output(["git", "-C", str(source), "rev-parse", "HEAD"], text=True).strip() != pins["commit"] + or subprocess.check_output(["git", "-C", str(shared), "rev-parse", "HEAD"], text=True).strip() != pins["hbb_common_commit"]): + raise SystemExit("Transport archive does not use the pinned public revisions") +# Compilation can mutate the tracked SQLite schema database. A correctly +# ordered build must archive first; do not silently call a changed working tree +# the exact public inputs. Only the hash-verified UDP patch is allowed to differ. +for directory, excluded in [(source, "libs/hbb_common"), (shared, "src/udp.rs")]: + subprocess.run(["git", "-C", str(directory), "diff", "--cached", "--quiet", "HEAD"], check=True) + subprocess.run(["git", "-C", str(directory), "diff", "--quiet", "HEAD", "--", ".", ":!" + excluded], check=True) +udp = (shared / "src/udp.rs").read_bytes().replace(b"\r\n", b"\n") +if hashlib.sha256(udp).hexdigest() != pins["patches"][-1]["after_sha256"]: + raise SystemExit("Transport archive UDP source does not match the reviewed patch") +tracked = subprocess.check_output(["git", "-C", str(source), "ls-files", "--recurse-submodules", "-z"]) +files = {} +for raw in filter(None, tracked.split(b"\0")): + name = raw.decode() + relative = PurePosixPath(name) + path = source.joinpath(*relative.parts) + if relative.is_absolute() or any(part in ("..", ".git") for part in relative.parts) or path.is_symlink() or not path.is_file(): + raise SystemExit("Unsafe or missing tracked transport input") + files["rustdesk-server/" + name] = path +files["rustdesk-server/SWAN-SOURCE.json"] = source / "SWAN-SOURCE.json" +for name in ("source.json", "prepare_source.py", "check_baseline.py", "archive_source.py", + "patches/0001-udp-address-regression.patch", "patches/0002-udp-address-normalization.patch"): + files["swan-recipe/" + name] = project / name +output.parent.mkdir(parents=True, exist_ok=True) +with zipfile.ZipFile(output, "x", compression=zipfile.ZIP_DEFLATED) as archive: + for name, path in sorted(files.items()): + archive.writestr(name, path.read_bytes()) +print(json.dumps({"source_files": len(files), "sha256": hashlib.sha256(output.read_bytes()).hexdigest(), + "scope": "Exact transport build inputs and recipe; not binary reproducibility"})) diff --git a/deployment/transport/check_baseline.py b/deployment/transport/check_baseline.py new file mode 100644 index 00000000000..8a9fa1e9052 --- /dev/null +++ b/deployment/transport/check_baseline.py @@ -0,0 +1,35 @@ +"""Verify the baseline failure or the fixed real UDP paths, retaining logs.""" +import json +import os +import subprocess +import sys +from pathlib import Path + +source = Path(sys.argv[1]).resolve(strict=True) +stage = sys.argv[2] if len(sys.argv) == 3 else "baseline" +if stage not in ("baseline", "fixed"): + raise SystemExit("Select baseline or fixed verification") +result = subprocess.run(["cargo", "+1.90.0", "test", "--release", "--locked", "-p", "hbb_common", + "swan_udp_tests", "--", "--test-threads=1"], cwd=source, + stdout=subprocess.PIPE, stderr=subprocess.STDOUT) +log = result.stdout.decode(errors="replace") +evidence = source / "swan-evidence" +evidence.mkdir(exist_ok=stage == "fixed") +(evidence / (stage + ".log")).write_bytes(result.stdout) +windows = os.name == "nt" +expected_failure = (result.returncode != 0 and "os error 10014" in log + and "dual_stack_sends_ipv4_message_and_raw_datagrams ... FAILED" in log + and "ipv4_sender_keeps_ipv4_destination ... ok" in log + and "ipv6_sender_keeps_native_ipv6_destination ... ok" in log) +all_passed = result.returncode == 0 and all(name + " ... ok" in log for name in ( + "dual_stack_sends_ipv4_message_and_raw_datagrams", + "ipv4_sender_keeps_ipv4_destination", "ipv6_sender_keeps_native_ipv6_destination")) +passed = expected_failure if windows and stage == "baseline" else all_passed +receipt = {"stage": stage, "windows": windows, "exit_code": result.returncode, + "expected_winsock_failure_reproduced": expected_failure, + "baseline_check_passed": passed} +(evidence / (stage + ".json")).write_text(json.dumps(receipt, indent=2) + "\n") +print("\n".join(log.splitlines()[-60:])) +print(json.dumps(receipt)) +if not passed: + raise SystemExit("Baseline did not demonstrate the required platform behavior; no fix accepted") diff --git a/deployment/transport/patches/0001-udp-address-regression.patch b/deployment/transport/patches/0001-udp-address-regression.patch new file mode 100644 index 00000000000..10d2fa495a8 --- /dev/null +++ b/deployment/transport/patches/0001-udp-address-regression.patch @@ -0,0 +1,62 @@ +--- a/src/udp.rs ++++ b/src/udp.rs +@@ -169,3 +169,59 @@ + None + } + } ++ ++ ++#[cfg(test)] ++mod swan_udp_tests { ++ use super::*; ++ use crate::rendezvous_proto::TestNatRequest; ++ use std::time::Duration; ++ ++ fn framed_sender(address: SocketAddr, dual_stack: bool) -> ResultType { ++ let domain = if address.is_ipv6() { Domain::ipv6() } else { Domain::ipv4() }; ++ let socket = Socket::new(domain, Type::dgram(), None)?; ++ if address.is_ipv6() { socket.set_only_v6(!dual_stack)?; } ++ socket.set_nonblocking(true)?; ++ socket.bind(&address.into())?; ++ Ok(FramedSocket::Direct(UdpFramed::new( ++ UdpSocket::from_std(socket.into_udp_socket())?, BytesCodec::new(), ++ ))) ++ } ++ ++ #[tokio::test] ++ async fn dual_stack_sends_ipv4_message_and_raw_datagrams() -> ResultType<()> { ++ let receiver = UdpSocket::bind("127.0.0.1:0").await?; ++ let mut sender = framed_sender("[::]:0".parse()?, true)?; ++ let request = TestNatRequest { serial: 21116, ..Default::default() }; ++ sender.send(&request, receiver.local_addr()?).await?; ++ let mut buffer = [0u8; 256]; ++ let count = tokio::time::timeout(Duration::from_secs(2), receiver.recv(&mut buffer)).await??; ++ assert_eq!(TestNatRequest::parse_from_bytes(&buffer[..count])?.serial, 21116); ++ sender.send_raw(b"Swan raw UDP", receiver.local_addr()?).await?; ++ let count = tokio::time::timeout(Duration::from_secs(2), receiver.recv(&mut buffer)).await??; ++ assert_eq!(&buffer[..count], b"Swan raw UDP"); ++ Ok(()) ++ } ++ ++ #[tokio::test] ++ async fn ipv4_sender_keeps_ipv4_destination() -> ResultType<()> { ++ let receiver = UdpSocket::bind("127.0.0.1:0").await?; ++ let mut sender = framed_sender("127.0.0.1:0".parse()?, false)?; ++ sender.send_raw(b"IPv4 control", receiver.local_addr()?).await?; ++ let mut buffer = [0u8; 64]; ++ let count = tokio::time::timeout(Duration::from_secs(2), receiver.recv(&mut buffer)).await??; ++ assert_eq!(&buffer[..count], b"IPv4 control"); ++ Ok(()) ++ } ++ ++ #[tokio::test] ++ async fn ipv6_sender_keeps_native_ipv6_destination() -> ResultType<()> { ++ let receiver = UdpSocket::bind("[::1]:0").await?; ++ let mut sender = framed_sender("[::1]:0".parse()?, false)?; ++ sender.send_raw(b"IPv6 control", receiver.local_addr()?).await?; ++ let mut buffer = [0u8; 64]; ++ let count = tokio::time::timeout(Duration::from_secs(2), receiver.recv(&mut buffer)).await??; ++ assert_eq!(&buffer[..count], b"IPv6 control"); ++ Ok(()) ++ } ++} diff --git a/deployment/transport/patches/0002-udp-address-normalization.patch b/deployment/transport/patches/0002-udp-address-normalization.patch new file mode 100644 index 00000000000..2e59af84838 --- /dev/null +++ b/deployment/transport/patches/0002-udp-address-normalization.patch @@ -0,0 +1,36 @@ +--- a/src/udp.rs ++++ b/src/udp.rs +@@ -43,6 +43,17 @@ + } + socket.bind(&addr.into())?; + Ok(socket) ++} ++ ++fn destination_for_socket(local: SocketAddr, destination: SocketAddr) -> SocketAddr { ++ match (local, destination) { ++ // Winsock rejects an AF_INET sockaddr on an AF_INET6 socket with ++ // WSAEFAULT (10014), even when IPV6_V6ONLY is disabled. ++ (SocketAddr::V6(_), SocketAddr::V4(address)) => SocketAddr::V6( ++ std::net::SocketAddrV6::new(address.ip().to_ipv6_mapped(), address.port(), 0, 0), ++ ), ++ (_, destination) => destination, ++ } + } + + impl FramedSocket { +@@ -100,6 +111,7 @@ + match self { + Self::Direct(f) => { + if let TargetAddr::Ip(addr) = addr { ++ let addr = destination_for_socket(f.get_ref().local_addr()?, addr); + f.send((send_data, addr)).await? + } + } +@@ -120,6 +132,7 @@ + match self { + Self::Direct(f) => { + if let TargetAddr::Ip(addr) = addr { ++ let addr = destination_for_socket(f.get_ref().local_addr()?, addr); + f.send((Bytes::from(msg), addr)).await? + } + } diff --git a/deployment/transport/prepare_source.py b/deployment/transport/prepare_source.py new file mode 100644 index 00000000000..e303d8a9cb3 --- /dev/null +++ b/deployment/transport/prepare_source.py @@ -0,0 +1,60 @@ +"""Apply reviewed transport patches only to the exact public source pins.""" +import argparse +import hashlib +import json +import subprocess +from pathlib import Path + + +def git(directory, *arguments): + return subprocess.check_output(["git", "-C", str(directory), *arguments]) + + +def canonical(path): + if not path.is_file() or path.is_symlink(): + raise ValueError("Expected a regular pinned source file") + return path.read_bytes().replace(b"\r\n", b"\n") + + +def prepare(source, stage): + project = Path(__file__).resolve().parent + manifest = json.loads((project / "source.json").read_text()) + source = source.resolve(strict=True) + shared = source / "libs/hbb_common" + if git(source, "rev-parse", "HEAD").decode().strip() != manifest["commit"]: + raise ValueError("Wrong transport source revision") + if git(shared, "rev-parse", "HEAD").decode().strip() != manifest["hbb_common_commit"]: + raise ValueError("Wrong shared transport source revision") + if hashlib.sha256(canonical(source / "Cargo.lock")).hexdigest() != manifest["cargo_lock_sha256"]: + raise ValueError("Pinned transport dependency lock changed") + if stage == "tests" and git(source, "status", "--porcelain").strip(): + raise ValueError("Baseline checkout must be clean before adding the regression") + entry = manifest["patches"][0 if stage == "tests" else 1] + patch = (project / entry["path"]).resolve(strict=True) + if patch.parent != project / "patches" or hashlib.sha256(patch.read_bytes()).hexdigest() != entry["sha256"]: + raise ValueError("Transport patch identity differs") + target = shared / "src/udp.rs" + before = canonical(target) + if hashlib.sha256(before).hexdigest() != entry["before_sha256"]: + raise ValueError("Transport source differs from the reviewed patch input") + # Use a canonical checkout representation for identical patch application on + # Windows and Linux; Git still records the same public source content. + target.write_bytes(before) + git(shared, "apply", "--check", "--whitespace=error-all", str(patch)) + git(shared, "apply", "--whitespace=error-all", str(patch)) + if hashlib.sha256(canonical(target)).hexdigest() != entry["after_sha256"]: + raise ValueError("Patched transport source differs from reviewed output") + receipt = {"upstream": manifest, "stage": stage, + "project_revision": git(project, "rev-parse", "HEAD").decode().strip(), + "patched_udp_sha256": entry["after_sha256"], "unsigned_test": True} + (source / "SWAN-SOURCE.json").write_text(json.dumps(receipt, indent=2) + "\n") + print(json.dumps({"stage": stage, "source": manifest["commit"], + "shared_source": manifest["hbb_common_commit"], "patch_verified": True})) + + +if __name__ == "__main__": + parser = argparse.ArgumentParser() + parser.add_argument("source", type=Path) + parser.add_argument("stage", choices=("tests", "fix")) + arguments = parser.parse_args() + prepare(arguments.source, arguments.stage) diff --git a/deployment/transport/prepare_windows_components.py b/deployment/transport/prepare_windows_components.py new file mode 100644 index 00000000000..ebf0db57561 --- /dev/null +++ b/deployment/transport/prepare_windows_components.py @@ -0,0 +1,199 @@ +"""Prepare public Windows server inputs; never execute supplied components. + +Binary pins and a canonical digest of all corresponding source/recipe files +bind the maintained transport to reviewed public inputs. Build receipts are +checked separately because their project revision varies between CI runs. +""" +import argparse +import hashlib +import io +import json +from pathlib import Path, PurePosixPath +import re +import stat +import struct +import zipfile + +ROOT = Path(__file__).resolve().parents[2] +SOURCE_RECEIPT = "rustdesk-server/SWAN-SOURCE.json" +TRANSPORT_FILES = { + "transport-source/target/release/hbbs.exe": 128 * 1024**2, + "transport-source/target/release/hbbr.exe": 128 * 1024**2, + "transport-source/SWAN-SOURCE.json": 128 * 1024, + "transport-artifacts/transport-build-source.zip": 64 * 1024**2, +} + + +def canonical(value): + if b"\0" not in value: + try: + return value.decode("utf-8").replace("\r\n", "\n").encode("utf-8") + except UnicodeDecodeError: + pass + return value + + +def entries(archive, limits=None): + result = {} + total = 0 + for entry in archive.infolist(): + name = entry.filename + path = PurePosixPath(name) + if (entry.is_dir() or path.is_absolute() or "\\" in name or ":" in name + or any(p in (".", "..") or p.endswith((" ", ".")) for p in path.parts) + or stat.S_ISLNK(entry.external_attr >> 16) or entry.flag_bits & 1 + or name.casefold() in {p.casefold() for p in result}): + raise ValueError("Unsafe or duplicate server input") + limit = limits.get(name, -1) if limits is not None else 16 * 1024**2 + total += entry.file_size + if not name or not 0 <= entry.file_size <= limit or total > 300 * 1024**2: + raise ValueError("Unexpected or oversized server input") + with archive.open(entry) as stream: + value = stream.read(limit + 1) + if len(value) != entry.file_size: + raise ValueError("Server input length changed") + result[name] = value + if limits is not None and set(result) != set(limits): + raise ValueError("Incomplete maintained transport input") + return result + + +def read_file(path, limit): + info = path.lstat() + if (not stat.S_ISREG(info.st_mode) or getattr(info, "st_file_attributes", 0) & 1024 + or info.st_size > limit): + raise ValueError("Expected a bounded regular server input") + with path.open("rb") as stream: + value = stream.read(limit + 1) + if len(value) > limit or len(value) != info.st_size: + raise ValueError("Server input changed or exceeded its size limit") + return value + + +def transport_inputs(package=None, directory=None): + if package is not None: + with zipfile.ZipFile(io.BytesIO(read_file(package, 300 * 1024**2))) as archive: + return entries(archive, TRANSPORT_FILES) + info = directory.lstat() + if not stat.S_ISDIR(info.st_mode) or getattr(info, "st_file_attributes", 0) & 1024: + raise ValueError("Expected a regular maintained transport directory") + directory = directory.resolve(strict=True) + allowed_directories = {str(parent) for name in TRANSPORT_FILES + for parent in PurePosixPath(name).parents if str(parent) != "."} + found = set() + for path in directory.rglob("*"): + info = path.lstat() + if stat.S_ISLNK(info.st_mode) or getattr(info, "st_file_attributes", 0) & 1024: + raise ValueError("Maintained transport inputs must not contain links") + if stat.S_ISREG(info.st_mode): + found.add(path.relative_to(directory).as_posix()) + elif not stat.S_ISDIR(info.st_mode) or path.relative_to(directory).as_posix() not in allowed_directories: + raise ValueError("Unexpected maintained transport directory entry") + if found != set(TRANSPORT_FILES): + raise ValueError("Unexpected maintained transport directory contents") + return {name: read_file(directory / name, limit) for name, limit in TRANSPORT_FILES.items()} + + +def source_tree_digest(files): + digest = hashlib.sha256(b"swan-windows-transport-source-v1\0") + items = sorted((name, canonical(value)) for name, value in files.items() if name != SOURCE_RECEIPT) + digest.update(struct.pack(">Q", len(items))) + for name, value in items: + encoded = name.encode("utf-8") + digest.update(struct.pack(">Q", len(encoded))); digest.update(encoded) + digest.update(struct.pack(">Q", len(value))); digest.update(value) + return digest.hexdigest() + + +def normalized_source(files): + # Source-only metadata/line endings become deterministic. Shipping EXEs are + # never modified. ZIP_STORED avoids compression-library version dependence. + output = io.BytesIO() + with zipfile.ZipFile(output, "w", compression=zipfile.ZIP_STORED) as archive: + for name, value in sorted(files.items()): + entry = zipfile.ZipInfo(name, date_time=(1980, 1, 1, 0, 0, 0)) + entry.create_system = 3 + entry.external_attr = 0o100644 << 16 + archive.writestr(entry, canonical(value)) + return output.getvalue() + + +def prepare(inputs, https_package, destination, root=ROOT): + manifest_bytes = (root / "deployment/windows/server-components.json").read_bytes() + manifest = json.loads(manifest_bytes) + pins = json.loads((root / "deployment/transport/source.json").read_text(encoding="utf-8")) + if (manifest["schema"] != 2 or manifest["architecture"] != "x86_64" + or manifest["transport"]["kind"] != "swan-maintained-source-build"): + raise ValueError("Maintained Windows component policy required") + for name, expected in manifest["transport"]["binary_sha256"].items(): + if name not in ("hbbs.exe", "hbbr.exe"): + raise ValueError("Unexpected maintained executable") + if hashlib.sha256(inputs["transport-source/target/release/" + name]).hexdigest() != expected: + raise ValueError("Maintained Windows executable hash mismatch") + if set(manifest["transport"]["binary_sha256"]) != {"hbbs.exe", "hbbr.exe"}: + raise ValueError("Incomplete maintained binary pins") + with zipfile.ZipFile(io.BytesIO(inputs["transport-artifacts/transport-build-source.zip"])) as archive: + source = entries(archive) + if (len(source) != manifest["transport"]["source_files"] + or source_tree_digest(source) != manifest["transport"]["source_tree_sha256"]): + raise ValueError("Maintained transport corresponding source mismatch") + if canonical(source[SOURCE_RECEIPT]) != canonical(inputs["transport-source/SWAN-SOURCE.json"]): + raise ValueError("Transport source receipts differ") + receipt = json.loads(source[SOURCE_RECEIPT]) + if (set(receipt) != {"upstream", "stage", "project_revision", "patched_udp_sha256", "unsigned_test"} + or receipt["stage"] != "fix" or receipt["upstream"] != pins + or receipt["patched_udp_sha256"] != pins["patches"][-1]["after_sha256"] + or receipt["unsigned_test"] is not True + or not re.fullmatch(r"[a-f0-9]{40}", receipt["project_revision"])): + raise ValueError("Maintained transport build policy mismatch") + https = read_file(https_package, 256 * 1024**2) + if hashlib.sha256(https).hexdigest() != manifest["https"]["sha256"]: + raise ValueError("Pinned HTTPS archive hash mismatch") + with zipfile.ZipFile(io.BytesIO(https)) as archive: + names = archive.namelist() + if names.count("caddy.exe") != 1 or names.count("LICENSE") != 1: + raise ValueError("HTTPS executable or license missing") + if archive.getinfo("caddy.exe").file_size > 128 * 1024**2 or not 0 < archive.getinfo("LICENSE").file_size <= 128 * 1024: + raise ValueError("HTTPS component exceeds size limit") + caddy, caddy_license = archive.read("caddy.exe"), archive.read("LICENSE") + if hashlib.sha256(caddy).hexdigest() != manifest["https"]["binary_sha256"]: + raise ValueError("Pinned HTTPS executable mismatch") + outputs = {name: inputs["transport-source/target/release/" + name] for name in ("hbbs.exe", "hbbr.exe")} + outputs.update({"caddy.exe": caddy, "Caddy-LICENSE.txt": caddy_license, + "RustDesk-LICENSE.txt": canonical(source["rustdesk-server/LICENSE"]), + "transport-build-source.zip": normalized_source(source), + "server-components.json": canonical(manifest_bytes)}) + notices = ("RustDesk rendezvous/relay: AGPL-3.0, upstream RustDesk contributors; Swan maintained UDP normalization.\n" + f"Upstream source: {manifest['transport']['source']}\n" + f"Public recipe revision: https://github.com/rijff24/SwanRemoteSupport/tree/{receipt['project_revision']}/deployment/transport\n" + "Complete corresponding source and recipe: transport-build-source.zip (source ZIP metadata and text checkout endings normalized).\n\n" + f"Caddy HTTPS server: Apache-2.0, upstream Caddy contributors.\nSource: {manifest['https']['source']}\n" + "Retain both licenses, notices and the transport source archive with distributions. Test inputs do not establish production signing approval.\n") + outputs["THIRD-PARTY.txt"] = notices.encode("utf-8") + destination = destination.absolute() + if destination.exists() or destination.is_symlink(): + raise ValueError("Component output directory already exists") + destination.mkdir() # Exclusive create, after all input verification. + for name, value in outputs.items(): + with (destination / name).open("xb") as stream: + stream.write(value) + return {"maintained_transport": True, "verified_source_files": len(source), + "source_tree_sha256": source_tree_digest(source), "project_revision": receipt["project_revision"], + "sha256": {name: hashlib.sha256(value).hexdigest() for name, value in outputs.items()}, + "supplier_executables_run": False, "production_publication": False} + + +def main(): + parser = argparse.ArgumentParser() + choice = parser.add_mutually_exclusive_group(required=True) + choice.add_argument("--transport-package", type=Path) + choice.add_argument("--transport-directory", type=Path) + parser.add_argument("--https-package", type=Path, required=True) + parser.add_argument("--output", type=Path, required=True) + args = parser.parse_args() + result = prepare(transport_inputs(args.transport_package, args.transport_directory), args.https_package, args.output) + print(json.dumps(result, indent=2)) + + +if __name__ == "__main__": + main() diff --git a/deployment/transport/source.json b/deployment/transport/source.json new file mode 100644 index 00000000000..b801cc1608b --- /dev/null +++ b/deployment/transport/source.json @@ -0,0 +1,23 @@ +{ + "schema": 1, + "repository": "https://github.com/rustdesk/rustdesk-server", + "commit": "9bae9f2f39d92c4b4ba2e28e089da5071897b22e", + "hbb_common_commit": "83419b6549636ee39dacef7776c473f5802e08d6", + "rust_toolchain": "1.90.0", + "cargo_lock_sha256": "a9c8c1a941df0c66a33eeadbb1b7fd413287aefb5f06e0e6ca30ceb92f8caad9", + "patches": [ + { + "path": "patches/0001-udp-address-regression.patch", + "sha256": "e2ecf9da94c84e5447e81e8667a75dc73d4ab9179efec4f465414599c1f9ec15", + "before_sha256": "c7da75a4db64fea07f6dce1ad5a791fb4d791b15dcc41672129b0f6438ad8b2f", + "after_sha256": "024397429d5ec03888bff1b856f968764c4bb4e6b09ffa1b9c780ef93a6efac3" + }, + { + "path": "patches/0002-udp-address-normalization.patch", + "sha256": "c862020cf0fddf6a50e401674006d41775dd955c3eb2a2e7c149260ad888adc2", + "before_sha256": "024397429d5ec03888bff1b856f968764c4bb4e6b09ffa1b9c780ef93a6efac3", + "after_sha256": "c881d983b3b1cb77be30cf557be84e65064c72f69b414792cd62230fa99811f4" + } + ], + "scope": "Candidate transport patch; not approved for production or substituted into current installers" +} diff --git a/deployment/transport/test_windows_components.py b/deployment/transport/test_windows_components.py new file mode 100644 index 00000000000..dfb23dd3b2b --- /dev/null +++ b/deployment/transport/test_windows_components.py @@ -0,0 +1,163 @@ +"""Verifier boundary tests with inert fixtures; no executable is run.""" +import copy +import hashlib +import importlib.util +import io +import json +from pathlib import Path +import tempfile +import unittest +import warnings +import zipfile + +spec = importlib.util.spec_from_file_location("windows_components", Path(__file__).with_name("prepare_windows_components.py")) +components = importlib.util.module_from_spec(spec) +spec.loader.exec_module(components) + + +def zipped(files, date=(2024, 1, 1, 0, 0, 0)): + stream = io.BytesIO() + with zipfile.ZipFile(stream, "w") as archive: + for name, value in files.items(): + archive.writestr(zipfile.ZipInfo(name, date_time=date), value) + return stream.getvalue() + + +class WindowsComponentsTests(unittest.TestCase): + def setUp(self): + self.temporary = tempfile.TemporaryDirectory(prefix="swan-maintained-package-") + self.addCleanup(self.temporary.cleanup) + self.root = Path(self.temporary.name).resolve() + for directory in ("deployment/windows", "deployment/transport"): + (self.root / directory).mkdir(parents=True) + self.pins = {"patches": [{"after_sha256": "c" * 64}], "scope": "inert verifier fixture"} + self.receipt = {"upstream": self.pins, "stage": "fix", "project_revision": "a" * 40, + "patched_udp_sha256": "c" * 64, "unsigned_test": True} + self.source = {"rustdesk-server/Cargo.toml": b"inert source fixture\n", + "rustdesk-server/LICENSE": b"inert license fixture\n", + "swan-recipe/recipe.py": b"# inert recipe fixture\n", + components.SOURCE_RECEIPT: json.dumps(self.receipt).encode()} + self.inputs = {"transport-source/target/release/hbbs.exe": b"inert rendezvous fixture", + "transport-source/target/release/hbbr.exe": b"inert relay fixture", + "transport-source/SWAN-SOURCE.json": self.source[components.SOURCE_RECEIPT], + "transport-artifacts/transport-build-source.zip": zipped(self.source)} + https = zipped({"caddy.exe": b"inert HTTPS fixture", "LICENSE": b"inert HTTPS license"}) + self.https = self.root / "https.zip" + self.https.write_bytes(https) + self.manifest = {"schema": 2, "architecture": "x86_64", + "transport": {"kind": "swan-maintained-source-build", "source": "https://example.test/source", + "binary_sha256": {n: hashlib.sha256(self.inputs["transport-source/target/release/" + n]).hexdigest() for n in ("hbbs.exe", "hbbr.exe")}, + "source_files": len(self.source), "source_tree_sha256": components.source_tree_digest(self.source)}, + "https": {"source": "https://example.test/https", "sha256": hashlib.sha256(https).hexdigest(), + "binary_sha256": hashlib.sha256(b"inert HTTPS fixture").hexdigest()}} + (self.root / "deployment/windows/server-components.json").write_text(json.dumps(self.manifest), encoding="utf-8") + (self.root / "deployment/transport/source.json").write_text(json.dumps(self.pins), encoding="utf-8") + self.output = self.root / "prepared" + + def prepare(self, inputs=None, destination=None): + return components.prepare(self.inputs if inputs is None else inputs, self.https, + self.output if destination is None else destination, self.root) + + def reject(self, inputs): + with self.assertRaises(ValueError): + self.prepare(inputs) + self.assertFalse(self.output.exists(), "Rejected inputs must not create output") + + def test_verified_bytes_source_and_licenses_retained_without_execution(self): + result = self.prepare() + self.assertFalse(result["supplier_executables_run"]) + self.assertEqual(len(result["sha256"]), 8) + for name in ("hbbs.exe", "hbbr.exe"): + self.assertEqual((self.output / name).read_bytes(), self.inputs["transport-source/target/release/" + name]) + with zipfile.ZipFile(self.output / "transport-build-source.zip") as source: + self.assertEqual(set(source.namelist()), set(self.source)) + for entry in source.infolist(): + self.assertEqual(entry.date_time, (1980, 1, 1, 0, 0, 0)) + self.assertIn("AGPL-3.0", (self.output / "THIRD-PARTY.txt").read_text()) + + def test_source_zip_timestamps_and_checkout_endings_do_not_change_shipping_inputs(self): + first = self.prepare() + source = {name: value.replace(b"\n", b"\r\n") for name, value in self.source.items()} + inputs = dict(self.inputs) + inputs["transport-artifacts/transport-build-source.zip"] = zipped(source, (2025, 2, 2, 2, 2, 2)) + second = self.prepare(inputs, self.root / "second") + self.assertEqual(first, second) + + def test_changed_binary_rejected_before_any_output(self): + inputs = dict(self.inputs) + inputs["transport-source/target/release/hbbs.exe"] += b"changed" + self.reject(inputs) + + def test_changed_source_and_extra_file_rejected(self): + for change in ("changed", "extra"): + source = dict(self.source) + source["rustdesk-server/Cargo.toml" if change == "changed" else "unexpected.py"] = b"unreviewed input" + inputs = dict(self.inputs) + inputs["transport-artifacts/transport-build-source.zip"] = zipped(source) + self.reject(inputs) + + def test_forged_receipt_cannot_replace_verified_source(self): + for field, value in [("stage", "tests"), ("project_revision", "not-a-revision"), + ("unsigned_test", False), ("patched_udp_sha256", "d" * 64), + ("upstream", {"different": True}), ("unexpected", "unreviewed")]: + receipt = copy.deepcopy(self.receipt) + receipt[field] = value + source = dict(self.source) + source[components.SOURCE_RECEIPT] = json.dumps(receipt).encode() + inputs = dict(self.inputs) + inputs["transport-source/SWAN-SOURCE.json"] = source[components.SOURCE_RECEIPT] + inputs["transport-artifacts/transport-build-source.zip"] = zipped(source) + self.reject(inputs) + + def test_outer_and_inner_receipt_must_agree(self): + inputs = dict(self.inputs) + inputs["transport-source/SWAN-SOURCE.json"] += b" " + self.reject(inputs) + + def test_wrong_https_archive_rejected(self): + self.https.write_bytes(b"wrong archive") + self.reject(self.inputs) + + def test_unsafe_and_duplicate_source_paths_rejected(self): + for name in ("../outside", "/absolute", "back\\slash", "drive:name"): + source = dict(self.source) + source[name] = b"unreviewed" + inputs = dict(self.inputs) + inputs["transport-artifacts/transport-build-source.zip"] = zipped(source) + self.reject(inputs) + stream = io.BytesIO() + with warnings.catch_warnings(): + warnings.simplefilter("ignore", UserWarning) + with zipfile.ZipFile(stream, "w") as archive: + for _ in range(2): + archive.writestr("duplicate", b"fixture") + inputs = dict(self.inputs) + inputs["transport-artifacts/transport-build-source.zip"] = stream.getvalue() + self.reject(inputs) + + def test_existing_output_is_never_overwritten(self): + self.output.mkdir() + marker = self.output / "owned-marker" + marker.write_bytes(b"preserve") + with self.assertRaises(ValueError): + self.prepare() + self.assertEqual(marker.read_bytes(), b"preserve") + + def test_artifact_zip_and_extracted_directory_have_same_strict_input_set(self): + package = self.root / "transport.zip" + package.write_bytes(zipped(self.inputs)) + self.assertEqual(components.transport_inputs(package=package), self.inputs) + directory = self.root / "artifact" + directory.mkdir() + for name, value in self.inputs.items(): + path = directory / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(value) + self.assertEqual(components.transport_inputs(directory=directory), self.inputs) + (directory / "unexpected").write_bytes(b"unreviewed") + with self.assertRaises(ValueError): + components.transport_inputs(directory=directory) + + +if __name__ == "__main__": + unittest.main() diff --git a/deployment/web/test-company-setup.js b/deployment/web/test-company-setup.js new file mode 100644 index 00000000000..f2912880615 --- /dev/null +++ b/deployment/web/test-company-setup.js @@ -0,0 +1,94 @@ +// Browser-form contract checks only; these do not replace real setup/MFA tests. +'use strict'; +const assert=require('node:assert/strict'); +const fs=require('node:fs'); +const path=require('node:path'); +const vm=require('node:vm'); +const {webcrypto}=require('node:crypto'); +const source=fs.readFileSync(path.join(__dirname,'../../product/management/web/app.js'),'utf8').replace(/\r\n/g,'\n'); +const brand={display_name:'Default support',primary_color:'#007F82',logo_svg:'',support_url:'',consent_text:'Ask before support.',support_contacts:'',shortcuts:[]}; + +async function fixture(){ + const nodes=new Map(),requests=[]; + class Element { + constructor(){this.listeners={};this.children=[];this.disabled=false;this.hidden=false;this.textContent='';} + addEventListener(type,callback){this.listeners[type]=callback;} + querySelector(selector){ + if(!this.parts)this.parts=new Map(); + if(!this.parts.has(selector))this.parts.set(selector,new Element()); + return this.parts.get(selector); + } + set innerHTML(html){ + this.html=html; + for(const match of html.matchAll(/\bid="([^"]+)"/g))nodes.set('#'+match[1],new Element()); + } + get innerHTML(){return this.html;} + } + for(const id of ['content','notice','title','description','nav','logout'])nodes.set('#'+id,new Element()); + const context=vm.createContext({ + document:{querySelector:selector=>nodes.get(selector)||null,querySelectorAll:()=>[]}, + crypto:webcrypto,Uint8Array,TextDecoder,atob:value=>Buffer.from(value,'base64').toString('binary'), + FormData:class{constructor(form){return form.values;}}, + fetch:async(url,options)=>{ + requests.push({url,options,body:options.body&&JSON.parse(options.body)}); + let value; + if(url==='/api/v1/status')value={configured:false}; + else if(url==='/api/v1/default-branding')value=brand; + else if(url==='/api/v1/setup'&&options.method==='POST')value={configured:true}; + else if(url==='/api/v1/profile'&&options.method==='GET'){ + const profile=requests.find(r=>r.url==='/api/v1/setup').body.profile; + value={payload:Buffer.from(JSON.stringify(profile)).toString('base64'),signature:'contract-fixture-only'}; + }else if(url==='/api/v1/profile'&&options.method==='PUT')value={revision:2}; + else throw Error('Unexpected contract request: '+url); + return {ok:true,json:async()=>value}; + } + }); + assert.ok(source.endsWith('initialize();\n')); + vm.runInContext(source.replace(/initialize\(\);\n$/,'globalThis.initialization=initialize();\n'),context); + await context.initialization; + return {context,nodes,requests}; +} +function values(){ + const v=new FormData(); + for(const [key,value] of Object.entries({url:'https://support.example.org/',rendezvous:'id.example.org:32116',relay:'relay.example.org:32117',key:'public-transport-key',setup_token:'one-use-setup-token',username:'owner',password:'example password for form contract',code:'123456',rollout:'23',update_channel:'test',maintenance_start:'23',maintenance_end:'4',paused:'on'}))v.set(key,value); + for(const edition of ['customer','technician']){ + for(const [key,value] of Object.entries({name:edition==='customer'?'Example customer care':'Example service desk',color:edition==='customer'?'#123456':'#654321',support:'https://example.org/'+edition,contacts:'Company help desk',consent:'Only approve a known technician.',logo:''}))v.set(edition+'_'+key,value); + v.append(edition+'_shortcut_label','Company '+edition);v.append(edition+'_shortcut_url','https://example.org/help/'+edition); + } + return v; +} +async function submit(form,v){form.values=v;await form.listeners.submit({preventDefault(){},target:form});} + +(async()=>{ + const f=await fixture(); + const html=f.nodes.get('#content').innerHTML; + for(const name of ['customer_name','technician_name','customer_color','technician_color','customer_contacts','technician_contacts','customer_logo','technician_logo','unattended','paused','rollout','update_channel','maintenance_start','maintenance_end'])assert.ok(html.includes('name="'+name+'"'),name+' is available before setup'); + assert.ok(html.includes('<svg>'),'SVG source is escaped in the form'); + const v=values();await submit(f.nodes.get('#setup'),v); + const sent=f.requests.find(r=>r.url==='/api/v1/setup');assert.ok(sent); + assert.equal(sent.options.headers.Authorization,'Bearer one-use-setup-token'); + assert.match(sent.body.totp_secret,/^[A-Z2-7]{32}$/); + assert.equal(sent.body.username,'owner');assert.equal(sent.body.totp_code,'123456'); + const p=sent.body.profile; + assert.equal(p.management_url,'https://support.example.org');assert.equal(p.rendezvous,'id.example.org:32116');assert.equal(p.relay,'relay.example.org:32117');assert.equal(p.transport_public_key,'public-transport-key'); + for(const edition of ['customer','technician']){ + assert.equal(p[edition].display_name,v.get(edition+'_name'));assert.equal(p[edition].primary_color,v.get(edition+'_color'));assert.equal(p[edition].logo_svg,v.get(edition+'_logo'));assert.equal(p[edition].support_url,v.get(edition+'_support'));assert.equal(p[edition].support_contacts,v.get(edition+'_contacts'));assert.equal(p[edition].consent_text,v.get(edition+'_consent')); + assert.deepEqual(p[edition].shortcuts,[{label:'Company '+edition,url:'https://example.org/help/'+edition}]); + } + assert.equal(p.allow_unattended,false);assert.equal(p.updates_paused,true);assert.equal(p.rollout_percent,23);assert.equal(p.update_channel,'test');assert.equal(p.maintenance_start_utc,23);assert.equal(p.maintenance_end_utc,4); + assert.equal(brand.display_name,'Default support');assert.deepEqual(brand.shortcuts,[]); + assert.ok(f.nodes.get('#login'),'Successful setup returns to MFA login'); + + await vm.runInContext('screens.branding()',f.context); + v.set('customer_name','Updated customer care');v.set('unattended','on');v.delete('paused'); + await submit(f.nodes.get('#branding'),v); + const edited=f.requests.find(r=>r.url==='/api/v1/profile'&&r.options.method==='PUT').body; + assert.equal(edited.customer.display_name,'Updated customer care');assert.equal(edited.technician.display_name,'Example service desk');assert.equal(edited.allow_unattended,true);assert.equal(edited.updates_paused,false); + + const invalid=await fixture(),bad=values();bad.delete('customer_shortcut_url'); + await submit(invalid.nodes.get('#setup'),bad); + assert.equal(invalid.requests.filter(r=>r.url==='/api/v1/setup').length,0,'Incomplete shortcuts must not create a company'); + assert.match(invalid.nodes.get('#notice').textContent,/complete website shortcuts/); + assert.equal(invalid.nodes.get('#setup').querySelector('button[type=submit]').disabled,false); + console.log('Company setup and profile-editor form contracts passed (not server or browser acceptance).'); +})().catch(error=>{console.error(error);process.exitCode=1;}); diff --git a/deployment/web/test-installation-status.js b/deployment/web/test-installation-status.js new file mode 100644 index 00000000000..8ef42482c7d --- /dev/null +++ b/deployment/web/test-installation-status.js @@ -0,0 +1,64 @@ +// Browser contract checks; real reporting and native update tests remain separate. +'use strict'; +const assert=require('node:assert/strict'); +const fs=require('node:fs'); +const path=require('node:path'); +const vm=require('node:vm'); + +async function main(){ + const nodes=new Map(),timers=new Map(),requests=[]; + let timer=0,hold=false,release,releaseLogout; + class Element{ + addEventListener(){} + set innerHTML(value){this.html=value;for(const match of value.matchAll(/\bid="([^"]+)"/g))nodes.set('#'+match[1],new Element());} + get innerHTML(){return this.html;} + } + for(const id of ['content','notice','title','description','nav','logout'])nodes.set('#'+id,new Element()); + const rows=[ + {owner_name:'',owner_state:'approved',edition:'customer',received_at:0,report:{installed_version:'1.5.7',phase:'downloading',downloaded_bytes:50,total_bytes:100}}, + {owner_name:'Company technician',owner_state:'active',edition:'technician',received_at:Date.now()/1000,report:{installed_version:'1.5.8',phase:'installing',downloaded_bytes:0,total_bytes:null}}, + ]; + const response=value=>({ok:true,json:async()=>value}); + const context=vm.createContext({ + document:{querySelector:selector=>nodes.get(selector)||null,querySelectorAll:()=>[]}, + setTimeout:(callback,delay)=>{assert.equal(delay,10000);timers.set(++timer,callback);return timer;}, + clearTimeout:id=>timers.delete(id), + fetch:async(url,options)=>{ + requests.push({url,options}); + assert.equal(options.headers.Authorization,'Bearer fixture-session'); + if(url==='/api/v1/logout')return new Promise(resolve=>{releaseLogout=()=>resolve(response({ok:true}));}); + assert.match(url,/^\/api\/v1\/installations\?offset=(0|200)$/); + if(hold)return new Promise(resolve=>{release=()=>resolve(response({reports:rows,next_offset:200}));}); + return response(url.endsWith('=0')?{reports:rows,next_offset:200}:{reports:[],next_offset:null}); + }, + }); + const source=fs.readFileSync(path.join(__dirname,'../../product/management/web/app.js'),'utf8').replace(/\r\n/g,'\n'); + assert.ok(source.endsWith('initialize();\n')); + vm.runInContext(source.replace(/initialize\(\);\n$/,''),context); + await vm.runInContext("accessToken='fixture-session';view('installations')",context); + let html=nodes.get('#content').innerHTML; + assert.ok(!html.includes('".into();assert!(brand.validate().is_err()); + let mut profile=profile_fixture();assert!(profile.validate("fixture",1,now()).is_ok()); + assert!(profile.validate("wrong-company",1,now()).is_err());assert!(profile.validate("fixture",2,now()).is_err()); + profile.expires_at=now();assert!(profile.validate("fixture",1,now()).is_err()); +} diff --git a/product/management/src/windows.rs b/product/management/src/windows.rs new file mode 100644 index 00000000000..fe7b0cadf52 --- /dev/null +++ b/product/management/src/windows.rs @@ -0,0 +1,30 @@ +use anyhow::Result; +use std::{ffi::OsString,sync::Mutex,time::Duration}; +use windows_service::{define_windows_service,service::{ServiceControl,ServiceControlAccept,ServiceExitCode,ServiceState,ServiceStatus,ServiceType},service_control_handler::{self,ServiceControlHandlerResult},service_dispatcher}; +const NAME:&str="SwanCompanyServer"; +define_windows_service!(ffi_service_main,service_main); +pub fn dispatch()->Result<()> {service_dispatcher::start(NAME,ffi_service_main)?;Ok(())} +fn service_main(_arguments:Vec) { + let (sender,receiver)=tokio::sync::oneshot::channel();let sender=Mutex::new(Some(sender)); + let handler=move|control|match control { + ServiceControl::Stop|ServiceControl::Shutdown=>{if let Some(sender)=sender.lock().unwrap().take(){let _=sender.send(());}ServiceControlHandlerResult::NoError}, + ServiceControl::Interrogate=>ServiceControlHandlerResult::NoError, + _=>ServiceControlHandlerResult::NotImplemented, + }; + let status_handle=match service_control_handler::register(NAME,handler){Ok(handle)=>handle,Err(error)=>{eprintln!("Service registration failed: {error}");return;}}; + let status=|state,exit|ServiceStatus{service_type:ServiceType::OWN_PROCESS,current_state:state,controls_accepted:if state==ServiceState::Running{ServiceControlAccept::STOP|ServiceControlAccept::SHUTDOWN}else{ServiceControlAccept::empty()},exit_code:ServiceExitCode::Win32(exit),checkpoint:0,wait_hint:Duration::from_secs(10),process_id:None}; + if let Err(error)=status_handle.set_service_status(status(ServiceState::Running,0)){eprintln!("Service status failed: {error}");return;} + // SCM invokes this entrypoint on its own thread; it owns the only server runtime. + let outcome=tokio::runtime::Runtime::new().map_err(anyhow::Error::from).and_then(|runtime|runtime.block_on(async { + match std::env::var("SWAN_COMPONENTS") { + Ok(value) if value=="1"=>{ + let specs=tokio::task::spawn_blocking(super::components::installed_specs).await??; + super::components::supervise(specs,receiver,super::run).await + }, + Err(std::env::VarError::NotPresent)=>super::run(receiver).await, + _=>anyhow::bail!("Invalid company component service mode"), + } + })); + if let Err(error)=&outcome{eprintln!("Company server failed: {error}");} + if let Err(error)=status_handle.set_service_status(status(ServiceState::Stopped,if outcome.is_ok(){0}else{1})){eprintln!("Service shutdown status failed: {error}");} +} diff --git a/product/management/web/app.js b/product/management/web/app.js new file mode 100644 index 00000000000..90cd8d4ab7b --- /dev/null +++ b/product/management/web/app.js @@ -0,0 +1,145 @@ +'use strict'; +let accessToken=''; +let viewGeneration=0,installationTimer=null; +function stopInstallationRefresh(){if(installationTimer!==null){clearTimeout(installationTimer);installationTimer=null;}} +const content=document.querySelector('#content'); +const notice=document.querySelector('#notice'); +const escapeText=value=>String(value??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); +let shortcutCounter=0; +function shortcutRow(edition,shortcut={label:'',url:''}) { + const id=edition+'_shortcut_'+shortcutCounter++; + return `
`; +} +function shortcutFields(edition,shortcuts=[]) { + return `
Website shortcuts
${shortcuts.map(shortcut=>shortcutRow(edition,shortcut)).join('')}
Up to eight website shortcuts. Each shortcut has a name and an HTTPS address.
`; +} +function bindShortcuts(edition) { + const editor=document.querySelector('#'+edition+'_shortcut_editor'); + const rows=editor.querySelector('[data-shortcut-rows]'),add=editor.querySelector('[data-add-shortcut]'); + const refresh=()=>{add.disabled=rows.children.length>=8;}; + add.onclick=()=>{if(rows.children.length>=8)return;rows.insertAdjacentHTML('beforeend',shortcutRow(edition));refresh();}; + rows.onclick=event=>{const button=event.target.closest('[data-remove-shortcut]');if(button&&rows.contains(button)){button.closest('[data-shortcut-row]').remove();refresh();}}; + refresh(); +} +function readShortcuts(values,edition) { + const labels=values.getAll(edition+'_shortcut_label'),urls=values.getAll(edition+'_shortcut_url'); + if(labels.length!==urls.length||labels.length>8)throw new Error('Up to eight complete website shortcuts are allowed.'); + return labels.flatMap((label,index)=>{const url=urls[index];if(!label.trim()&&!url.trim())return [];if(!label.trim()||!url.trim())throw new Error('Each website shortcut needs a name and an HTTPS address.');return [{label,url}];}); +} +async function api(path,method='GET',body,token=accessToken){ + const generation=viewGeneration; + const response=await fetch('/api/v1/'+path,{method,headers:{'Content-Type':'application/json',...(token?{Authorization:'Bearer '+token}:{})},body:body===undefined?undefined:JSON.stringify(body),redirect:'error',cache:'no-store'}); + const result=await response.json();if(generation!==viewGeneration)throw Object.assign(new Error('The page changed before this request finished.'),{superseded:true});if(!response.ok)throw new Error(result.error||'Request failed');return result; +} +function showError(error){if(!error.superseded)notice.textContent=error.message;} +function bindForm(id,callback){document.querySelector('#'+id).addEventListener('submit',async event=>{event.preventDefault();notice.textContent='';const button=event.target.querySelector('button[type=submit]');if(button)button.disabled=true;try{await callback(new FormData(event.target));}catch(error){showError(error);}finally{if(button)button.disabled=false;}});} +function field(name,label,type='text',value=''){return ``;} +function title(text,description=''){document.querySelector('#title').textContent=text;document.querySelector('#description').textContent=description;} +function base32(bytes){let bits=0,value=0,out='';for(const byte of bytes){value=(value<<8)|byte;bits+=8;while(bits>=5){out+='ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'[(value>>>(bits-5))&31];bits-=5;}}if(bits)out+='ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'[(value<<(5-bits))&31];return out;} +function defaultProfile(brand){return {schema:1,company_id:'setup',revision:1,issued_at:0,expires_at:0,management_url:'',rendezvous:'',relay:'',transport_public_key:'',customer:{...brand},technician:{...brand},allow_unattended:true,updates_paused:true,rollout_percent:10,maintenance_start_utc:22,maintenance_end_utc:5,update_channel:'stable',next_profile_public_key:null};} +function profileFields(p){return `

Customer app

${field('customer_name','Display name','text',p.customer.display_name)}${field('customer_color','Primary color','color',p.customer.primary_color)}${field('customer_support','Support HTTPS URL','url',p.customer.support_url||p.management_url)}${shortcutFields('customer',p.customer.shortcuts||[])}

Technician app

${field('technician_name','Display name','text',p.technician.display_name)}${field('technician_color','Primary color','color',p.technician.primary_color)}${field('technician_support','Support HTTPS URL','url',p.technician.support_url||p.management_url)}${shortcutFields('technician',p.technician.shortcuts||[])}
${field('rollout','Rollout percentage','number',p.rollout_percent)}
${field('maintenance_start','Maintenance start hour (UTC, 0–23)','number',p.maintenance_start_utc)}${field('maintenance_end','Maintenance end hour (UTC, 0–23)','number',p.maintenance_end_utc)}
Windows can cross midnight. Equal start and end hours allow updates throughout the day. Releases still require administrator approval.`;} +function bindProfileFields(){for(const edition of ['customer','technician'])bindShortcuts(edition);} +function readProfileFields(p,v){for(const edition of ['customer','technician']){p[edition].display_name=v.get(edition+'_name');p[edition].primary_color=v.get(edition+'_color');p[edition].support_url=v.get(edition+'_support');p[edition].logo_svg=v.get(edition+'_logo');p[edition].consent_text=v.get(edition+'_consent');p[edition].support_contacts=v.get(edition+'_contacts');p[edition].shortcuts=readShortcuts(v,edition);}p.allow_unattended=v.has('unattended');p.updates_paused=v.has('paused');p.rollout_percent=Number(v.get('rollout'));p.update_channel=v.get('update_channel');p.maintenance_start_utc=Number(v.get('maintenance_start'));p.maintenance_end_utc=Number(v.get('maintenance_end'));} +async function initialize(){try{const state=await api('status');if(!state.configured)setupScreen(await api('default-branding'));else loginScreen();}catch(error){showError(error);}} +function setupScreen(brand){ + title('Set up your company server','Use your public HTTPS domain and a publicly reachable rendezvous and relay server.'); + const secret=base32(crypto.getRandomValues(new Uint8Array(20))); + const profile=defaultProfile(brand); + content.innerHTML=`

Company and network

${field('url','Management HTTPS URL','url','https://support.example.com')}${field('rendezvous','ID server hostname','text','support.example.com')}${field('relay','Relay server hostname','text','support.example.com')}${field('key','RustDesk server public key')}
${field('setup_token','Setup token from the server data directory','password')}${field('username','Administrator username')}${field('password','Administrator password (at least 14 characters)','password')}${escapeText(secret)}Add this secret to your authenticator, then enter its current code. Store recovery access to the server separately.${field('code','Authenticator code','text')}

HTTPS is provided by the deployment reverse proxy. Clients need TCP 21115–21117 and UDP 21116 to the transport server. An office connection behind CGNAT needs a public relay. This form does not expose or change your firewall.

Endpoint branding and policies

Choose the identity customers and technicians see. New devices require company approval. Allowing unattended support still requires each customer to opt in.

${profileFields(profile)}
`; + bindProfileFields(); + bindForm('setup',async values=>{readProfileFields(profile,values);profile.management_url=values.get('url').replace(/\/$/,'');profile.rendezvous=values.get('rendezvous');profile.relay=values.get('relay');profile.transport_public_key=values.get('key');await api('setup','POST',{profile,username:values.get('username'),password:values.get('password'),totp_secret:secret,totp_code:values.get('code')},values.get('setup_token'));loginScreen();notice.textContent='Server configured. Wait for the next authenticator code, then sign in.';}); +} +function loginScreen(){viewGeneration++;stopInstallationRefresh();title('Sign in to your company server','Administrator and technician accounts require an authenticator code.');document.querySelector('#nav').hidden=true;content.innerHTML=`
${field('username','Username')}${field('password','Password','password')}${field('code','Authenticator code')}

Your session stays in this page’s memory and expires after one hour.

`;bindForm('login',async values=>{const login=await api('login','POST',{username:values.get('username'),password:values.get('password'),totp_code:values.get('code')});accessToken=login.token;document.querySelector('#nav').hidden=false;await view('devices');});} +async function view(name){const generation=++viewGeneration;stopInstallationRefresh();notice.textContent='';for(const button of document.querySelectorAll('[data-view]'))button.classList.toggle('active',button.dataset.view===name);try{await screens[name](generation);}catch(error){if(generation===viewGeneration)showError(error);}} +const installationPhases={current:'Installed',checking:'Checking for updates',downloading:'Downloading update',preparing:'Preparing update',installing:'Installing update',recovering:'Recovering update',deferred:'Update deferred',failed:'Update needs attention'}; +function installationRows(rows){ + return rows.map(row=>{ + const report=row.report||{},received=Number(row.received_at),date=new Date(received*1000); + const stamp=Number.isFinite(received)&&!Number.isNaN(date.getTime())?date.toLocaleString():'Unknown'; + const old=Number.isFinite(received)&&Date.now()/1000-received>900; + const total=report.total_bytes,done=report.downloaded_bytes; + const progress=['downloading','preparing'].includes(report.phase)&&Number.isSafeInteger(total)&&total>0&&total<=4294967296&&Number.isSafeInteger(done)&&done>=0&&done<=total?`${Math.floor(done*100/total)}%`:''; + return `${escapeText(row.owner_name)}${row.edition==='technician'?'Technician app':'Customer app'} · ${escapeText(row.owner_state)}${escapeText(report.installed_version||'Not yet recorded')}${escapeText(installationPhases[report.phase]||'Unknown update status')}${progress}${escapeText(stamp)}${old?'Report is over 15 minutes old':''}`; + }).join(''); +} +const screens={ + async installations(generation){ + title('Installed versions and updates','Versions and progress reported by your company’s apps. This page refreshes every 10 seconds.'); + let requestNumber=0; + const load=async offset=>{ + if(generation!==viewGeneration||!accessToken)return; + const number=++requestNumber;stopInstallationRefresh(); + const page=await api('installations?offset='+encodeURIComponent(offset)); + if(generation!==viewGeneration||number!==requestNumber||!accessToken)return; + const next=Number.isSafeInteger(page.next_offset)&&page.next_offset>=0?page.next_offset:null; + content.innerHTML=`
${installationRows(page.reports)}
InstallationReported versionUpdate statusLast report
${page.reports.length?'':'

No installation reports yet. Apps report after enrollment or technician sign-in.

'}
`; + const failed=error=>{if(generation===viewGeneration&&accessToken)showError(error);}; + document.querySelector('#installation-refresh').onclick=()=>load(0).catch(failed); + document.querySelector('#installation-next').onclick=()=>{if(next!==null)return load(next).catch(failed);}; + installationTimer=setTimeout(()=>{installationTimer=null;load(offset).catch(failed);},10000); + }; + await load(0); + }, + async network(){ + title('Company network','Configure company-owned public endpoints and check them from this server. External access needs a separate test.'); + const envelope=await api('profile'); + const profile=JSON.parse(new TextDecoder().decode(Uint8Array.from(atob(envelope.payload),c=>c.charCodeAt(0)))); + content.innerHTML=`

Transport and management

${field('management-url','Management HTTPS URL','url',profile.management_url)}${field('rendezvous-host','ID server hostname and optional port','text',profile.rendezvous)}${field('relay-host','Relay hostname and optional port','text',profile.relay)}${field('transport-key','Public transport trust key','text',profile.transport_public_key)}

These settings publish signed client configuration. DNS, HTTPS certificates, server processes and port forwarding must also match. Changing the URL does not move this server.

Reachability check

This check uses the saved company configuration. It can detect DNS, TCP and management trust problems from this server, and checks a native UDP response. Outside access and NAT traversal require separate tests.

Check from a separate connection

Copy the public network configuration and the matching swan-management executable to a computer on a separate Internet connection.

swan-management check-network company-network-bootstrap.json --outside-network

The checker reports connectivity from that computer. Confirm its location yourself and test actual direct and relay support sessions before distribution.

Office hosting behind CGNAT may need a company-controlled public relay. Test direct and relay sessions externally before publishing customer packages. HTTPS-only corporate proxies are not guaranteed.

`; + bindForm('network',async values=>{ + profile.management_url=String(values.get('management-url')).replace(/\/$/,'');profile.rendezvous=String(values.get('rendezvous-host'));profile.relay=String(values.get('relay-host'));profile.transport_public_key=String(values.get('transport-key')); + await api('profile','PUT',profile);notice.textContent='Signed network configuration published. Check the saved endpoints before distribution.'; + }); + document.querySelector('#download-network-bootstrap').onclick=async event=>{ + event.target.disabled=true; + try { + const bootstrap=await api('network/bootstrap'); + const url=URL.createObjectURL(new Blob([JSON.stringify(bootstrap,null,2)],{type:'application/json'})); + const link=document.createElement('a');link.href=url;link.download='company-network-bootstrap.json';link.click(); + setTimeout(()=>URL.revokeObjectURL(url),1000); + notice.textContent='Public network configuration downloaded. Run the check from a separate Internet connection.'; + } catch(error){showError(error);} finally {event.target.disabled=false;} + }; + document.querySelector('#check-network').onclick=async event=>{ + event.target.disabled=true; + try { + const result=await api('network/check','POST'); + document.querySelector('#network-result').innerHTML=`

Vantage point: this company server. Management HTTPS and company signature: ${result.https_and_company_signature_valid?'verified':'failed'}.

${result.tcp_checks.map(check=>``).join('')}
EndpointResultResolved addresses
${escapeText(check.name)}${escapeText(check.host)}:${escapeText(check.port)}${escapeText(check.result)}${check.addresses.map(address=>`${escapeText(address.ip)} (${escapeText(address.classification)})`).join('
')}

Rendezvous UDP: ${result.udp_check?.reachable?'native response received':'failed or no response'}. This result is from the company server.

${escapeText(result.guidance)}

`; + } catch(error){showError(error);} finally {event.target.disabled=false;} + }; + }, + async permissions(){ + title('Group session permissions','Set what technicians may do on devices in each group. Reducing permissions prevents existing sessions from renewing their authorization.'); + const capabilities={keyboard:'Keyboard and mouse control',clipboard:'Clipboard',audio:'Audio',file:'File transfer',restart:'Restart device',recording:'Session recording',block_input:'Block customer input',privacy_mode:'Privacy mode'}; + content.innerHTML=`
${field('policy-group','Device group','text','default')}
`; + bindForm('select-policy',async values=>{ + const group=String(values.get('policy-group')).trim(); + const policy=await api('groups/'+encodeURIComponent(group)+'/permissions'); + document.querySelector('#policy').innerHTML=`

${escapeText(group)}

Desktop viewing requires device approval and technician group access. The permissions below limit additional actions.

${Object.entries(capabilities).map(([key,label])=>``).join('')}
`; + bindForm('save-policy',async selected=>{ + const updated=Object.fromEntries(Object.keys(capabilities).map(key=>[key,selected.has(key)])); + await api('groups/'+encodeURIComponent(group)+'/permissions','PUT',updated); + notice.textContent='Permissions saved. New grants use this policy; existing sessions must reauthorize within five minutes.'; + }); + }); + }, + async devices(){title('Devices','Approve new devices and choose which support group can access them.');const devices=await api('devices');content.innerHTML='
'+devices.map(d=>``).join('')+'
DeviceStatusAccessActions
${escapeText(d.name)}${escapeText(d.rustdesk_id)}${escapeText(d.state)}${escapeText(d.group)}${d.unattended?'Unattended consent recorded':'Customer approval required'}

Only enrolled and approved devices are available to authorized technicians. Use the technician app for remote sessions.

'; + for(const button of document.querySelectorAll('[data-approve]'))button.onclick=async()=>{const group=prompt('Device group','default');if(!group)return;try{await api('devices/'+encodeURIComponent(button.dataset.approve)+'/state','PUT',{state:'approved',group});await view('devices');}catch(error){showError(error);}}; + for(const button of document.querySelectorAll('[data-revoke]'))button.onclick=async()=>{try{await api('devices/'+encodeURIComponent(button.dataset.revoke)+'/state','PUT',{state:'revoked',group:'default'});await view('devices');}catch(error){showError(error);}}; + }, + async branding(){ + title('Branding and policies','Company changes synchronize with your installed customer and technician apps.'); + const envelope=await api('profile');const p=JSON.parse(new TextDecoder().decode(Uint8Array.from(atob(envelope.payload),c=>c.charCodeAt(0)))); + content.innerHTML=`
${profileFields(p)}
`; + bindProfileFields();bindForm('branding',async v=>{readProfileFields(p,v);await api('profile','PUT',p);notice.textContent='Company profile published.';}); + }, + async users(){title('Technicians and access','Every technician needs MFA and explicit device-group access.');const users=await api('users');content.innerHTML=`
${users.map(u=>``).join('')}
UsernameRoleIdentifierAccount access
${escapeText(u.username)}${u.disabled?' (disabled)':''}${escapeText(u.role)}${escapeText(u.id)}${u.disabled?'Disabled':``}

Add technician

${field('username','Username')}${field('password','Initial password (14+ characters)','password')}

Manage group access

${field('user_id','Technician identifier')}${field('group','Device group','text','default')}
`;bindForm('user',async v=>{const result=await api('users','POST',{username:v.get('username'),password:v.get('password'),role:'technician'});document.querySelector('#secret').textContent='Transfer privately: '+JSON.stringify(result);});bindForm('access',async v=>{await api('groups/'+encodeURIComponent(v.get('group'))+'/users/'+encodeURIComponent(v.get('user_id')),v.get('access_action'));notice.textContent=v.get('access_action')==='DELETE'?'Group access revoked. Existing sessions cannot renew authorization.':'Group access granted.';});for(const button of document.querySelectorAll('[data-disable-user]'))button.onclick=async()=>{button.disabled=true;try{await api('users/'+encodeURIComponent(button.dataset.disableUser)+'/disable','POST');await view('users');notice.textContent='Account disabled. New access and authorization renewals are denied.';}catch(error){showError(error);button.disabled=false;}}; + }, + async releases(){title('Software releases','Import verified project releases, then approve them for company rollout.');const rows=await api('releases');content.innerHTML=`

Import signed release

${field('public_key','Configured project release public key')}
${rows.map(r=>`

${escapeText(r.id)} ${escapeText(r.data.version)} · ${escapeText(r.data.edition)} · ${r.approved?`Approved `:``}

`).join('')||'No releases imported.'}
`;bindForm('release',async v=>{await api('releases','POST',{public_key:v.get('public_key'),envelope:JSON.parse(v.get('envelope'))});await view('releases');});for(const button of document.querySelectorAll('[data-release]'))button.onclick=async()=>{try{await api('releases/'+encodeURIComponent(button.dataset.release)+'/approve','POST');await view('releases');}catch(error){showError(error);}};for(const button of document.querySelectorAll('[data-withdraw]'))button.onclick=async()=>{try{await api('releases/'+encodeURIComponent(button.dataset.withdraw)+'/approve','DELETE');await view('releases');}catch(error){showError(error);}}; + }, + async builds(){title('Installer generation','Your Windows worker creates company bundles from approved signed releases.');const [rows,workers]=await Promise.all([api('builds'),api('workers')]);content.innerHTML=`

Generate installer bundle

${field('release_id','Approved release identifier')}

Connect a Windows worker

Run swan-worker on a dedicated Windows machine. Keep its enrollment token private.

Registered workers

${workers.map(w=>`

${escapeText(w.worker_id)} ${w.disabled?'Disabled':``}

`).join('')||'

No workers registered.

'}Revocation cancels unfinished jobs. Completed bundles remain available until their release is withdrawn.
${rows.map(r=>``).join('')}
JobStatusResult
${escapeText(r.id)}${escapeText(r.state)}
${escapeText(r.result)}
${r.state==='completed'?`Website link: /api/v1/downloads/${escapeText(r.id)}`:''}
`;bindForm('build',async v=>{await api('builds','POST',{release_id:v.get('release_id')});await view('builds');});for(const button of document.querySelectorAll('[data-download]'))button.onclick=async()=>{try{const response=await fetch('/api/v1/downloads/'+encodeURIComponent(button.dataset.download),{headers:{Authorization:'Bearer '+accessToken},redirect:'error'});if(!response.ok)throw new Error('Bundle download failed');const url=URL.createObjectURL(await response.blob());const link=document.createElement('a');link.href=url;link.download='SwanRemoteSupport-company.zip';link.click();setTimeout(()=>URL.revokeObjectURL(url),1000);}catch(error){showError(error);}};for(const button of document.querySelectorAll('[data-revoke-worker]'))button.onclick=async()=>{button.disabled=true;try{await api('workers/'+encodeURIComponent(button.dataset.revokeWorker),'DELETE');await view('builds');notice.textContent='Worker revoked. Its unfinished builds were cancelled.';}catch(error){showError(error);button.disabled=false;}};document.querySelector('#worker').onclick=async()=>{try{document.querySelector('#worker_secret').textContent=JSON.stringify(await api('workers','POST'),null,2);}catch(error){showError(error);}}; + }, + async audit(){title('Audit history','Account, enrollment, permission, release and session events.');const rows=await api('audit');content.innerHTML=`
${rows.map(r=>``).join('')}
TimeEventActorTarget
${escapeText(new Date(r.at*1000).toISOString())}${escapeText(r.event)}${escapeText(r.actor)}${escapeText(r.target)}
`;} +}; +for(const button of document.querySelectorAll('[data-view]'))button.onclick=()=>view(button.dataset.view); +document.querySelector('#logout').onclick=async()=>{const token=accessToken;accessToken='';loginScreen();try{await api('logout','POST',undefined,token);}catch{}}; +initialize(); diff --git a/product/management/web/index.html b/product/management/web/index.html new file mode 100644 index 00000000000..18c0db18540 --- /dev/null +++ b/product/management/web/index.html @@ -0,0 +1,3 @@ +Swan Remote Support — Company server + +

YOUR SUPPORT INFRASTRUCTURE

Welcome to Swan Remote Support

Configure your company once. Keep your devices and support team connected.

diff --git a/product/management/web/style.css b/product/management/web/style.css new file mode 100644 index 00000000000..e051cd9487d --- /dev/null +++ b/product/management/web/style.css @@ -0,0 +1,3 @@ +:root{font-family:Segoe UI,system-ui,sans-serif;color:#183140;background:#f3f6f7;font-synthesis:none}*{box-sizing:border-box}body{margin:0;display:flex;min-height:100vh}aside{width:252px;position:fixed;inset:0 auto 0 0;padding:32px 24px;background:#007f82;color:white;display:flex;flex-direction:column}.brand{display:flex;gap:13px;align-items:center;font-size:19px;line-height:1.35}.brand strong{font-size:17px}.mark{height:49px;width:49px;flex-shrink:0}.subtitle{font-size:11px;letter-spacing:2px;color:#b1d0da;margin:28px 0 20px}nav button{display:block;width:100%;padding:13px 12px;margin:4px 0;text-align:left;background:transparent;color:#e3f2f5;border:none}nav button:hover,nav button.active{background:#ffffff1c}footer{margin-top:auto;color:#b1d0da;font-size:12px;line-height:1.8}main{margin-left:252px;padding:45px 55px;width:calc(100% - 252px);max-width:1450px}.eyebrow{font-size:11px;letter-spacing:2px;color:#567a8e}h1{font-size:32px;font-weight:600;margin:12px 0}header>p:last-child{color:#617b89;line-height:1.6}section{margin-top:28px}.card{background:white;border:1px solid #dce6ea;border-radius:12px;padding:28px;margin:18px 0;box-shadow:0 5px 16px #00375205}.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(250px,1fr));gap:20px}h2{font-size:20px;margin-top:0}label{display:block;font-size:13px;font-weight:600;margin:18px 0 7px}input,textarea,select{display:block;width:100%;padding:12px;border:1px solid #c9d9e0;border-radius:6px;font:inherit;background:#fff}textarea{min-height:260px;font-family:Consolas,monospace;font-size:13px}button{background:#007f82;color:white;border:0;border-radius:6px;padding:12px 18px;font:inherit;cursor:pointer}button.secondary{background:#e8f0f3;color:#007f82}button.danger{background:#9a3333}button:disabled{opacity:.5;cursor:wait}form>button{margin-top:22px}.actions{display:flex;gap:8px;flex-wrap:wrap}.muted{color:#607b89;font-size:13px;line-height:1.6}.badge{display:inline-block;background:#e8f2f4;color:#005374;padding:5px 10px;border-radius:20px;font-size:12px}table{border-collapse:collapse;width:100%;font-size:14px}th,td{text-align:left;border-bottom:1px solid #e3ebee;padding:14px 8px;vertical-align:top}th{color:#67818e;font-weight:500}code,pre{overflow-wrap:anywhere;white-space:pre-wrap;background:#eef3f5;padding:8px;border-radius:5px;font-size:12px}#notice:not(:empty){padding:15px;border-radius:8px;background:#fff3db;border:1px solid #ead6af}small{display:block;margin-top:7px;color:#6b7e88}.check{display:flex;align-items:center;gap:10px}.check input{width:auto}a{color:#006b88}@media(max-width:800px){aside{position:static;width:190px;padding:20px 12px}main{margin:0;width:calc(100% - 190px);padding:25px 20px}.grid{display:block}table{display:block;overflow:auto}}@media(max-width:560px){body{display:block}aside{width:100%;position:static}nav{display:flex;flex-wrap:wrap}nav button{width:auto;padding:8px}.subtitle,footer{display:none}main{width:100%;padding:24px}h1{font-size:26px}} +.shortcut-editor{margin-top:20px;padding:16px;border:1px solid #c9d9e0;border-radius:6px}.shortcut-editor legend{padding:0 6px;font-size:13px;font-weight:600}.shortcut-row{padding-bottom:18px;margin-bottom:18px;border-bottom:1px solid #e3ebee}.shortcut-row label:first-child{margin-top:0}.shortcut-row button{margin-top:12px} +input[type=color]{height:46px;padding:6px;cursor:pointer} diff --git a/product/protocol/Cargo.toml b/product/protocol/Cargo.toml new file mode 100644 index 00000000000..9a17c042dcb --- /dev/null +++ b/product/protocol/Cargo.toml @@ -0,0 +1,18 @@ +[package] +workspace = ".." +name = "swan-protocol" +version.workspace = true +edition.workspace = true +license.workspace = true +rust-version.workspace = true + +[dependencies] +anyhow.workspace = true +serde.workspace = true +serde_json.workspace = true +base64.workspace = true +ed25519-dalek.workspace = true +rand.workspace = true +sha2.workspace = true +hex.workspace = true +url.workspace = true diff --git a/product/protocol/src/installation.rs b/product/protocol/src/installation.rs new file mode 100644 index 00000000000..a8746e35fb7 --- /dev/null +++ b/product/protocol/src/installation.rs @@ -0,0 +1,79 @@ +//! Installation diagnostics are authenticated reports, never authorization or +//! proof of installed bytes. Signed release verification remains mandatory. +use crate::*; + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum UpdatePhase { Current, Checking, Downloading, Preparing, Installing, Recovering, Deferred, Failed } + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct InstallationIdentity { + pub edition: Edition, + pub installation_id: String, +} +impl InstallationIdentity { + pub fn validate(&self) -> Result<()> { + let bytes = self.installation_id.as_bytes(); + ensure!(bytes.len() == 36 && bytes.iter().enumerate().all(|(index, byte)| { + if [8, 13, 18, 23].contains(&index) { *byte == b'-' } + else { byte.is_ascii_digit() || (b'a'..=b'f').contains(byte) } + }), "Expected canonical installation UUID"); + Ok(()) + } +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct InstallationReport { + pub schema: u32, + pub identity: InstallationIdentity, + /// Persisted by the reporter; equal revisions are idempotent, older ones fail. + pub revision: u64, + pub installed_sequence: u64, + pub installed_version: Option, + pub phase: UpdatePhase, + pub target_sequence: Option, + pub downloaded_bytes: u64, + pub total_bytes: Option, +} +impl InstallationReport { + pub fn validate(&self) -> Result<()> { + ensure!(self.schema == 1, "Unsupported installation report schema"); + self.identity.validate()?; + ensure!(self.revision > 0 && self.revision <= i64::MAX as u64, "Invalid installation report revision"); + ensure!((self.installed_sequence == 0) == self.installed_version.is_none(), "Installed version and sequence disagree"); + if let Some(version) = &self.installed_version { + let parts: Vec<_> = version.split('.').collect(); + ensure!(version.len() <= 32 && parts.len() == 3 && parts.iter().all(|part| !part.is_empty() && part.bytes().all(|byte| byte.is_ascii_digit())), "Invalid reported version"); + } + ensure!(!matches!(self.phase, UpdatePhase::Current) || self.installed_sequence > 0, "Installed state requires a recorded version"); + ensure!(self.target_sequence != Some(0), "Invalid target sequence"); + ensure!(!matches!(self.phase, UpdatePhase::Downloading | UpdatePhase::Preparing | UpdatePhase::Installing | UpdatePhase::Recovering) || self.target_sequence.is_some(), "Update phase requires a target release"); + const LIMIT: u64 = 4 * 1024 * 1024 * 1024; + ensure!(self.downloaded_bytes <= LIMIT && self.total_bytes.is_none_or(|total| total <= LIMIT && total >= self.downloaded_bytes), "Invalid download progress"); + ensure!(matches!(self.phase, UpdatePhase::Downloading | UpdatePhase::Preparing) || (self.downloaded_bytes == 0 && self.total_bytes.is_none()), "Byte progress requires a download phase"); + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn reports_reject_unbounded_or_conflicting_status_and_active_content() { + let report = InstallationReport { schema: 1, identity: InstallationIdentity { edition: Edition::Customer, installation_id: "a0000000-0000-4000-8000-000000000001".into() }, revision: 1, installed_sequence: 9, installed_version: Some("1.5.7".into()), phase: UpdatePhase::Downloading, target_sequence: Some(10), downloaded_bytes: 25, total_bytes: Some(100) }; + report.validate().unwrap(); + for invalid in ["../another-device", "A0000000-0000-4000-8000-000000000001", "a0000000000040008000000000000001"] { + let mut bad = report.clone(); bad.identity.installation_id = invalid.into(); assert!(bad.validate().is_err()); + } + let mut bad = report.clone(); bad.installed_version = Some("