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 `Name HTTPS address Remove shortcut
`;
+}
+function shortcutFields(edition,shortcuts=[]) {
+ return `Website shortcuts ${shortcuts.map(shortcut=>shortcutRow(edition,shortcut)).join('')}
Add shortcut 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 `${label} `;}
+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)}Support contact details ${shortcutFields('customer',p.customer.shortcuts||[])}Consent wording Logo SVG (optional)
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)}Support contact details ${shortcutFields('technician',p.technician.shortcuts||[])}Consent wording Logo SVG (optional) Allow customers to opt into unattended support Pause automatic updates ${field('rollout','Rollout percentage','number',p.rollout_percent)}Update channel Stable Test
${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=``;
+ 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=``;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=`Refresh latest Next page
Installation Reported version Update status Last report ${installationRows(page.reports)}
${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=`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 saved endpoints Check from a separate connection Copy the public network configuration and the matching swan-management executable to a computer on a separate Internet connection.
Network configuration for outside checks 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'}.
Endpoint Result Resolved addresses ${result.tcp_checks.map(check=>`${escapeText(check.name)}${escapeText(check.host)}:${escapeText(check.port)} ${escapeText(check.result)} ${check.addresses.map(address=>`${escapeText(address.ip)} (${escapeText(address.classification)})`).join(' ')} `).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=`
`;
+ 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=``;
+ 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='Device Status Access Actions '+devices.map(d=>`${escapeText(d.name)}${escapeText(d.rustdesk_id)} ${escapeText(d.state)} ${escapeText(d.group)}${d.unattended?'Unattended consent recorded':'Customer approval required'} Approve / group Revoke
`).join('')+'
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=``;
+ 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=`Username Role Identifier Account access ${users.map(u=>`${escapeText(u.username)}${u.disabled?' (disabled)':''} ${escapeText(u.role)} ${escapeText(u.id)}${u.disabled?'Disabled':`Disable account `} `).join('')}
`;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=`${rows.map(r=>`
${escapeText(r.id)} ${escapeText(r.data.version)} · ${escapeText(r.data.edition)} · ${r.approved?`Approved Withdraw `:`Approve `}
`).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')}Queue generation Connect a Windows worker Run swan-worker on a dedicated Windows machine. Keep its enrollment token private.
Create worker credential Registered workers ${workers.map(w=>`
${escapeText(w.worker_id)} ${w.disabled?'Disabled':`Revoke worker `}
`).join('')||'
No workers registered.
'}
Revocation cancels unfinished jobs. Completed bundles remain available until their release is withdrawn. Job Status Result ${rows.map(r=>`${escapeText(r.id)} ${escapeText(r.state)} ${escapeText(r.result)} ${r.state==='completed'?`Download bundle Website link: /api/v1/downloads/${escapeText(r.id)} `:''} `).join('')}
`;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=`Time Event Actor Target ${rows.map(r=>`${escapeText(new Date(r.at*1000).toISOString())} ${escapeText(r.event)} ${escapeText(r.actor)} ${escapeText(r.target)} `).join('')}
`;}
+};
+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
+SwanRemote Support
COMPANY SERVER
Devices Update status Branding & policies Company network Technicians & access Session permissions Software releases Installer generation Audit history Sign out Open source · AGPL-3.0 Powered by RustDesk
+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("