From 2cd0b2222418d0c420f254d3bc019b38f6c1907d Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Thu, 1 Oct 2026 12:15:46 +0200 Subject: [PATCH 001/652] Implement company management, trust, worker and managed update development flows --- .github/workflows/company-product.yml | 23 + .github/workflows/swan-windows-build.yml | 68 +- .gitignore | 7 + Cargo.toml | 5 +- branding/swan-support-mark.svg | 1 + build.rs | 34 - deployment/linux/Caddyfile | 7 + deployment/linux/Dockerfile | 15 + deployment/linux/company.env.example | 3 + deployment/linux/compose.yaml | 48 + deployment/transport-source.json | 7 + deployment/windows/Install-Company.ps1 | 62 + deployment/windows/Install-Server.ps1 | 25 + deployment/windows/LOCAL-TESTING.md | 11 + deployment/windows/Open-Technician.ps1 | 40 + deployment/windows/Start-LocalTest.ps1 | 24 + deployment/windows/Verify-Package.ps1 | 10 + deployment/windows/local-test.env.example | 5 + docs/PRODUCT_COMPLETION_ASSESSMENT.md | 117 + .../lib/desktop/pages/desktop_home_page.dart | 33 +- product/Cargo.lock | 2279 +++++++++++++++++ product/Cargo.toml | 23 + product/README.md | 36 + product/agent/Cargo.toml | 24 + product/agent/src/lib.rs | 204 ++ product/agent/src/main.rs | 88 + product/agent/src/update.rs | 133 + product/management/Cargo.toml | 34 + product/management/src/backup.rs | 75 + product/management/src/main.rs | 40 + product/management/src/schema.sql | 11 + product/management/src/service.rs | 469 ++++ product/management/src/tests.rs | 73 + product/management/src/windows.rs | 21 + product/management/web/app.js | 42 + product/management/web/index.html | 3 + product/management/web/style.css | 1 + product/protocol/Cargo.toml | 17 + product/protocol/src/lib.rs | 257 ++ product/worker/Cargo.toml | 15 + product/worker/src/main.rs | 92 + src/client.rs | 9 + src/common.rs | 81 +- src/core_main.rs | 2 + src/flutter_ffi.rs | 11 + src/ipc.rs | 24 + src/lib.rs | 2 + src/managed.rs | 72 + src/server/connection.rs | 49 + 49 files changed, 4555 insertions(+), 177 deletions(-) create mode 100644 .github/workflows/company-product.yml create mode 100644 branding/swan-support-mark.svg create mode 100644 deployment/linux/Caddyfile create mode 100644 deployment/linux/Dockerfile create mode 100644 deployment/linux/company.env.example create mode 100644 deployment/linux/compose.yaml create mode 100644 deployment/transport-source.json create mode 100644 deployment/windows/Install-Company.ps1 create mode 100644 deployment/windows/Install-Server.ps1 create mode 100644 deployment/windows/LOCAL-TESTING.md create mode 100644 deployment/windows/Open-Technician.ps1 create mode 100644 deployment/windows/Start-LocalTest.ps1 create mode 100644 deployment/windows/Verify-Package.ps1 create mode 100644 deployment/windows/local-test.env.example create mode 100644 docs/PRODUCT_COMPLETION_ASSESSMENT.md create mode 100644 product/Cargo.lock create mode 100644 product/Cargo.toml create mode 100644 product/README.md create mode 100644 product/agent/Cargo.toml create mode 100644 product/agent/src/lib.rs create mode 100644 product/agent/src/main.rs create mode 100644 product/agent/src/update.rs create mode 100644 product/management/Cargo.toml create mode 100644 product/management/src/backup.rs create mode 100644 product/management/src/main.rs create mode 100644 product/management/src/schema.sql create mode 100644 product/management/src/service.rs create mode 100644 product/management/src/tests.rs create mode 100644 product/management/src/windows.rs create mode 100644 product/management/web/app.js create mode 100644 product/management/web/index.html create mode 100644 product/management/web/style.css create mode 100644 product/protocol/Cargo.toml create mode 100644 product/protocol/src/lib.rs create mode 100644 product/worker/Cargo.toml create mode 100644 product/worker/src/main.rs create mode 100644 src/managed.rs diff --git a/.github/workflows/company-product.yml b/.github/workflows/company-product.yml new file mode 100644 index 00000000000..1419fe0e5e5 --- /dev/null +++ b/.github/workflows/company-product.yml @@ -0,0 +1,23 @@ +name: Company product components +on: + workflow_dispatch: + push: + paths: ['product/**', 'deployment/**', '.github/workflows/company-product.yml'] + pull_request: + paths: ['product/**', 'deployment/**', '.github/workflows/company-product.yml'] +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 security and management + run: cargo +1.90.0 test --manifest-path product/Cargo.toml --workspace --locked + - name: Build components + run: cargo +1.90.0 build --manifest-path product/Cargo.toml --workspace --release --locked diff --git a/.github/workflows/swan-windows-build.yml b/.github/workflows/swan-windows-build.yml index 2a2af7ebdcb..4f0e20aaa43 100644 --- a/.github/workflows/swan-windows-build.yml +++ b/.github/workflows/swan-windows-build.yml @@ -7,7 +7,7 @@ permissions: contents: read env: - RUST_VERSION: "1.75" + RUST_VERSION: "1.90.0" LLVM_VERSION: "15.0.6" FLUTTER_VERSION: "3.24.5" VCPKG_COMMIT_ID: "120deac3062162151622ca4860575a33844ba10b" @@ -33,12 +33,6 @@ jobs: 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,35 +46,6 @@ jobs: with: submodules: recursive - - name: Validate public server configuration - 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 (-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." - } - } - Write-Output "Public Swan server configuration passed structural validation." - - name: Restore Flutter bridge files uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: @@ -180,19 +145,13 @@ jobs: sha256sum SwanRemoteSupport-*.exe | tee SHA256SUMS.txt popd - - name: Add express installation helpers + - name: Build company management and distribution components 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\ + 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/Verify-Package.ps1 artifacts/ + Copy-Item LICENCE artifacts/ - name: Upload unsigned installer and checksum uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 @@ -202,13 +161,8 @@ jobs: path: | artifacts/SwanRemoteSupport-${{ env.VERSION }}-x64-unsigned-install.exe 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/Open-Technician.ps1 + artifacts/Verify-Package.ps1 + artifacts/LICENCE 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.toml b/Cargo.toml index 509d2e07efa..8bb91d9e0bf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,7 +6,7 @@ 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" } diff --git a/branding/swan-support-mark.svg b/branding/swan-support-mark.svg new file mode 100644 index 00000000000..7a04bfff239 --- /dev/null +++ b/branding/swan-support-mark.svg @@ -0,0 +1 @@ + diff --git a/build.rs b/build.rs index bf38b320a51..92fb1f4b452 100644 --- a/build.rs +++ b/build.rs @@ -77,41 +77,7 @@ 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; - } - - // 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}"); - } - - println!("cargo:rerun-if-env-changed=API_SERVER"); - println!( - "cargo:rustc-env=API_SERVER={}", - std::env::var("API_SERVER").unwrap_or_default() - ); -} - fn main() { - validate_swan_build_environment(); hbb_common::gen_version(); install_android_deps(); #[cfg(all(windows, feature = "inline"))] 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..9e9522a4931 --- /dev/null +++ b/deployment/linux/Dockerfile @@ -0,0 +1,15 @@ +FROM rust:1.90.0-bookworm AS builder +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 +RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates && rm -rf /var/lib/apt/lists/* && 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 +EXPOSE 8080 +ENTRYPOINT ["swan-management"] 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.yaml b/deployment/linux/compose.yaml new file mode 100644 index 00000000000..f92e17d1c1d --- /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 + 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/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/windows/Install-Company.ps1 b/deployment/windows/Install-Company.ps1 new file mode 100644 index 00000000000..ce69e079707 --- /dev/null +++ b/deployment/windows/Install-Company.ps1 @@ -0,0 +1,62 @@ +[CmdletBinding()] +param([switch]$UnattendedConsent) +$ErrorActionPreference = 'Stop' +$root = $PSScriptRoot +$bootstrap = Get-Content -LiteralPath (Join-Path $root 'bootstrap.json') -Raw | ConvertFrom-Json +if ($bootstrap.edition -notin @('customer','technician')) { throw 'Unknown package edition.' } +$domain = [uri]$bootstrap.management_url +if ($domain.Scheme -ne 'https' -or $domain.UserInfo -or -not $domain.Host) { throw 'A company HTTPS endpoint is required.' } +Write-Host "Company server: $($domain.AbsoluteUri)" +Write-Host 'Confirm this is the support company you intended to install. The company must approve your device before remote access.' +if ((Read-Host 'Type the company server hostname to confirm') -cne $domain.Host) { throw 'Company verification cancelled.' } +if ($UnattendedConsent) { + Write-Host 'Unattended access allows approved company technicians to connect while you are absent, including the sign-in screen.' + if ((Read-Host 'Type ALLOW ONGOING SUPPORT to consent') -cne 'ALLOW ONGOING SUPPORT') { throw 'Ongoing-access consent not granted.' } +} +$agent = Join-Path $root 'swan-agent.exe' +# The executable verifies signed company profiles itself. Never use unsigned setup files for a production release. +$signature = Get-AuthenticodeSignature -LiteralPath $agent +if ($signature.Status -ne 'Valid') { throw 'Configuration agent is not trusted.' } +$editionDirectory = if ($bootstrap.edition -eq 'technician') { Join-Path $env:LOCALAPPDATA 'SwanRemoteSupport-Technician' } else { Join-Path $env:ProgramData 'SwanRemoteSupport' } +if ($bootstrap.edition -eq 'customer') { + $identity = [Security.Principal.WindowsIdentity]::GetCurrent() + if (-not ([Security.Principal.WindowsPrincipal]::new($identity)).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { throw 'Run customer setup as administrator.' } +} +New-Item -ItemType Directory -Force -Path $editionDirectory | Out-Null +if ($bootstrap.edition -eq 'customer') { + & icacls.exe $editionDirectory '/inheritance:r' '/grant:r' '*S-1-5-18:(OI)(CI)F' '*S-1-5-32-544:(OI)(CI)F' | Out-Null + if ($LASTEXITCODE -ne 0) { throw 'Cannot protect customer configuration directory.' } +} +$env:SWAN_STATE_DIR = $editionDirectory +if (-not (Test-Path -LiteralPath (Join-Path $editionDirectory 'managed-state.json'))) { + & $agent setup (Join-Path $root 'bootstrap.json') --accept-company + if ($LASTEXITCODE -ne 0) { throw 'Company configuration verification failed.' } +} +$installers = @(Get-ChildItem -LiteralPath $root -File | Where-Object { $_.Name -match '^SwanRemoteSupport-install\.(exe|msi)$' }) +if ($installers.Count -ne 1) { throw 'Expected one company installer.' } +& $agent verify-package (Join-Path $root 'release.json') $installers[0].FullName +if ($LASTEXITCODE -ne 0) { throw 'Installer metadata, hash or publisher validation failed.' } +if ($bootstrap.edition -eq 'customer') { + if ($installers[0].Extension -eq '.msi') { + $process = Start-Process -FilePath 'msiexec.exe' -ArgumentList @('/i',('"' + $installers[0].FullName + '"'),'/passive','/norestart') -PassThru -Wait -WindowStyle Hidden + } else { + $process = Start-Process -FilePath $installers[0].FullName -ArgumentList '--silent-install','printer=0' -PassThru -Wait -WindowStyle Hidden + } + if ($process.ExitCode -notin @(0,3010)) { throw "Installation failed ($($process.ExitCode))." } + $installed = Join-Path $env:ProgramFiles 'Swan Remote Support/Swan Remote Support.exe' + if (-not (Test-Path -LiteralPath $installed)) { throw 'Installed executable not found. Verify the package install directory.' } + $deviceId = (& $installed --get-id | Out-String).Trim() + $enrollmentArguments = @('enroll', $env:COMPUTERNAME, $deviceId) + if ($UnattendedConsent) { $enrollmentArguments += '--unattended-consent' } + & $agent @enrollmentArguments + if ($LASTEXITCODE -ne 0) { throw 'Device enrollment failed. Access remains blocked.' } + Copy-Item -LiteralPath $agent -Destination (Join-Path $editionDirectory 'swan-agent.exe') -Force + $action = New-ScheduledTaskAction -Execute (Join-Path $editionDirectory 'swan-agent.exe') -Argument 'watch' + $trigger = New-ScheduledTaskTrigger -AtStartup + $settings = New-ScheduledTaskSettingsSet -RestartCount 3 -RestartInterval (New-TimeSpan -Minutes 1) -ExecutionTimeLimit ([TimeSpan]::Zero) + Register-ScheduledTask -TaskName 'Swan Company Configuration' -Action $action -Trigger $trigger -Settings $settings -User 'SYSTEM' -RunLevel Highest -Force | Out-Null + Start-ScheduledTask -TaskName 'Swan Company Configuration' + Write-Host 'Installed and enrolled. Your company must approve the device. Support status and stop controls remain available.' +} else { + Write-Host 'Technician company profile verified. Run Open-Technician.ps1 to sign in and request support sessions.' +} diff --git a/deployment/windows/Install-Server.ps1 b/deployment/windows/Install-Server.ps1 new file mode 100644 index 00000000000..3755773ac4f --- /dev/null +++ b/deployment/windows/Install-Server.ps1 @@ -0,0 +1,25 @@ +[CmdletBinding()] +param([Parameter(Mandatory)][string]$Executable,[Parameter(Mandatory)][string]$ReleasePublicKey,[int]$Port=8080) +$ErrorActionPreference = 'Stop' +$identity = [Security.Principal.WindowsIdentity]::GetCurrent() +if (-not ([Security.Principal.WindowsPrincipal]::new($identity)).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { throw 'Server installation requires administrator rights.' } +if ($Port -lt 1024 -or $Port -gt 65535) { throw 'Invalid management port.' } +$source = (Resolve-Path -LiteralPath $Executable).Path +if ((Get-AuthenticodeSignature -LiteralPath $source).Status -ne 'Valid') { throw 'The company server executable requires a trusted release signature.' } +if ([Convert]::FromBase64String($ReleasePublicKey).Length -ne 32) { throw 'Expected a 32-byte project release public key.' } +$installDirectory = Join-Path $env:ProgramFiles 'Swan Company Server' +$dataDirectory = Join-Path $env:ProgramData 'SwanCompanyServer' +if (Get-Service -Name SwanCompanyServer -ErrorAction SilentlyContinue) { throw 'Company server already exists. Use the documented backed-up maintenance upgrade procedure.' } +New-Item -ItemType Directory -Force -Path $installDirectory,$dataDirectory | Out-Null +& icacls.exe $dataDirectory '/inheritance:r' '/grant:r' '*S-1-5-18:(OI)(CI)F' '*S-1-5-32-544:(OI)(CI)F' | Out-Null +if ($LASTEXITCODE -ne 0) { throw 'Cannot protect server data.' } +$destination = Join-Path $installDirectory 'swan-management.exe' +Copy-Item -LiteralPath $source -Destination $destination +New-Service -Name SwanCompanyServer -DisplayName 'Swan Remote Support Company Server' -BinaryPathName ('"'+$destination+'" --service') -StartupType Automatic | Out-Null +$serviceRegistry = 'HKLM:/SYSTEM/CurrentControlSet/Services/SwanCompanyServer' +New-ItemProperty -Path $serviceRegistry -Name Environment -PropertyType MultiString -Value @("SWAN_DATA_DIR=$dataDirectory","SWAN_LISTEN=127.0.0.1:$Port","SWAN_RELEASE_PUBLIC_KEY=$ReleasePublicKey") -Force | Out-Null +& sc.exe failure SwanCompanyServer reset= 86400 actions= restart/60000/restart/60000/restart/300000 | Out-Null +Start-Service SwanCompanyServer +Write-Host "Company server installed on localhost:$Port. Put an HTTPS reverse proxy in front of this service." +Write-Host "Read the one-time setup token from $dataDirectory/setup-token.txt, then complete the web setup wizard." +Write-Host 'Existing RustDesk and Tailscale services were not changed. Install the parallel public transport using the pinned release in deployment/transport-source.json.' diff --git a/deployment/windows/LOCAL-TESTING.md b/deployment/windows/LOCAL-TESTING.md new file mode 100644 index 00000000000..418a2de55d9 --- /dev/null +++ b/deployment/windows/LOCAL-TESTING.md @@ -0,0 +1,11 @@ +# Local company-server testing + +Each company runs its own management, rendezvous, relay and Windows build worker. The project does not operate a shared hosting or signing service. + +Copy `local-test.env.example` to `local-test.env`, build `product/Cargo.toml`, then run `Start-LocalTest.ps1` from the repository root. The launcher reads named settings as data and binds management to loopback. `local-test.env` and `swan-data/` are ignored by Git. The data directory contains the database, private profile-signing key and setup token; its Windows ACL permits the current user, administrators and SYSTEM. + +The default local administration address is http://127.0.0.1:18080. Use the one-time token in `swan-data/local-test/setup-token.txt` for initial setup. Do not paste the token into issues or build logs. Existing RustDesk services are independent of this test process. + +This loopback HTTP listener is for administration smoke tests. Endpoint enrollment and profile synchronization require HTTPS with a trusted certificate; put a local TLS reverse proxy in front before testing those flows. Do not disable certificate validation. Public direct and relay testing requires a company-owned reachable endpoint and a separate external test machine. + +Verified on this development machine: management starts, administration responds with HTTP 200, and the seven product-workspace tests pass. The native Flutter/RustDesk integration, production signatures, complete installers, automatic update execution and clean-machine Windows compatibility remain release requirements; this development build is not a production release. diff --git a/deployment/windows/Open-Technician.ps1 b/deployment/windows/Open-Technician.ps1 new file mode 100644 index 00000000000..7907e029874 --- /dev/null +++ b/deployment/windows/Open-Technician.ps1 @@ -0,0 +1,40 @@ +[CmdletBinding()] +param() +$ErrorActionPreference = 'Stop' +$env:SWAN_STATE_DIR = Join-Path $env:LOCALAPPDATA 'SwanRemoteSupport-Technician' +$agent = Join-Path $PSScriptRoot 'swan-agent.exe' +if ((Get-AuthenticodeSignature -LiteralPath $agent).Status -ne 'Valid') { throw 'Technician agent requires a valid signature.' } +& $agent sync +if ($LASTEXITCODE -ne 0) { throw 'Company synchronization failed.' } +$user = Read-Host 'Technician username' +$secure = Read-Host 'Password' -AsSecureString +$passwordPointer = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($secure) +try { + $env:SWAN_LOGIN_PASSWORD = [Runtime.InteropServices.Marshal]::PtrToStringBSTR($passwordPointer) + $env:SWAN_LOGIN_TOTP = Read-Host 'Authenticator code' + $loginJson = & $agent login $user + if ($LASTEXITCODE -ne 0) { throw 'Technician authentication failed.' } + $login = $loginJson | ConvertFrom-Json + $env:SWAN_TECHNICIAN_TOKEN = $login.token +} finally { + [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($passwordPointer) + Remove-Item Env:SWAN_LOGIN_PASSWORD,Env:SWAN_LOGIN_TOTP -ErrorAction SilentlyContinue +} +try { + & $agent update + if ($LASTEXITCODE -ne 0) { throw 'Update validation failed or recovery is required.' } + & $agent devices + if ($LASTEXITCODE -ne 0) { throw 'Device inventory unavailable.' } + $device = Read-Host 'Approved device identifier' + $app = Join-Path $PSScriptRoot 'SwanRemoteSupport-install.exe' + if (-not (Test-Path -LiteralPath $app)) { throw 'Portable technician EXE is required.' } + # Avoid the portable packer opening its installer when the filename ends in install.exe. + $portable = Join-Path $env:SWAN_STATE_DIR 'SwanRemoteSupport-Technician.exe' + if (-not (Test-Path -LiteralPath $portable)) { + & $agent verify-package (Join-Path $PSScriptRoot 'release.json') $app + if ($LASTEXITCODE -ne 0) { throw 'Technician package verification failed.' } + Copy-Item -LiteralPath $app -Destination $portable + } + & $agent connect $device $portable + if ($LASTEXITCODE -ne 0) { throw 'Support session could not start.' } +} finally { Remove-Item Env:SWAN_TECHNICIAN_TOKEN -ErrorAction SilentlyContinue } diff --git a/deployment/windows/Start-LocalTest.ps1 b/deployment/windows/Start-LocalTest.ps1 new file mode 100644 index 00000000000..ca4397ebf69 --- /dev/null +++ b/deployment/windows/Start-LocalTest.ps1 @@ -0,0 +1,24 @@ +param( + [string]$EnvironmentFile = (Join-Path $PSScriptRoot 'local-test.env'), + [string]$ServerExecutable = (Join-Path $PSScriptRoot '../../product/target/debug/swan-management.exe') +) +$ErrorActionPreference = 'Stop' +if (!(Test-Path -LiteralPath $EnvironmentFile)) { throw 'Create local-test.env from local-test.env.example first.' } +# Only named server settings are accepted; the file is data, never PowerShell code. +$allowed = @('SWAN_LISTEN', 'SWAN_DATA_DIR', 'SWAN_RELEASE_PUBLIC_KEY') +foreach ($line in Get-Content -LiteralPath $EnvironmentFile) { + if (!$line.Trim() -or $line.TrimStart().StartsWith('#')) { continue } + if ($line -notmatch '^([A-Z_]+)=(.*)$' -or $Matches[1] -notin $allowed) { throw 'Invalid local environment setting.' } + [Environment]::SetEnvironmentVariable($Matches[1], $Matches[2], 'Process') +} +if ($env:SWAN_LISTEN -notmatch '^127\.0\.0\.1:\d+$') { throw 'Local tests must bind to loopback.' } +if (!$env:SWAN_DATA_DIR) { throw 'Set a separate local test data directory.' } +$testDataPath = [IO.Path]::GetFullPath($env:SWAN_DATA_DIR) +New-Item -ItemType Directory -Path $testDataPath -Force | Out-Null +$testUserSid = [System.Security.Principal.WindowsIdentity]::GetCurrent().User.Value +& icacls.exe $testDataPath /inheritance:r /grant:r ('*' + $testUserSid + ':(OI)(CI)F') '*S-1-5-18:(OI)(CI)F' '*S-1-5-32-544:(OI)(CI)F' | Out-Null +if ($LASTEXITCODE -ne 0) { throw 'Failed to protect local test data.' } +$env:SWAN_DATA_DIR = $testDataPath +if (!(Test-Path -LiteralPath $ServerExecutable)) { throw 'Build the product workspace before starting local tests.' } +& $ServerExecutable +if ($LASTEXITCODE -ne 0) { throw "Management server exited with code $LASTEXITCODE" } diff --git a/deployment/windows/Verify-Package.ps1 b/deployment/windows/Verify-Package.ps1 new file mode 100644 index 00000000000..60173c733f5 --- /dev/null +++ b/deployment/windows/Verify-Package.ps1 @@ -0,0 +1,10 @@ +[CmdletBinding()] +param([Parameter(Mandatory)][string]$Path,[Parameter(Mandatory)][string]$Publisher,[string]$Sha256) +$ErrorActionPreference = 'Stop' +$resolved = (Resolve-Path -LiteralPath $Path).Path +if ($Sha256 -and (Get-FileHash -LiteralPath $resolved -Algorithm SHA256).Hash -ne $Sha256) { throw 'Package hash does not match signed release metadata.' } +$signature = Get-AuthenticodeSignature -LiteralPath $resolved +if ($signature.Status -ne 'Valid' -or -not $signature.SignerCertificate) { throw 'Package requires a valid trusted Authenticode signature.' } +$actualPublisher = $signature.SignerCertificate.GetNameInfo([System.Security.Cryptography.X509Certificates.X509NameType]::SimpleName, $false) +if ($actualPublisher -cne $Publisher) { throw 'Package publisher does not match the approved release publisher.' } +Write-Output 'Package signature and publisher verified.' diff --git a/deployment/windows/local-test.env.example b/deployment/windows/local-test.env.example new file mode 100644 index 00000000000..3dced8e003e --- /dev/null +++ b/deployment/windows/local-test.env.example @@ -0,0 +1,5 @@ +# Copy to local-test.env. Actual machine settings and keys must stay out of Git. +SWAN_LISTEN=127.0.0.1:18080 +SWAN_DATA_DIR=swan-data/local-test +# Set a public release verification key before testing release import. +SWAN_RELEASE_PUBLIC_KEY= diff --git a/docs/PRODUCT_COMPLETION_ASSESSMENT.md b/docs/PRODUCT_COMPLETION_ASSESSMENT.md new file mode 100644 index 00000000000..d2f01209812 --- /dev/null +++ b/docs/PRODUCT_COMPLETION_ASSESSMENT.md @@ -0,0 +1,117 @@ +# Swan Remote Support completion assessment + +Research date: 1 October 2026. This assessment covers the company-configurable open-source product: a self-hosted server, public customer installer, internal technician installer, branding and policy synchronization, and automatic updates without requiring Tailscale. + +The remote desktop engine and a Swan-specific unsigned customer build exist. The reusable company administration and distribution system does not yet exist in this checkout. We should extend the RustDesk OSS foundation with our own open-source management service and company configuration protocol. The proposed architecture below is a recommendation, not implemented behavior. + +## Evidence and limits + +Inspected checkout: `SwanRemoteSupport-main`, branch `main`, commit `9494a5a`, initially clean. The outer workspace repository has no commits; `SwanRemoteSupport` contains only Git metadata. The substantive application is in `SwanRemoteSupport-main`. + +The GitHub API confirms the Swan Windows build [32726437306](https://github.com/rijff24/SwanRemoteSupport/actions/runs/32726437306) succeeded on 24 August 2026 from `0e400fd`. The API returned no published releases. A successful build is evidence of compilation and packaging, not acceptance testing or trusted signing. The local change log records verification of the unsigned artifact and preparation of a two-laptop test kit. + +Today, the local `RustDesk`, `hbbs`, and `hbbr` Windows services are running with automatic startup. Earlier server hardening and recovery records are documented evidence; public internet reachability, current firewall policy, customer consent flows, and remote session behavior were not revalidated in this research. No live network or service configuration was changed. + +The `libs/hbb_common` submodule is not initialized in this checkout. Cargo, Flutter, and CMake were not found on PATH in the research shell. No application build or end-to-end test was performed here. + +## What already exists + +| Area | Finding | Evidence | +| --- | --- | --- | +| Remote desktop foundation | RustDesk 1.4.9 client with upstream screen control, transfer, clipboard, service and relay code | `Cargo.toml`, `src/client.rs`, `src/server/`, `src/rendezvous_mediator.rs` | +| Customer branding | Swan logos, light/dark assets, product text, icon and Windows metadata | `branding/`, `flutter/windows/runner/Runner.rc`, `flutter/lib/common.dart` | +| Managed customer behavior | Incoming-only defaults, hidden ID/password panel and server settings, permanent-password setup | `src/common.rs:load_swan_client_defaults`, `flutter/lib/desktop/pages/desktop_home_page.dart` | +| Customer setup | Express installer and PowerShell deployment; unique password generation, credential receipt, rotation and revocation helpers | `scripts/`, `docs/SWAN_CUSTOM_BUILD.md` | +| Windows packaging | Manual x64 workflow producing an unsigned self-extracting EXE and checksum | `.github/workflows/swan-windows-build.yml` | +| Technician test launcher | Copies the same unsigned customer binary under a different filename to avoid opening the installer | `scripts/Run-Swan-Technician-UNSIGNED-TEST.cmd` | +| Server transport | Existing RustDesk OSS rendezvous and relay services; August audit recorded version 1.1.15 | `docs/AI_CHANGELOG.md` | +| Reliability | Windows service recovery and startup/hourly monitoring on this machine | `../rustdesk-reliability/README.md` | +| Release governance | Source availability, privacy, signing policy and acceptance documentation | `docs/SOURCE_AND_LICENSE.md`, `docs/CODE_SIGNING_POLICY.md` | + +The technician launcher is not a separately configured technician product. It changes the filename, while `load_swan_client_defaults` still forces incoming-only settings and the Swan managed UI. Its ability to initiate the intended support sessions must be checked and corrected rather than assumed. + +## Gaps against the requested product + +| Requirement | Current gap | +| --- | --- | +| A company configures its own server | No company setup wizard, branding editor, technician administration, device enrollment service, or installer generator found | +| Clients inherit company branding and setup | Swan name, assets, private-network address and public key are built into the app/workflow; no company profile synchronization found | +| Website download and unattended configuration | Existing process expects an in-person technician, Tailscale login/tag approval, and manual credential collection | +| Internal technician edition | Need authenticated technician login, outgoing support UI, permissions, device inventory and proper packaging | +| Server generates installers | Current packaging runs in GitHub Actions; no server build queue or downloadable company packages | +| Automatic updates | Swan explicitly overrides `allow-auto-update` to `N`; upstream updater is not a Swan/company release system | +| All major Windows versions | Only the Swan x64 Flutter build is verified; no Swan ARM64, legacy x86, or Windows Server compatibility evidence | +| No Tailscale requirement | Installer, defaults, customer text and existing server deployment depend on Tailscale | +| Customer-ready publication | No published signed release or completed acceptance evidence found | + +Upstream custom configuration is not immediately usable as our company profile format: `read_custom_client` verifies `custom.txt` using a fixed upstream public signing key. We need our own explicitly trusted profile mechanism. Also, `API_SERVER` currently refers to the RustDesk Pro protocol; pointing it at a new generic REST service will not create compatible login or configuration sync. + +## Proposed company server and synchronization + +Use one self-hosted deployment per company initially. Keep `hbbs` and `hbbr` as the transport and add an open-source management service with a database, administration UI, configuration API, enrollment API, audit records, download endpoints and release management. + +Company setup should collect the public service hostname, transport addresses, server public key, company name, logos, theme, support contacts, consent wording, technician accounts, access policies and update channel. Provide backup and restore for configuration, device identity and server keys. + +Each company installer needs a small initial configuration containing the management URL, company identifier, intended edition, and a trusted public configuration key. The installer must contain no administrator credentials, shared unattended password, signing private key or reusable technician enrollment secret. + +On installation and subsequent refreshes, the app verifies and applies a versioned company profile, caches the last valid profile, and retries with backoff when offline. Protect against invalid signatures, expired metadata, older profile replay, and silent changes of company ownership. Plan trusted key rotation and address migration before shipping. + +Public customer downloads are public: their embedded values cannot grant technician privileges. Enrollment needs unique per-device credentials and appropriate approval or scoped invitation. Unattended access requires clear owner consent. Avoid the current need for customers to copy support passwords into a technician's password manager. + +Technicians authenticate separately with MFA and server-enforced device/group permissions. Hiding buttons or specifying an outgoing/incoming role is not authorization. Authorization must also protect direct and relayed sessions, device revocation and stolen credentials; implementing this likely requires transport/client changes in addition to the management API. + +Allow logo, contact, theme and policy changes to sync without reinstalling. Windows executable icons, publisher/version resources and installation identity are packaging concerns: changing these may require a newly generated signed package. Use stable internal application, service and configuration identifiers so a display-name change does not lose device identity or break upgrades. + +## Generating and publishing installers + +Recommended product behavior: the company administrator clicks **Generate installers** and receives customer EXE/MSI packages for its website and technician EXE/MSI packages for authenticated internal distribution. + +The server can orchestrate this, but Windows compilation should run on isolated Windows workers with pinned source/toolchains. A Linux-hosted company server can queue a Windows worker or CI job. Begin by adapting the working GitHub Actions build and upstream WiX MSI scaffolding in `res/msi/`; the latter is not already a finished Swan MSI pipeline. + +Build shared runtime binaries per version/architecture, then assemble company-specific configuration and branding. Where resource customization changes a PE binary, sign the final customized binary. Sign inner executables/DLLs before packaging and the final installer afterward. Do not modify a signed artifact to insert company configuration. Keep signing access separate from the publicly reachable runtime server. + +Each download should identify edition, architecture, version, publisher, hash and corresponding source. Keep an immutable release record; a stable website download link may point to the latest approved package. Protect technician downloads, but enforce technician authorization even if someone obtains that installer. + +[Windows Installer major upgrades](https://learn.microsoft.com/en-us/windows/win32/msi/major-upgrades) provide package upgrade mechanisms and downgrade prevention. Proposed installers must preserve device identity, enrollment and consent through upgrade/repair, and implement explicit uninstall and recovery behavior. + +## Automatic updates + +Do not simply enable the existing upstream updater. `src/common.rs` checks the upstream version service, and `src/updater.rs` constructs RustDesk release filenames. It does not implement a company-approved Swan release feed. Its size-based cached-download reuse is not a package integrity check. + +Implement a company-controlled feed identifying product, edition, architecture, OS compatibility, version, download URL, hash and metadata expiry. Verify signed metadata and artifact integrity, plus trusted Authenticode publisher policy, before privileged installation. Evaluate [The Update Framework](https://theupdateframework.io/docs/overview/) for the update trust design. + +Add stable/test channels, staged rollout, maintenance windows, pause controls, retry/backoff and explicit status reporting. Preserve enrollment and settings. Coordinate installation with both client roles so sessions cannot start while an update is being applied. Define recovery after interrupted installation and controlled rollback to an approved version without permitting arbitrary replay of old releases. Separate company configuration revisions from software versions. + +Clients can update automatically within company policy. Server updates should initially require an administrator maintenance action with database/key backups and tested migration rollback; a failed server upgrade could affect every customer. + +## Removing Tailscale + +This is feasible with the existing transport. [RustDesk self-hosting documentation](https://rustdesk.com/docs/en/self-host/) describes direct NAT traversal with relay fallback. Native clients need reachable server TCP 21115, 21116 and 21117, plus UDP 21116. Proposed management/download APIs use HTTPS 443. + +Recommended deployment is a server with a public address and DNS name. An office-hosted server needs working port forwarding and a reachable public address; CGNAT may require a public VPS or another reachable relay. Ordinary shared website hosting does not necessarily permit the required long-running TCP/UDP services. A website can host downloads while the transport runs elsewhere. + +Use DNS names rather than a Tailscale-only address. Validate direct and forced-relay sessions from independent external networks, including restrictive corporate networks; do not promise that the existing native transport works through every HTTPS-only proxy. + +Tailscale currently provides network restrictions as well as connectivity. A public OSS server does not automatically reproduce technician identities, MFA or company access policy. Implement and validate replacement authorization, rate limiting, abuse controls and auditability. The public server key is a trust value, not an access secret. Migrate through a parallel endpoint and tested rollback before removing the existing private access path. + +## Windows support and open source + +Recommended initial release target: Windows 10 and 11 x64. Add ARM64 after validating the project's Rust, Flutter engine, plugins and native dependencies together. [Flutter's current supported-platform matrix](https://docs.flutter.dev/reference/supported-platforms) lists Windows 10/11 x64 and ARM64 and excludes Windows 8 and earlier; it does not prove compatibility of our pinned Flutter 3.24.5 and custom RustDesk engine. + +Windows 7/8/8.1 and 32-bit Windows require a separate legacy UI/toolchain assessment and actual tests. Windows Server releases need explicit testing of interactive sessions, logon screen, UAC and service behavior. Installer format alone cannot make an unsupported runtime compatible. Do not advertise all Windows versions until the support matrix is tested. + +Retain AGPL notices and exact corresponding-source/build links for distributed modified binaries and assess the new management service's license obligations. The [GNU AGPL](https://www.gnu.org/licenses/agpl.en.html) includes obligations for modified network software. Company keys and customer data remain private. Existing [RustDesk branded client generation](https://rustdesk.com/docs/en/self-host/client-configuration/) is a Pro feature; our fully open-source design needs its own implementation. + +Signing remains unresolved. The existing SignPath policy documents an intended process, not proof of project approval. [SignPath Foundation terms](https://signpath.org/terms.html) have eligibility and provenance requirements; confirm coverage of company-specific generated builds. Publisher identity follows the actual certificate, not the company logo. Automatic per-company trusted signing cannot be assumed free. + +## Recommended completion order + +1. **Reproduce the baseline.** Initialize the pinned submodule in the development/build environment, rebuild the customer package, test the technician-role issue, and record current install/reboot/remote-session results. Correct stale documentation: the build has succeeded, and the spare-laptop guide uses `tag:swan-operator` where the policy uses `tag:swan-support-operator`. +2. **Implement company management and trust.** Deliver setup, profiles, enrollment, technician authentication, device groups and enforced session permissions. Demonstrate one company's profile cannot authorize access to another deployment. +3. **Create the two editions and sync.** Remove Swan-only assumptions, implement customer self-setup and technician login, and verify live profile refresh, offline caching and key rotation. +4. **Validate public transport.** Prepare a parallel public endpoint, implement replacement access/abuse controls, and pass external direct/relay and negative-access tests without Tailscale. +5. **Deliver signed packages.** Add company packaging jobs, proper MSI/EXE install/repair/uninstall, website downloads and protected technician distribution. +6. **Deliver safe updates.** Test valid updates, invalid signatures/hashes, wrong company/edition/architecture, expired/replayed metadata, active sessions, interrupted installation and recovery. +7. **Publish the supported release.** Complete clean-machine acceptance on every advertised OS/architecture; publish signed hashes, exact source, privacy and recovery instructions. Extend legacy compatibility only with separate evidence. + +Completion means a fresh company deployment can configure branding once, generate both installers, publish the customer package, enroll a consenting customer without manual network configuration, authenticate an authorized technician, connect without Tailscale, change branding centrally, and upgrade both apps without losing identity or access controls. diff --git a/flutter/lib/desktop/pages/desktop_home_page.dart b/flutter/lib/desktop/pages/desktop_home_page.dart index 6d8d397683f..d37a8fe9862 100644 --- a/flutter/lib/desktop/pages/desktop_home_page.dart +++ b/flutter/lib/desktop/pages/desktop_home_page.dart @@ -80,7 +80,7 @@ class _DesktopHomePageState extends State Widget buildLeftPane(BuildContext context) { final isIncomingOnly = bind.isIncomingOnly(); final isOutgoingOnly = bind.isOutgoingOnly(); - final isSwanManagedClient = bind.mainGetAppNameSync() == swanManagedAppName; + final isSwanManagedClient = bind.mainGetAppNameSync() == swanManagedAppName && isIncomingOnly; final children = [ if (!isOutgoingOnly && !isSwanManagedClient) buildPresetPasswordWarning(), if (bind.isCustomClient()) @@ -194,17 +194,21 @@ class _DesktopHomePageState extends State Widget buildSwanManagedOverview(BuildContext context) { return FutureBuilder( - future: bind.mainGetCommon(key: 'permanent-password-set'), + future: bind.mainGetCommon(key: 'company-overview'), builder: (context, snapshot) { - final isReady = snapshot.data == 'true'; + Map company = {}; + try { + company = jsonDecode(snapshot.data ?? '{}') as Map; + } catch (_) {} + final isReady = company['configured'] == true && company['enrolled'] == true; final statusColor = isReady ? const Color(0xFF0A7D5A) : Colors.orange; final statusIcon = isReady ? Icons.verified_user : Icons.warning_amber; final statusTitle = isReady - ? 'Ready for unattended support' - : 'Swan setup is not complete'; + ? (company['unattended'] == true ? 'Unattended support enabled' : 'Customer approval required') + : 'Company setup is not complete'; final statusText = isReady - ? 'This computer can receive private Swan Computing support while it is connected to Tailscale.' - : 'A Swan technician must finish express setup and securely record this computer\'s unique support credentials.'; + ? 'Only company-authorized technicians can request access. Your company must approve this device before support begins.' + : 'Run the company setup included with your download to verify its server and enroll this computer.'; return Padding( padding: const EdgeInsets.fromLTRB(20, 16, 16, 8), @@ -212,12 +216,12 @@ class _DesktopHomePageState extends State crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - 'Managed by Swan Computing', + company['display_name'] as String? ?? 'Swan Remote Support', style: Theme.of(context).textTheme.titleLarge, ), const SizedBox(height: 8), Text( - 'Remote support is configured for Swan Computing\'s private Tailscale network. The device ID and password are hidden from this customer screen.', + company['consent_text'] as String? ?? 'Support requires a verified company configuration. No public-network fallback is permitted.', style: Theme.of(context).textTheme.bodySmall, ), const SizedBox(height: 16), @@ -254,8 +258,17 @@ class _DesktopHomePageState extends State ), ), const SizedBox(height: 12), + if (company['unattended'] == true) + TextButton.icon( + onPressed: () { + bind.mainSetCommon(key: 'company-revoke-unattended', value: 'N'); + setState(() {}); + }, + icon: const Icon(Icons.shield_outlined), + label: const Text('Revoke unattended access'), + ), Text( - 'The computer owner can use the tray icon to view or stop the support service.', + 'Use the tray icon to view or stop support. Company branding powered by Swan Remote Support ยท AGPL-3.0.', style: Theme.of(context).textTheme.bodySmall, ), ], diff --git a/product/Cargo.lock b/product/Cargo.lock new file mode 100644 index 00000000000..84e9283c56e --- /dev/null +++ b/product/Cargo.lock @@ -0,0 +1,2279 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common", + "generic-array", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures 0.2.17", +] + +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "ghash", + "subtle", +] + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +dependencies = [ + "derive_arbitrary", +] + +[[package]] +name = "argon2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" +dependencies = [ + "base64ct", + "blake2", + "cpufeatures 0.2.17", + "password-hash", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[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", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "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 = "base32" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "022dfe9eb35f19ebbcb51e0b40a5ab759f46ad60cadf7297e0bd085afb50e076" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cc" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f360145194ee8e21db5ee7f3fcd4fe52210864c75c985dae33218202c8bbe040" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "chacha20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "rand_core 0.10.1", +] + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "constant_time_eq" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c74b8349d32d297c9134b8c88677813a227df8f779daa29bfc29c183fe3dca6" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "typenum", +] + +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "curve25519-dalek-derive", + "digest", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "zeroize", +] + +[[package]] +name = "derive_arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", + "subtle", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "rand_core 0.6.4", + "serde", + "sha2", + "subtle", + "zeroize", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + +[[package]] +name = "find-msvc-tools" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" + +[[package]] +name = "flate2" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "fs2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9564fc758e15025b46aa6643b1b77d047d1a56a1aea6e01002ac0c7026876213" +dependencies = [ + "libc", + "winapi", +] + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-io", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 6.0.0", + "rand_core 0.10.1", + "wasm-bindgen", +] + +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval", +] + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "hashlink" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" +dependencies = [ + "hashbrown 0.14.5", +] + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +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 = "hyper" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b501faa50e7a26c3d3560ca625132f4078a17771f4810baf70475ae48cbe43" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfa8e654703247911e29c23fbeaa261834bd9bb74efba2f9acddc37bfb127f53" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", +] + +[[package]] +name = "hyper-util" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff" +dependencies = [ + "base64 0.23.1", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "httparse", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "icu_collections" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" + +[[package]] +name = "icu_properties" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" + +[[package]] +name = "icu_provider" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", +] + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + +[[package]] +name = "ipnet" +version = "2.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libsqlite3-sys" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "lru-slab" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4050469837a6ff301cd14c1f8f24f88549e6d548f24f64e2148eb0f72cebc51f" + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "opaque-debug", + "universal-hash", +] + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quinn" +version = "0.11.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4051e23e9185c255a7e33ef59cdbca87a22d359052eecd22fc6b901fb37d9d11" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e750cca55fe4f0439a15d0bb529da9651e79993e8e72c61a899a36d462befbe" +dependencies = [ + "bytes", + "getrandom 0.4.3", + "lru-slab", + "rand 0.10.3", + "rand_pcg", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af66907df18639dcf4db56ca65490cabc4b27a97dbadd96f2926cca73298f016" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.61.2", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core 0.10.1", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rusqlite" +version = "0.32.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e" +dependencies = [ + "bitflags", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", +] + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustls" +version = "0.23.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha1" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "rand_core 0.6.4", +] + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e" + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "subtle" +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.8", + "reqwest", + "serde", + "serde_json", + "sha2", + "swan-protocol", + "tokio", + "windows-sys 0.61.2", +] + +[[package]] +name = "swan-management" +version = "0.1.0" +dependencies = [ + "aes-gcm", + "anyhow", + "argon2", + "axum", + "base64 0.22.1", + "ed25519-dalek", + "hex", + "http-body-util", + "rand 0.8.8", + "rusqlite", + "serde", + "serde_json", + "sha2", + "swan-agent", + "swan-protocol", + "tokio", + "totp-rs", + "tower", + "tower-http", + "uuid", + "windows-service", + "zip", +] + +[[package]] +name = "swan-protocol" +version = "0.1.0" +dependencies = [ + "anyhow", + "base64 0.22.1", + "ed25519-dalek", + "hex", + "rand 0.8.8", + "serde", + "serde_json", + "sha2", + "url", +] + +[[package]] +name = "swan-worker" +version = "0.1.0" +dependencies = [ + "anyhow", + "reqwest", + "serde_json", + "swan-agent", + "swan-protocol", + "tokio", + "zip", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "thiserror" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" + +[[package]] +name = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9cc2678c2cdd569ef8215e2afd7954ada2ae20b4fdd2c5fe6139a3b02d105db" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "totp-rs" +version = "5.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50e69a15e21b2ff22c415446983978bded3244195f17d59cb113551c1e806f91" +dependencies = [ + "base32", + "constant_time_eq", + "hmac", + "rand 0.9.5", + "sha1", + "sha2", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" + +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common", + "subtle", +] + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ef6dac1e96601b4fb3acccccff2139741fcb757cb9a36089bf5be91cfb285ce" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.79" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3cbab34de2d982e9b48e18d216d04c4a6f641066ff19ffb699980f591ee3610e" +dependencies = [ + "js-sys", + "tokio", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.6", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88261b9deccee56594c11a3460c462c41f58d148598fe70ad77070126a68aba4" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "widestring" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471" + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-service" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "857224b3b211c6f3616921f081ee54721ee3ad2ace2fac6a6337e032f7b4dcf2" +dependencies = [ + "bitflags", + "widestring", + "windows-sys 0.61.2", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.59" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6df92bf3d9227be3d53173901ddbffac2babc27ae50f397776ffd6dc33f800cb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.59" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac4f328cf2f05d084e496c3e9c3f33ed0a183656a16e1fcec4d464d8373aec82" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "zip" +version = "2.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50" +dependencies = [ + "arbitrary", + "crc32fast", + "crossbeam-utils", + "displaydoc", + "flate2", + "indexmap", + "memchr", + "thiserror", + "zopfli", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + +[[package]] +name = "zopfli" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" +dependencies = [ + "bumpalo", + "crc32fast", + "log", + "simd-adler32", +] diff --git a/product/Cargo.toml b/product/Cargo.toml new file mode 100644 index 00000000000..a4c0c23a86c --- /dev/null +++ b/product/Cargo.toml @@ -0,0 +1,23 @@ +[workspace] +members = ["protocol", "management", "agent", "worker"] +resolver = "2" + +[workspace.package] +version = "0.1.0" +edition = "2021" +license = "AGPL-3.0-only" +rust-version = "1.90" + +[workspace.dependencies] +anyhow = "1" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +base64 = "0.22" +ed25519-dalek = { version = "2", features = ["rand_core"] } +rand = "0.8" +sha2 = "0.10" +hex = "0.4" +url = "2" +uuid = { version = "1", features = ["v4", "serde"] } +tokio = { version = "1", features = ["full"] } +reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "blocking"] } diff --git a/product/README.md b/product/README.md new file mode 100644 index 00000000000..d0a8a743a43 --- /dev/null +++ b/product/README.md @@ -0,0 +1,36 @@ +# Swan Remote Support company product + +Development implementation, not a production release. The original single-company application is preserved at tag `swan-single-company-baseline-1.4.9`. + +Each company hosts one independent deployment. The management service uses Axum and SQLite, with an embedded administration interface. Pinned RustDesk OSS rendezvous and relay services carry transport; receiving managed applications enforce company authorization regardless of transport. The project supplies source and releases, not shared hosting or private signing keys. + +## Build + +Install Rust 1.90.0 and the platform C linker. From the repository root: + +``` +cargo +1.90.0 test --manifest-path product/Cargo.toml --workspace --locked +cargo +1.90.0 build --manifest-path product/Cargo.toml --workspace --release --locked +``` + +Windows production builds use MSVC. GNU compilation has verified the product components on the local development machine. Native endpoint builds additionally require the pinned Flutter and vcpkg dependencies in `.github/workflows/swan-windows-build.yml`; the company-product CI separately checks Windows and Linux management components. The root application enables management integration with the `swan_custom` feature. + +## Implemented development flows + +- One-time setup, local password/TOTP authentication with replay checks, administrator and technician roles, group access, pending device enrollment, revocation and auditing. +- Signed company profiles, pinned HTTPS bootstrap, profile refresh, local consent revocation and short-lived, target-bound session grants with challenge proof and authorization leases. +- Outbound authenticated worker jobs producing ZIP installation bundles from unchanged, hash-checked, Authenticode-verified release artifacts. Public customer downloads and authenticated technician downloads. +- Company release import and approval, stable/test selection, staged cohorts, pause and UTC maintenance windows. Endpoint updater checks signatures, hashes, publisher and installed executable identity, and excludes sessions during installation. +- Encrypted management backup and empty-directory restore. + +Installers contain public bootstrap information only. The worker pins both profile and project release verification keys locally. Release metadata must include package, installed executable and agent SHA-256 identities, exact source URL and publisher. No profile value becomes a shell command. + +The updater writes `pending-update.json` before installation and advances the sequence only after verifying the installed executable. `swan-agent recover-update` completes recovery only if signed installed identity matches; partial failures remain blocked for repair. Automatic repair/retry, rollback and updating the configuration agent itself remain incomplete. + +## Deployment and release gates + +See `deployment/windows/LOCAL-TESTING.md` for this machine's isolated test process. Windows service installation is in `deployment/windows/Install-Server.ps1`; Linux container definitions are in `deployment/linux/`. Management must sit behind trusted HTTPS before endpoint use. Companies supply DNS and publicly reachable transport or their own relay. Local tests do not prove internet reachability, NAT traversal or relay fallback. + +Still required: complete native graphical technician login/inventory/history, live logo/color branding, equivalent first-run deployment wizards and reachability diagnostics, executable/MSI packaging integration and repair, company signing integration, full transport-key backup, migrations with rollback, full update recovery and agent replacement, pinned native dependency artifacts, native builds and end-to-end sessions, and clean-machine installation/upgrade tests across every advertised Windows version. Windows 10/11 x64 and Windows Server 2016/2019/2022/2025 Desktop Experience remain intended targets, not verified compatibility claims. + +Never publish this development build as production. Signing-provider approval, valid production signatures, exact corresponding AGPL source and all acceptance checks are mandatory release gates. Retain RustDesk attribution and license notices when distributing changed binaries or hosting modified server code. diff --git a/product/agent/Cargo.toml b/product/agent/Cargo.toml new file mode 100644 index 00000000000..00beec22844 --- /dev/null +++ b/product/agent/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "swan-agent" +version.workspace = true +edition.workspace = true +license.workspace = true +rust-version.workspace = true + +[dependencies] +swan-protocol = { path = "../protocol" } +anyhow.workspace = true +serde.workspace = true +serde_json.workspace = true +base64.workspace = true +ed25519-dalek.workspace = true +rand.workspace = true +reqwest.workspace = true +tokio.workspace = true +sha2.workspace = true +hex.workspace = true +axum = "0.8" +fs2 = "0.4" + +[target.'cfg(windows)'.dependencies] +windows-sys = { version = "0.61", features = ["Win32_Storage_FileSystem"] } diff --git a/product/agent/src/lib.rs b/product/agent/src/lib.rs new file mode 100644 index 00000000000..a9dac2095cd --- /dev/null +++ b/product/agent/src/lib.rs @@ -0,0 +1,204 @@ +use anyhow::{ensure, Context, Result}; +use base64::{engine::general_purpose::STANDARD, Engine}; +use ed25519_dalek::SigningKey; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use std::path::{Path, PathBuf}; +use swan_protocol::*; +pub use swan_protocol as protocol; +pub mod update; + +#[derive(Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Bootstrap { + pub schema:u32, + pub edition:Edition, + pub company_id:String, + pub management_url:String, + pub profile_public_key:String, + pub release_public_key:String, +} +impl Bootstrap { + pub fn validate(&self)->Result<()> { + ensure!(self.schema==SCHEMA && !self.company_id.is_empty(),"Invalid bootstrap"); + https_url(&self.management_url)?;public_key(&self.profile_public_key)?; + if !self.release_public_key.is_empty(){public_key(&self.release_public_key)?;} + Ok(()) + } +} + +#[derive(Clone,Serialize,Deserialize)] +#[serde(deny_unknown_fields)] +pub struct AgentState { + pub bootstrap:Bootstrap, + pub profile:SignedEnvelope, + pub accepted_revision:u64, + pub device_id:Option, + pub device_token:Option, + pub unattended_consent:bool, + pub last_release_sequence:u64, +} +impl AgentState { + pub fn load(directory:&Path)->Result { + let state=Self::load_for_refresh(directory)?; + state.company_profile()?; + Ok(state) + } + // Expired cached policy may fetch a replacement, but cannot authorize sessions. + pub fn load_for_refresh(directory:&Path)->Result { + let state:Self=serde_json::from_slice(&std::fs::read(directory.join("managed-state.json"))?)?; + state.bootstrap.validate()?; + let profile:CompanyProfile=state.profile.verify(&public_key(&state.bootstrap.profile_public_key)?)?; + ensure!(profile.company_id==state.bootstrap.company_id && profile.revision>=state.accepted_revision,"Invalid cached company profile"); + Ok(state) + } + pub fn save(&self,directory:&Path)->Result<()> { + std::fs::create_dir_all(directory)?; + #[cfg(unix)] {use std::os::unix::fs::PermissionsExt; std::fs::set_permissions(directory,std::fs::Permissions::from_mode(0o700))?;} + let path=directory.join("managed-state.json"); + use fs2::FileExt; + let lock=std::fs::OpenOptions::new().create(true).read(true).write(true).open(directory.join("state.lock"))?; + lock.lock_exclusive()?; + let mut saved=self.clone(); + if path.exists(){ + let existing:AgentState=serde_json::from_slice(&std::fs::read(&path)?)?; + ensure!(existing.bootstrap.company_id==self.bootstrap.company_id,"Cannot change company ownership"); + ensure!(existing.accepted_revision<=self.accepted_revision,"A newer profile has already been saved"); + saved.last_release_sequence=saved.last_release_sequence.max(existing.last_release_sequence); + if existing.device_id.is_some() && !existing.unattended_consent {saved.unattended_consent=false;} + } + let temporary=directory.join(format!("state-{}.tmp",random_token())); + use std::io::Write; + let mut options=std::fs::OpenOptions::new();options.write(true).create_new(true); + #[cfg(unix)] {use std::os::unix::fs::OpenOptionsExt;options.mode(0o600);} + let mut file=options.open(&temporary)?;file.write_all(&serde_json::to_vec_pretty(&saved)?)?;file.sync_all()?; + drop(file); + replace_state(&temporary,&path)?;Ok(()) + } + pub fn company_profile(&self)->Result { + let p:CompanyProfile=self.profile.verify(&public_key(&self.bootstrap.profile_public_key)?)?; + p.validate(&self.bootstrap.company_id,self.accepted_revision,now())?; + Ok(p) + } + pub fn client(&self)->Result { + Ok(reqwest::Client::builder().timeout(std::time::Duration::from_secs(15)).redirect(reqwest::redirect::Policy::none()).https_only(true).build()?) + } + pub fn endpoint(&self,path:&str)->Result { + self.bootstrap.validate()?; + Ok(format!("{}/api/v1/{path}",self.bootstrap.management_url.trim_end_matches('/'))) + } + pub async fn sync(&mut self)->Result<()> { + let envelope:SignedEnvelope=self.client()?.get(self.endpoint("profile")?).send().await?.error_for_status()?.json().await?; + let key=public_key(&self.bootstrap.profile_public_key)?; + let profile:CompanyProfile=match envelope.verify(&key){ + Ok(p)=>p, + Err(error)=>{ + let old=self.company_profile()?; + let next=old.next_profile_public_key.as_ref().context("Profile signature invalid and no trusted key rotation")?; + let p=envelope.verify(&public_key(next)?)?; + // Commit the new pin only after every validation succeeds below. + let _:anyhow::Error=error; + p + } + }; + profile.validate(&self.bootstrap.company_id,self.accepted_revision,now())?; + if envelope.verify::(&key).is_err(){ + self.bootstrap.profile_public_key=self.company_profile()?.next_profile_public_key.context("Missing rotation key")?; + } + self.bootstrap.management_url=profile.management_url.clone(); + self.accepted_revision=profile.revision;self.profile=envelope; + Ok(()) + } + pub async fn enroll(&mut self,name:&str,rustdesk_id:&str,consent:bool)->Result<()> { + ensure!(self.bootstrap.edition==Edition::Customer,"Only customer edition enrolls devices"); + ensure!(self.device_id.is_none(),"Already enrolled"); + let profile=self.company_profile()?; + ensure!(!consent || profile.allow_unattended,"Company has disabled unattended access"); + let result:Value=self.client()?.post(self.endpoint("enroll")?).json(&json!({"name":name,"rustdesk_id":rustdesk_id,"unattended_consent":consent})).send().await?.error_for_status()?.json().await?; + ensure!(result["company_id"].as_str()==Some(self.bootstrap.company_id.as_str()),"Wrong company enrollment response"); + self.device_id=Some(result["device_id"].as_str().context("Missing device identity")?.into()); + self.device_token=Some(result["device_token"].as_str().context("Missing device credential")?.into()); + self.unattended_consent=consent;Ok(()) + } + pub async fn consent(&mut self,enabled:bool)->Result<()> { + // Local revoke takes effect even if the server is unavailable. + if !enabled {self.unattended_consent=false;} + ensure!(!enabled || self.company_profile()?.allow_unattended,"Company disallows unattended support"); + self.client()?.put(self.endpoint("device/consent")?).bearer_auth(self.device_token.as_ref().context("Not enrolled")?).json(&json!({"unattended":enabled})).send().await?.error_for_status()?; + self.unattended_consent=enabled;Ok(()) + } + pub async fn request_grant(&self,token:&str,device:&str,unattended:bool,key:&SigningKey)->Result { + ensure!(self.bootstrap.edition==Edition::Technician,"Technician edition required"); + let grant:SignedEnvelope=self.client()?.post(self.endpoint("grants")?).bearer_auth(token).json(&json!({"device_id":device,"proof_public_key":STANDARD.encode(key.verifying_key().as_bytes()),"unattended":unattended})).send().await?.error_for_status()?.json().await?; + let parsed:SessionGrant=grant.verify(&public_key(&self.bootstrap.profile_public_key)?)?; + parsed.validate(&self.bootstrap.company_id,device,&parsed.rustdesk_id,now())?; + Ok(grant) + } + pub async fn claim(&self,login:&ManagedLogin,challenge:&str,peer:&str)->Result { + let activity=lock_session(&state_directory())?; + ensure!(self.bootstrap.edition==Edition::Customer,"Customer edition required"); + let profile=self.company_profile()?; + let grant:SessionGrant=login.grant.verify(&public_key(&self.bootstrap.profile_public_key)?)?; + grant.validate(&self.bootstrap.company_id,self.device_id.as_ref().context("Not enrolled")?,peer,now())?; + login.verify_proof(&grant,challenge)?; + ensure!(!grant.unattended || (self.unattended_consent && profile.allow_unattended),"Unattended consent required"); + let result:Value=self.client()?.post(self.endpoint("grants/claim")?).bearer_auth(self.device_token.as_ref().context("Not enrolled")?).json(&json!({"grant":login.grant})).send().await?.error_for_status()?.json().await?; + let expires=result["lease_until"].as_u64().context("Missing lease expiry")?; + ensure!(expires>now() && expires<=now()+310,"Invalid session lease"); + Ok(Lease { grant_id:grant.grant_id,unattended:grant.unattended,expires_at:expires,last_renewed:now(),_activity:activity }) + } + pub async fn renew(&self,lease:&mut Lease)->Result<()> { + ensure!(!lease.unattended || self.unattended_consent,"Unattended consent revoked"); + let result:Value=self.client()?.post(self.endpoint(&format!("grants/{}/renew",lease.grant_id))?).bearer_auth(self.device_token.as_ref().context("Not enrolled")?).send().await?.error_for_status()?.json().await?; + let expires=result["lease_until"].as_u64().context("Missing lease expiry")?; + ensure!(expires>now() && expires<=now()+310,"Invalid session lease"); + lease.expires_at=expires;lease.last_renewed=now();Ok(()) + } +} + +fn replace_state(temporary:&Path,path:&Path)->Result<()> { + #[cfg(windows)] { + use std::os::windows::ffi::OsStrExt; + use windows_sys::Win32::Storage::FileSystem::{MoveFileExW,MOVEFILE_REPLACE_EXISTING,MOVEFILE_WRITE_THROUGH}; + let from:Vec=temporary.as_os_str().encode_wide().chain(Some(0)).collect(); + let to:Vec=path.as_os_str().encode_wide().chain(Some(0)).collect(); + // Replace without deleting the valid state first; a crash retains old or new. + if unsafe{MoveFileExW(from.as_ptr(),to.as_ptr(),MOVEFILE_REPLACE_EXISTING|MOVEFILE_WRITE_THROUGH)}==0 {return Err(std::io::Error::last_os_error().into());} + Ok(()) + } + #[cfg(not(windows))] {std::fs::rename(temporary,path)?;Ok(())} +} + +pub struct Lease { pub grant_id:String,pub unattended:bool,pub expires_at:u64,pub last_renewed:u64,_activity:std::fs::File } +pub fn activity_file(directory:&Path)->Result { + std::fs::create_dir_all(directory)?; + Ok(std::fs::OpenOptions::new().create(true).read(true).write(true).open(directory.join("activity.lock"))?) +} +pub fn lock_session(directory:&Path)->Result { + let file=activity_file(directory)?;fs2::FileExt::try_lock_shared(&file).context("An update is in progress")?;Ok(file) +} +pub fn state_directory()->PathBuf { + if let Some(directory)=std::env::var_os("SWAN_STATE_DIR"){return directory.into();} + #[cfg(windows)] {return PathBuf::from(std::env::var_os("PROGRAMDATA").unwrap_or_else(||"C:\\ProgramData".into())).join("SwanRemoteSupport");} + #[cfg(not(windows))] {PathBuf::from("/var/lib/swan-remote-support")} +} +pub async fn bootstrap(input:Bootstrap)->Result { + input.validate()?; + let client=reqwest::Client::builder().timeout(std::time::Duration::from_secs(15)).redirect(reqwest::redirect::Policy::none()).https_only(true).build()?; + let profile:SignedEnvelope=client.get(format!("{}/api/v1/profile",input.management_url.trim_end_matches('/'))).send().await?.error_for_status()?.json().await?; + let parsed:CompanyProfile=profile.verify(&public_key(&input.profile_public_key)?)?; + parsed.validate(&input.company_id,0,now())?; + Ok(AgentState {bootstrap:input,profile,accepted_revision:parsed.revision,device_id:None,device_token:None,unattended_consent:false,last_release_sequence:0}) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn bootstrap_never_accepts_http_or_embedded_credentials() { + let key=STANDARD.encode(SigningKey::from_bytes(&[1;32]).verifying_key().as_bytes()); + let mut b=Bootstrap{schema:1,edition:Edition::Customer,company_id:"a".into(),management_url:"https://support.example.com".into(),profile_public_key:key,release_public_key:String::new()}; + assert!(b.validate().is_ok());b.management_url="http://support.example.com".into();assert!(b.validate().is_err()); + b.management_url="https://secret@example.com".into();assert!(b.validate().is_err()); + } +} diff --git a/product/agent/src/main.rs b/product/agent/src/main.rs new file mode 100644 index 00000000000..a82930f806c --- /dev/null +++ b/product/agent/src/main.rs @@ -0,0 +1,88 @@ +use anyhow::{bail, ensure, Context, Result}; +use ed25519_dalek::SigningKey; +use rand::rngs::OsRng; +use serde_json::{json,Value}; +use std::path::PathBuf; +use swan_agent::*; +use swan_protocol::*; + +#[tokio::main] +async fn main()->Result<()> { + let args:Vec=std::env::args().collect(); + let directory=state_directory(); + let command=args.get(1).map(String::as_str).unwrap_or("help"); + match command { + "setup"=>{ + let file=args.get(2).context("Usage: swan-agent setup bootstrap.json --accept-company")?; + ensure!(args.iter().any(|a|a=="--accept-company"),"Review the company HTTPS domain and trust key; pass --accept-company only after confirming them"); + ensure!(!directory.join("managed-state.json").exists(),"Already configured; refuse to silently change company ownership"); + let input:Bootstrap=serde_json::from_slice(&std::fs::read(file)?)?; + println!("Connecting to company domain {}",input.management_url); + bootstrap(input).await?.save(&directory)?; + println!("Company verified and profile pinned."); + } + "enroll"=>{ + let name=args.get(2).context("Usage: swan-agent enroll COMPUTER_NAME RUSTDESK_ID [--unattended-consent]")?; + let peer=args.get(3).context("Missing RustDesk ID")?; + let mut state=AgentState::load(&directory)?; + state.enroll(name,peer,args.iter().any(|a|a=="--unattended-consent")).await?;state.save(&directory)?; + println!("Device enrolled. Company approval is required before remote access."); + } + "sync"=>{let mut state=AgentState::load_for_refresh(&directory)?;state.sync().await?;state.save(&directory)?;println!("Profile synchronized.");} + "watch"=>{loop {match AgentState::load_for_refresh(&directory){Ok(mut state)=>{ + match state.sync().await{Ok(())=>state.save(&directory)?,Err(error)=>eprintln!("Profile refresh failed: {error}")}; + if state.bootstrap.edition==Edition::Customer {if let Err(error)=state.update(&directory,None).await {eprintln!("Update deferred: {error}");}} + },Err(error)=>eprintln!("Configuration unavailable: {error}")};tokio::time::sleep(std::time::Duration::from_secs(300)).await;}} + "update"=>{ + let mut state=AgentState::load_for_refresh(&directory)?;state.sync().await?;state.save(&directory)?; + let token=std::env::var("SWAN_TECHNICIAN_TOKEN").ok(); + println!("Update installed: {}",state.update(&directory,token.as_deref()).await?); + } + #[cfg(windows)] + "recover-update"=>{let mut state=AgentState::load_for_refresh(&directory)?;state.recover_update(&directory)?;println!("Installed release verified and update state recovered.");} + #[cfg(windows)] + "verify-package"=>{ + let state=AgentState::load(&directory)?; + let metadata=args.get(2).context("Usage: swan-agent verify-package release.json INSTALLER")?; + let package=args.get(3).context("Missing installer path")?; + let envelope:SignedEnvelope=serde_json::from_slice(&std::fs::read(metadata)?)?; + swan_agent::update::verify_package(&state,&envelope,std::path::Path::new(package))?; + println!("Installer metadata, bytes and publisher verified."); + } + "revoke-unattended"=>{ + let mut state=AgentState::load(&directory)?;state.unattended_consent=false;state.save(&directory)?; + match state.consent(false).await {Ok(())=>println!("Unattended access revoked locally and on the server."),Err(_)=>println!("Unattended access revoked locally. Server synchronization will retry when available.")} + } + "login"=>{ + let state=AgentState::load(&directory)?; + ensure!(state.bootstrap.edition==Edition::Technician,"Technician edition required"); + let username=args.get(2).context("Usage: swan-agent login USERNAME (password and code from SWAN_LOGIN_PASSWORD and SWAN_LOGIN_TOTP)")?; + let password=std::env::var("SWAN_LOGIN_PASSWORD").context("Set SWAN_LOGIN_PASSWORD for this process only")?; + let code=std::env::var("SWAN_LOGIN_TOTP").context("Set SWAN_LOGIN_TOTP for this process only")?; + let result:Value=state.client()?.post(state.endpoint("login")?).json(&json!({"username":username,"password":password,"totp_code":code})).send().await?.error_for_status()?.json().await?; + // Explicit CLI exchange for automation. Never write tokens to logs or persistent profile files. + println!("{}",result); + } + "devices"=>{ + let state=AgentState::load(&directory)?; + let token=std::env::var("SWAN_TECHNICIAN_TOKEN").context("Set authenticated technician token")?; + let devices:Value=state.client()?.get(state.endpoint("devices")?).bearer_auth(token).send().await?.error_for_status()?.json().await?; + println!("{}",serde_json::to_string_pretty(&devices)?); + } + "connect"=>{ + let state=AgentState::load(&directory)?;let device=args.get(2).context("Usage: swan-agent connect DEVICE_ID APP_EXE [--unattended]")?; + let executable=PathBuf::from(args.get(3).context("Missing app executable")?); + ensure!(executable.is_file(),"App executable not found"); + let token=std::env::var("SWAN_TECHNICIAN_TOKEN").context("Authenticated technician token required")?; + let proof=SigningKey::generate(&mut OsRng); + let envelope=state.request_grant(&token,device,args.iter().any(|a|a=="--unattended"),&proof).await?; + let grant:SessionGrant=envelope.verify(&public_key(&state.bootstrap.profile_public_key)?)?; + let mut child=std::process::Command::new(executable).arg("--connect").arg(&grant.rustdesk_id) + .env("SWAN_STATE_DIR",&directory).env("SWAN_SESSION_GRANT",serde_json::to_string(&envelope)?) + .env("SWAN_SESSION_PROOF_KEY",hex::encode(proof.to_bytes())).spawn()?; + ensure!(child.wait()?.success(),"Technician application exited unsuccessfully"); + } + _=>{println!("Swan Remote Support configuration agent\nCommands: setup, enroll, sync, watch, update, revoke-unattended, login, devices, connect\nState directory: {}",directory.display());if command!="help"{bail!("Unknown command");}} + } + Ok(()) +} diff --git a/product/agent/src/update.rs b/product/agent/src/update.rs new file mode 100644 index 00000000000..2f8fc08aaec --- /dev/null +++ b/product/agent/src/update.rs @@ -0,0 +1,133 @@ +//! Company-approved endpoint updates. No package code executes before metadata, +//! bytes, publisher and session exclusion have been checked. +use crate::*; +use anyhow::{ensure,Context,Result}; +use serde::{Deserialize,Serialize}; +use std::path::Path; + +#[derive(Serialize,Deserialize)] +#[serde(deny_unknown_fields)] +struct Receipt { release:SignedEnvelope, previous_sequence:u64, phase:String } + +pub fn validate_release(state:&AgentState,envelope:&SignedEnvelope)->Result { + let profile=state.company_profile()?; + let release:Release=envelope.verify(&public_key(&state.bootstrap.release_public_key)?)?; + release.validate(&state.bootstrap.edition,state.last_release_sequence,now())?; + ensure!(release.channel==profile.update_channel,"Wrong update channel"); + Ok(release) +} + +async fn download(url:&str,hash:&str,path:&Path)->Result<()> { + https_url(url)?; + // Artifact endpoints may redirect to HTTPS object storage. No credentials + // are attached, and reqwest rejects any redirect to plain HTTP. + let client=reqwest::Client::builder().https_only(true).timeout(std::time::Duration::from_secs(600)) + .redirect(reqwest::redirect::Policy::limited(5)).build()?; + let mut response=client.get(url).send().await?.error_for_status()?; + ensure!(response.content_length().unwrap_or(0)<=512*1024*1024,"Package too large"); + let mut bytes=Vec::new(); + while let Some(chunk)=response.chunk().await? {ensure!(bytes.len()+chunk.len()<=512*1024*1024,"Package too large");bytes.extend_from_slice(&chunk);} + ensure!(digest(&bytes).eq_ignore_ascii_case(hash),"Package hash mismatch"); + std::fs::write(path,bytes)?;Ok(()) +} + +#[cfg(windows)] +fn verify_publisher(path:&Path,release:&Release)->Result<()> { + let script=path.parent().context("Missing update directory")?.join("Verify-Package.ps1"); + std::fs::write(&script,include_str!("../../../deployment/windows/Verify-Package.ps1"))?; + let status=std::process::Command::new("powershell.exe").args(["-NoLogo","-NoProfile","-NonInteractive","-ExecutionPolicy","Bypass","-File"]) + .arg(script).arg("-Path").arg(path).arg("-Publisher").arg(&release.publisher).arg("-Sha256").arg(&release.sha256).status()?; + ensure!(status.success(),"Package publisher verification failed");Ok(()) +} + +#[cfg(windows)] +pub fn verify_package(state:&AgentState,envelope:&SignedEnvelope,path:&Path)->Result<()> { + let release=validate_release(state,envelope)?; + ensure!(digest(std::fs::read(path)?).eq_ignore_ascii_case(&release.sha256),"Installer hash differs from signed metadata"); + verify_publisher(path,&release) +} + +impl AgentState { + #[cfg(windows)] + pub fn recover_update(&mut self,directory:&Path)->Result<()> { + let activity=activity_file(directory)?;fs2::FileExt::try_lock_exclusive(&activity).context("Recovery requires all sessions to close")?; + let receipt_path=directory.join("pending-update.json"); + let receipt:Receipt=serde_json::from_slice(&std::fs::read(&receipt_path)?)?; + let release:Release=receipt.release.verify(&public_key(&self.bootstrap.release_public_key)?)?; + ensure!(release.edition==self.bootstrap.edition && release.product==PRODUCT && release.schema==SCHEMA,"Wrong recovery release"); + ensure!(self.last_release_sequence==receipt.previous_sequence || self.last_release_sequence==release.sequence,"Recovery sequence conflict"); + verify_installed(directory,&release)?; + // A crashed updater may have installed successfully or saved state before + // removing the receipt. Exact signed executable identity proves either case. + self.last_release_sequence=release.sequence;self.save(directory)?; + std::fs::remove_file(receipt_path)?;Ok(()) + } + pub async fn update(&mut self,directory:&Path,technician_token:Option<&str>)->Result { + #[cfg(not(windows))] {let _=(directory,technician_token);anyhow::bail!("Endpoint updates require Windows");} + #[cfg(windows)] { + let profile=self.company_profile()?; + if profile.updates_paused || !maintenance_open(profile.maintenance_start_utc,profile.maintenance_end_utc,now()){return Ok(false);} + let receipt_path=directory.join("pending-update.json"); + // An interrupted installation is deliberately not guessed successful. + // Keep the signed receipt so recovery can verify the installed build. + ensure!(!receipt_path.exists(),"Interrupted update requires recovery before another installation"); + let (path,token)=if self.bootstrap.edition==Edition::Customer {("device/update",self.device_token.as_deref().context("Not enrolled")?)}else{("user/update",technician_token.context("Technician authentication required")?)}; + let value:Value=self.client()?.get(self.endpoint(path)?).bearer_auth(token).send().await?.error_for_status()?.json().await?; + if value.is_null(){return Ok(false);} + let envelope:SignedEnvelope=serde_json::from_value(value)?; + let release=validate_release(self,&envelope)?; + ensure!(release.edition!=Edition::Technician || (release.format=="exe" && release.installed_sha256.eq_ignore_ascii_case(&release.sha256)),"Technician updates require a portable EXE with matching installed identity"); + let folder=directory.join("updates").join(release.sequence.to_string());std::fs::create_dir_all(&folder)?; + let package=folder.join(format!("SwanRemoteSupport-install.{}",release.format)); + download(&release.artifact_url,&release.sha256,&package).await?; + verify_publisher(&package,&release)?; + let activity=activity_file(directory)?; + fs2::FileExt::try_lock_exclusive(&activity).context("Update deferred while a session or technician app is active")?; + // Re-read consent and enrollment immediately before installation. + let mut latest=AgentState::load(directory)?; + validate_release(&latest,&envelope)?; + let receipt=Receipt{release:envelope,previous_sequence:latest.last_release_sequence,phase:"installing".into()}; + let mut options=std::fs::OpenOptions::new();options.create_new(true).write(true); + use std::io::Write; + let mut file=options.open(&receipt_path)?;file.write_all(&serde_json::to_vec(&receipt)?)?;file.sync_all()?;drop(file); + if latest.bootstrap.edition==Edition::Technician { + ensure!(release.format=="exe","Technician portable updates require EXE artifacts"); + let target=directory.join("SwanRemoteSupport-Technician.exe"); + if target.exists(){std::fs::copy(&target,directory.join("SwanRemoteSupport-Technician.previous.exe"))?;} + std::fs::copy(&package,&target)?; + }else{ + let package_for_install=package.clone();let format=release.format.clone(); + let status=tokio::task::spawn_blocking(move || ->Result{ + let mut command=if format=="msi" {let mut c=std::process::Command::new("msiexec.exe");c.arg("/i").arg(&package_for_install).args(["/qn","/norestart"]);c}else{let mut c=std::process::Command::new(package_for_install);c.args(["--silent-install","printer=0"]);c}; + Ok(command.status()?) + }).await??; + ensure!(matches!(status.code(),Some(0|3010)),"Installation failed; retain signed recovery receipt"); + } + verify_installed(directory,&release)?; + latest.last_release_sequence=release.sequence;latest.save(directory)?; + std::fs::remove_file(receipt_path)?;*self=latest;Ok(true) + } + } +} + +#[cfg(windows)] +fn verify_installed(directory:&Path,release:&Release)->Result<()> { + let target=if release.edition==Edition::Technician {directory.join("SwanRemoteSupport-Technician.exe")}else{ + std::path::PathBuf::from(std::env::var_os("ProgramFiles").context("ProgramFiles unavailable")?).join("Swan Remote Support/Swan Remote Support.exe") + }; + ensure!(digest(std::fs::read(&target)?).eq_ignore_ascii_case(&release.installed_sha256),"Installed executable differs from signed release; recovery remains pending"); + let mut installed=release.clone();installed.sha256=release.installed_sha256.clone();verify_publisher(&target,&installed) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn update_lock_excludes_sessions_and_releases_on_close() { + let folder=std::env::temp_dir().join(format!("swan-lock-{}",random_token())); + let session=lock_session(&folder).unwrap();let update=activity_file(&folder).unwrap(); + assert!(fs2::FileExt::try_lock_exclusive(&update).is_err());drop(session); + fs2::FileExt::try_lock_exclusive(&update).unwrap();assert!(lock_session(&folder).is_err());drop(update); + assert!(lock_session(&folder).is_ok());std::fs::remove_dir_all(folder).unwrap(); + } +} diff --git a/product/management/Cargo.toml b/product/management/Cargo.toml new file mode 100644 index 00000000000..35b2dad9abc --- /dev/null +++ b/product/management/Cargo.toml @@ -0,0 +1,34 @@ +[package] +name = "swan-management" +version.workspace = true +edition.workspace = true +license.workspace = true +rust-version.workspace = true + +[dependencies] +swan-protocol = { path = "../protocol" } +swan-agent = { path = "../agent" } +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 +tokio.workspace = true +uuid.workspace = true +axum = "0.8" +rusqlite = { version = "0.32", features = ["bundled", "backup"] } +aes-gcm = "0.10" +zip = { version = "2", default-features = false, features = ["deflate"] } +argon2 = "0.5" +totp-rs = { version = "5", features = ["gen_secret"] } +tower-http = { version = "0.6", features = ["limit", "set-header"] } + +[target.'cfg(windows)'.dependencies] +windows-service = "0.8" + +[dev-dependencies] +tower = { version = "0.5", features = ["util"] } +http-body-util = "0.1" diff --git a/product/management/src/backup.rs b/product/management/src/backup.rs new file mode 100644 index 00000000000..20e42d6cd16 --- /dev/null +++ b/product/management/src/backup.rs @@ -0,0 +1,75 @@ +use aes_gcm::{aead::{Aead,KeyInit},Aes256Gcm,Nonce}; +use anyhow::{ensure,Context,Result}; +use rand::RngCore; +use std::{io::{Cursor,Read,Write},path::Path}; + +const HEADER:&[u8]=b"SWANBK1"; +fn key(password:&str,salt:&[u8])->Result<[u8;32]> { + ensure!(password.len()>=16 && password.len()<=1024,"Backup passphrase must contain 16โ€“1024 characters"); + let mut key=[0u8;32];argon2::Argon2::default().hash_password_into(password.as_bytes(),salt,&mut key).map_err(|_|anyhow::anyhow!("Backup key derivation failed"))?;Ok(key) +} +pub fn export(data:&Path,destination:&Path,password:&str)->Result<()> { + ensure!(!destination.exists(),"Refusing to overwrite an existing backup"); + let db=rusqlite::Connection::open(data.join("management.sqlite3"))?; + let snapshot=data.join(format!("backup-{}.sqlite3",swan_protocol::random_token())); + db.backup(rusqlite::DatabaseName::Main,&snapshot,None)?; + let result=(||->Result<()> { + let mut archive=zip::ZipWriter::new(Cursor::new(Vec::new())); + let options=zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated); + for (name,path) in [("management.sqlite3",snapshot.as_path()),("profile-key.hex",data.join("profile-key.hex").as_path()),("setup-token.txt",data.join("setup-token.txt").as_path())] { + archive.start_file(name,options)?;archive.write_all(&std::fs::read(path)?)?; + } + let bytes=archive.finish()?.into_inner(); + let mut salt=[0u8;16];let mut nonce=[0u8;12];rand::rngs::OsRng.fill_bytes(&mut salt);rand::rngs::OsRng.fill_bytes(&mut nonce); + let cipher=Aes256Gcm::new_from_slice(&key(password,&salt)?).map_err(|_|anyhow::anyhow!("Invalid backup encryption key"))?; + let encrypted=cipher.encrypt(Nonce::from_slice(&nonce),bytes.as_ref()).map_err(|_|anyhow::anyhow!("Backup encryption failed"))?; + let mut options=std::fs::OpenOptions::new();options.create_new(true).write(true); + #[cfg(unix)] {use std::os::unix::fs::OpenOptionsExt;options.mode(0o600);} + let mut file=options.open(destination)?;file.write_all(HEADER)?;file.write_all(&salt)?;file.write_all(&nonce)?;file.write_all(&encrypted)?;file.sync_all()?;Ok(()) + })(); + std::fs::remove_file(&snapshot).context("Remove plaintext database snapshot after backup")?; + result +} +pub fn restore(data:&Path,source:&Path,password:&str)->Result<()> { + ensure!(!data.exists() || std::fs::read_dir(data)?.next().is_none(),"Restore requires an empty target directory and a stopped management service"); + let bytes=std::fs::read(source)?; + ensure!(bytes.len()>HEADER.len()+28 && bytes.starts_with(HEADER),"Invalid backup format"); + let offset=HEADER.len();let salt=&bytes[offset..offset+16];let nonce=&bytes[offset+16..offset+28]; + let cipher=Aes256Gcm::new_from_slice(&key(password,salt)?).map_err(|_|anyhow::anyhow!("Invalid decryption key"))?; + let plaintext=cipher.decrypt(Nonce::from_slice(nonce),&bytes[offset+28..]).map_err(|_|anyhow::anyhow!("Backup password incorrect or backup modified"))?; + let mut archive=zip::ZipArchive::new(Cursor::new(plaintext))?; + ensure!(archive.len()==3,"Unexpected backup contents"); + // Validate all contents before creating anything. Never extract arbitrary archive paths. + let mut files=Vec::new(); + for name in ["management.sqlite3","profile-key.hex","setup-token.txt"] { + let mut entry=archive.by_name(name)?;ensure!(entry.size()<=512*1024*1024,"Backup entry too large"); + let mut content=Vec::new();entry.read_to_end(&mut content)?;files.push((name,content)); + } + std::fs::create_dir_all(data)?; + #[cfg(unix)] {use std::os::unix::fs::PermissionsExt;std::fs::set_permissions(data,std::fs::Permissions::from_mode(0o700))?;} + for (name,content) in files { + let mut options=std::fs::OpenOptions::new();options.create_new(true).write(true); + #[cfg(unix)] {use std::os::unix::fs::OpenOptionsExt;options.mode(0o600);} + let mut file=options.open(data.join(name))?;file.write_all(&content)?;file.sync_all()?; + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn backup_roundtrip_rejects_wrong_password_and_nonempty_restore() { + let root=std::env::temp_dir().join(format!("swan-backup-{}",swan_protocol::random_token())); + let data=root.join("data");std::fs::create_dir_all(&data).unwrap(); + let db=rusqlite::Connection::open(data.join("management.sqlite3")).unwrap();db.execute_batch("CREATE TABLE evidence(value TEXT);INSERT INTO evidence VALUES('preserved');").unwrap();drop(db); + std::fs::write(data.join("profile-key.hex"),"key").unwrap();std::fs::write(data.join("setup-token.txt"),"token").unwrap(); + let archive=root.join("backup.swan");export(&data,&archive,"long backup passphrase").unwrap(); + assert!(restore(&root.join("wrong"),&archive,"different long password").is_err()); + restore(&root.join("restored"),&archive,"long backup passphrase").unwrap(); + let db=rusqlite::Connection::open(root.join("restored/management.sqlite3")).unwrap(); + assert_eq!(db.query_row("SELECT value FROM evidence",[],|r|r.get::<_,String>(0)).unwrap(),"preserved");drop(db); + assert!(restore(&root.join("restored"),&archive,"long backup passphrase").is_err()); + std::fs::remove_dir_all(root).unwrap(); + } +} diff --git a/product/management/src/main.rs b/product/management/src/main.rs new file mode 100644 index 00000000000..dc52b4cda39 --- /dev/null +++ b/product/management/src/main.rs @@ -0,0 +1,40 @@ +mod service; +mod backup; +#[cfg(test)] +mod tests; +use anyhow::{Context, Result}; +use axum::Router; +use service::Store; +use std::{net::SocketAddr, path::PathBuf, sync::Arc}; +#[cfg(windows)] +mod windows; + +fn main() -> Result<()> { + #[cfg(windows)] + if std::env::args().any(|a|a=="--service") {return windows::dispatch();} + // This is the executable entrypoint, not a helper within an existing runtime. + let runtime=tokio::runtime::Runtime::new()?; + runtime.block_on(async { + let (sender,receiver)=tokio::sync::oneshot::channel(); + tokio::spawn(async move {if tokio::signal::ctrl_c().await.is_ok(){let _=sender.send(());}}); + run(receiver).await + }) +} + +async fn run(stop:tokio::sync::oneshot::Receiver<()>)->Result<()> { + let data = PathBuf::from(std::env::var("SWAN_DATA_DIR").unwrap_or_else(|_| "swan-data".into())); + let args:Vec=std::env::args().collect(); + if matches!(args.get(1).map(String::as_str),Some("backup"|"restore")) { + let path=PathBuf::from(args.get(2).context("Usage: swan-management backup|restore FILE")?); + let passphrase=std::env::var("SWAN_BACKUP_PASSPHRASE").context("Set SWAN_BACKUP_PASSPHRASE for this process")?; + if args[1]=="backup" {backup::export(&data,&path,&passphrase)?;} else {backup::restore(&data,&path,&passphrase)?;} + println!("Encrypted {} completed.",args[1]);return Ok(()); + } + let store = Arc::new(Store::open(&data)?); + let address: SocketAddr = std::env::var("SWAN_LISTEN").unwrap_or_else(|_| "127.0.0.1:8080".into()).parse()?; + let app: Router = service::router(store); + let listener = tokio::net::TcpListener::bind(address).await.context("Bind management listener")?; + println!("Swan Remote Support management listening on {address}; secrets stored under {}",data.display()); + axum::serve(listener, app.into_make_service_with_connect_info::()).with_graceful_shutdown(async {let _=stop.await;}).await?; + Ok(()) +} diff --git a/product/management/src/schema.sql b/product/management/src/schema.sql new file mode 100644 index 00000000000..e43655b1180 --- /dev/null +++ b/product/management/src/schema.sql @@ -0,0 +1,11 @@ +CREATE TABLE IF NOT EXISTS profile(id INTEGER PRIMARY KEY CHECK(id=1),body TEXT NOT NULL); +CREATE TABLE IF NOT EXISTS users(id TEXT PRIMARY KEY,username TEXT NOT NULL UNIQUE,password_hash TEXT NOT NULL,totp_secret TEXT NOT NULL,last_totp_step INTEGER NOT NULL DEFAULT 0,role TEXT NOT NULL CHECK(role IN ('admin','technician')),disabled INTEGER NOT NULL DEFAULT 0); +CREATE TABLE IF NOT EXISTS sessions(token_hash TEXT PRIMARY KEY,user_id TEXT NOT NULL REFERENCES users(id),expires_at INTEGER NOT NULL); +CREATE TABLE IF NOT EXISTS devices(id TEXT PRIMARY KEY,name TEXT NOT NULL,rustdesk_id TEXT NOT NULL UNIQUE,token_hash TEXT NOT NULL UNIQUE,unattended INTEGER NOT NULL DEFAULT 0,state TEXT NOT NULL CHECK(state IN ('pending','approved','revoked')),group_id TEXT NOT NULL); +CREATE TABLE IF NOT EXISTS group_access(group_id TEXT NOT NULL,user_id TEXT NOT NULL REFERENCES users(id),PRIMARY KEY(group_id,user_id)); +CREATE TABLE IF NOT EXISTS grants(id TEXT PRIMARY KEY,user_id TEXT NOT NULL REFERENCES users(id),device_id TEXT NOT NULL REFERENCES devices(id),body TEXT NOT NULL,expires_at INTEGER NOT NULL,claimed INTEGER NOT NULL DEFAULT 0,closed INTEGER NOT NULL DEFAULT 0,lease_until INTEGER NOT NULL DEFAULT 0); +CREATE TABLE IF NOT EXISTS audit(id INTEGER PRIMARY KEY,at INTEGER NOT NULL,actor TEXT NOT NULL,event TEXT NOT NULL,target TEXT NOT NULL); +CREATE TABLE IF NOT EXISTS releases(id TEXT PRIMARY KEY,body TEXT NOT NULL,envelope TEXT NOT NULL,approved INTEGER NOT NULL DEFAULT 0); +CREATE TABLE IF NOT EXISTS workers(id TEXT PRIMARY KEY,token_hash TEXT NOT NULL UNIQUE,disabled INTEGER NOT NULL DEFAULT 0); +CREATE TABLE IF NOT EXISTS builds(id TEXT PRIMARY KEY,body TEXT NOT NULL,state TEXT NOT NULL,worker TEXT NOT NULL DEFAULT '',claimed_at INTEGER NOT NULL DEFAULT 0,result TEXT NOT NULL DEFAULT ''); +PRAGMA user_version=1; diff --git a/product/management/src/service.rs b/product/management/src/service.rs new file mode 100644 index 00000000000..32117889ca5 --- /dev/null +++ b/product/management/src/service.rs @@ -0,0 +1,469 @@ +use anyhow::ensure; +use argon2::{password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString}, Argon2}; +use axum::{extract::{DefaultBodyLimit, Path, State}, http::{header, HeaderMap, StatusCode}, response::{Html, IntoResponse, Response}, routing::{get, post, put}, Json, Router}; +use base64::{engine::general_purpose::STANDARD, Engine}; +use ed25519_dalek::SigningKey; +use rand::rngs::OsRng; +use rusqlite::{params, Connection, OptionalExtension}; +use serde::Deserialize; +use serde_json::{json, Value}; +use std::{collections::HashMap, path::{Path as FsPath,PathBuf}, sync::{Arc, Mutex}}; +use swan_protocol::*; +use totp_rs::{Algorithm, Secret, TOTP}; +use uuid::Uuid; + +pub struct Store { + db: Mutex, + key: SigningKey, + setup_hash: String, + limits: Mutex>, + artifact_dir: PathBuf, +} +type Shared = Arc; +type ApiResult = Result, ApiError>; +struct ApiError(StatusCode, &'static str); +impl IntoResponse for ApiError { + fn into_response(self) -> Response { (self.0,Json(json!({"error":self.1}))).into_response() } +} +impl From for ApiError { + fn from(error:anyhow::Error)->Self { eprintln!("Management operation failed: {}",error.root_cause()); Self(StatusCode::INTERNAL_SERVER_ERROR,"Operation failed") } +} +impl From for ApiError { + fn from(error:rusqlite::Error)->Self { anyhow::Error::from(error).into() } +} +impl From for ApiError { + fn from(error:serde_json::Error)->Self { anyhow::Error::from(error).into() } +} +fn bad(message:&'static str)->ApiError { ApiError(StatusCode::BAD_REQUEST,message) } +fn denied()->ApiError { ApiError(StatusCode::FORBIDDEN,"Access denied") } +fn unauthorized()->ApiError { ApiError(StatusCode::UNAUTHORIZED,"Authentication required") } +fn token(headers:&HeaderMap)->Result<&str,ApiError> { + headers.get(header::AUTHORIZATION).and_then(|v|v.to_str().ok()).and_then(|v|v.strip_prefix("Bearer ")).filter(|v|v.len()>=32 && v.len()<=256).ok_or_else(unauthorized) +} +fn password_hash(password:&str)->anyhow::Result { + ensure!(password.len()>=14 && password.len()<=256,"Password must contain 14โ€“256 characters"); + Ok(Argon2::default().hash_password(password.as_bytes(),&SaltString::generate(&mut OsRng)).map_err(|_|anyhow::anyhow!("Password hashing failed"))?.to_string()) +} +fn totp(secret:&str)->anyhow::Result { + Ok(TOTP::new(Algorithm::SHA1,6,1,30,Secret::Encoded(secret.into()).to_bytes().map_err(|_|anyhow::anyhow!("Invalid TOTP secret"))?).map_err(|_|anyhow::anyhow!("Invalid TOTP parameters"))?) +} +fn totp_step(secret:&str,code:&str,time:u64)->anyhow::Result { + ensure!(code.len()==6 && code.bytes().all(|b|b.is_ascii_digit()),"Invalid code"); + let generator=totp(secret)?; + for step in [(time/30).saturating_sub(1),time/30,time/30+1] { + if generator.generate(step*30)==code { return Ok(step); } + } + anyhow::bail!("Invalid code") +} + +impl Store { + pub fn open(directory:&FsPath)->anyhow::Result { + std::fs::create_dir_all(directory)?; + #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; std::fs::set_permissions(directory,std::fs::Permissions::from_mode(0o700))?; } + let key_path=directory.join("profile-key.hex"); + if !key_path.exists() { write_secret(&key_path,&hex::encode(SigningKey::generate(&mut OsRng).to_bytes()))?; } + let key_bytes:[u8;32]=hex::decode(std::fs::read_to_string(&key_path)?.trim())?.try_into().map_err(|_|anyhow::anyhow!("Invalid signing key"))?; + let setup_path=directory.join("setup-token.txt"); + if !setup_path.exists() { write_secret(&setup_path,&random_token())?; } + let setup_hash=digest(std::fs::read_to_string(setup_path)?.trim()); + let db=Connection::open(directory.join("management.sqlite3"))?; + db.pragma_update(None,"journal_mode","WAL")?; + db.pragma_update(None,"foreign_keys","ON")?; + db.busy_timeout(std::time::Duration::from_secs(5))?; + db.execute_batch(include_str!("schema.sql"))?; + let artifact_dir=directory.join("artifacts");std::fs::create_dir_all(&artifact_dir)?; + Ok(Self {db:Mutex::new(db),key:SigningKey::from_bytes(&key_bytes),setup_hash,limits:Mutex::new(HashMap::new()),artifact_dir}) + } + fn profile(&self)->anyhow::Result { + let raw:String=self.db.lock().unwrap().query_row("SELECT body FROM profile WHERE id=1",[],|r|r.get(0))?; + Ok(serde_json::from_str(&raw)?) + } + fn user(&self,headers:&HeaderMap,admin:bool)->Result { + let hash=digest(token(headers)?); + let result:Option<(String,String)>=self.db.lock().unwrap().query_row("SELECT u.id,u.role FROM sessions s JOIN users u ON u.id=s.user_id WHERE s.token_hash=?1 AND s.expires_at>?2 AND u.disabled=0",params![hash,now()],|r|Ok((r.get(0)?,r.get(1)?))).optional()?; + match result { Some((id,role)) if !admin || role=="admin" => Ok(id),_=>Err(unauthorized()) } + } + fn device(&self,headers:&HeaderMap)->Result { + self.db.lock().unwrap().query_row("SELECT id FROM devices WHERE token_hash=?1 AND state='approved'",[digest(token(headers)?)],|r|r.get(0)).optional()?.ok_or_else(unauthorized) + } + fn worker(&self,headers:&HeaderMap)->Result<(),ApiError> { + let found:bool=self.db.lock().unwrap().query_row("SELECT EXISTS(SELECT 1 FROM workers WHERE token_hash=?1 AND disabled=0)",[digest(token(headers)?)],|r|r.get(0))?; + if found {Ok(())} else {Err(unauthorized())} + } + fn audit(&self,actor:&str,event:&str,target:&str)->Result<(),ApiError> { + self.db.lock().unwrap().execute("INSERT INTO audit(at,actor,event,target) VALUES(?1,?2,?3,?4)",params![now(),actor,event,target])?; + Ok(()) + } + fn rate(&self,key:&str,maximum:u32)->Result<(),ApiError> { + let mut limits=self.limits.lock().unwrap(); + let time=now(); limits.retain(|_,v|time.saturating_sub(v.0)<300); + if limits.len()>10000 { return Err(ApiError(StatusCode::TOO_MANY_REQUESTS,"Retry later")); } + let value=limits.entry(key.into()).or_insert((time,0)); + if time.saturating_sub(value.0)>=60 { *value=(time,0); } + value.1+=1; + if value.1>maximum {Err(ApiError(StatusCode::TOO_MANY_REQUESTS,"Retry later"))} else {Ok(())} + } +} +fn write_secret(path:&FsPath,value:&str)->anyhow::Result<()> { + use std::io::Write; + let mut options=std::fs::OpenOptions::new(); options.write(true).create_new(true); + #[cfg(unix)] {use std::os::unix::fs::OpenOptionsExt;options.mode(0o600);} + let mut file=options.open(path)?; file.write_all(value.as_bytes())?;file.sync_all()?;Ok(()) +} + +pub fn router(store:Shared)->Router { + Router::new() + .route("/",get(||async{Html(include_str!("../web/index.html"))})) + .route("/app.js",get(||async{([(header::CONTENT_TYPE,"text/javascript; charset=utf-8")],include_str!("../web/app.js"))})) + .route("/style.css",get(||async{([(header::CONTENT_TYPE,"text/css")],include_str!("../web/style.css"))})) + .route("/health",get(||async{Json(json!({"status":"ok","product":PRODUCT}))})) + .route("/api/v1/status",get(status)) + .route("/api/v1/setup",post(setup)) + .route("/api/v1/login",post(login)) + .route("/api/v1/logout",post(logout)) + .route("/api/v1/profile",get(profile).put(save_profile)) + .route("/api/v1/users",get(users).post(create_user)) + .route("/api/v1/users/{id}/disable",post(disable_user)) + .route("/api/v1/enroll",post(enroll)) + .route("/api/v1/devices",get(devices)) + .route("/api/v1/devices/{id}/state",put(device_state)) + .route("/api/v1/device/consent",put(consent)) + .route("/api/v1/groups/{group}/users/{user}",put(group_access)) + .route("/api/v1/grants",post(grant)) + .route("/api/v1/grants/claim",post(claim)) + .route("/api/v1/grants/{id}/renew",post(renew)) + .route("/api/v1/grants/{id}/close",post(close_grant)) + .route("/api/v1/audit",get(audit)) + .route("/api/v1/releases",get(releases).post(import_release)) + .route("/api/v1/releases/{id}/approve",post(approve_release)) + .route("/api/v1/device/update",get(device_update)) + .route("/api/v1/user/update",get(technician_update)) + .route("/api/v1/workers",post(create_worker)) + .route("/api/v1/builds",get(builds).post(create_build)) + .route("/api/v1/worker/claim",post(claim_build)) + .route("/api/v1/worker/builds/{id}",put(finish_build)) + .route("/api/v1/worker/builds/{id}/artifact",put(upload_artifact).layer(DefaultBodyLimit::max(512*1024*1024))) + .route("/api/v1/downloads/{id}",get(download_artifact)) + .layer(DefaultBodyLimit::max(1024*1024)) + .layer(tower_http::set_header::SetResponseHeaderLayer::overriding(header::HeaderName::from_static("x-content-type-options"),header::HeaderValue::from_static("nosniff"))) + .layer(tower_http::set_header::SetResponseHeaderLayer::overriding(header::HeaderName::from_static("content-security-policy"),header::HeaderValue::from_static("default-src 'self'; img-src 'self' data:; script-src 'self'; style-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'"))) + .layer(tower_http::set_header::SetResponseHeaderLayer::overriding(header::CACHE_CONTROL,header::HeaderValue::from_static("no-store"))) + .with_state(store) +} +async fn status(State(s):State)->ApiResult { + let configured=s.db.lock().unwrap().query_row("SELECT EXISTS(SELECT 1 FROM profile)",[],|r|r.get::<_,bool>(0))?; + Ok(Json(json!({"configured":configured,"profile_public_key":STANDARD.encode(s.key.verifying_key().as_bytes())}))) +} +#[derive(Deserialize)] +struct Setup { profile:CompanyProfile, username:String,password:String,totp_secret:String,totp_code:String } +async fn setup(State(s):State,headers:HeaderMap,Json(mut input):Json)->ApiResult { + s.rate("setup",5)?; + if digest(token(&headers)?)!=s.setup_hash {return Err(denied());} + validate_username(&input.username)?; + let step=totp_step(&input.totp_secret,&input.totp_code,now()).map_err(|_|bad("Invalid authenticator code"))?; + let hash=password_hash(&input.password).map_err(|_|bad("Password must contain 14โ€“256 characters"))?; + input.profile.company_id=Uuid::new_v4().to_string(); input.profile.revision=1; + input.profile.issued_at=now();input.profile.expires_at=now()+86400*30; + input.profile.validate(&input.profile.company_id,0,now()).map_err(|_|bad("Invalid company profile"))?; + let mut db=s.db.lock().unwrap();let transaction=db.transaction()?; + let exists:bool=transaction.query_row("SELECT EXISTS(SELECT 1 FROM profile)",[],|r|r.get(0))?; + if exists {return Err(ApiError(StatusCode::CONFLICT,"Already configured"));} + let user=Uuid::new_v4().to_string(); + transaction.execute("INSERT INTO profile(id,body) VALUES(1,?1)",[serde_json::to_string(&input.profile)?])?; + transaction.execute("INSERT INTO users(id,username,password_hash,totp_secret,last_totp_step,role) VALUES(?1,?2,?3,?4,?5,'admin')",params![user,input.username,hash,input.totp_secret,step])?; + transaction.execute("INSERT INTO audit(at,actor,event,target) VALUES(?1,?2,'company.setup',?3)",params![now(),user,input.profile.company_id])?; + transaction.commit()?; + Ok(Json(json!({"company_id":input.profile.company_id}))) +} +fn validate_username(name:&str)->Result<(),ApiError> { + if name.len()<3 || name.len()>64 || !name.bytes().all(|b|b.is_ascii_alphanumeric() || b"._-@".contains(&b)) {Err(bad("Invalid username"))} else {Ok(())} +} +#[derive(Deserialize)] +struct Login { username:String,password:String,totp_code:String } +async fn login(State(s):State,Json(input):Json)->ApiResult { + s.rate("login-global",60)?;s.rate(&format!("login:{}",input.username),5)?; + validate_username(&input.username)?; + let record:Option<(String,String,String,u64)>=s.db.lock().unwrap().query_row("SELECT id,password_hash,totp_secret,last_totp_step FROM users WHERE username=?1 AND disabled=0",[&input.username],|r|Ok((r.get(0)?,r.get(1)?,r.get(2)?,r.get(3)?))).optional()?; + let (id,hash,secret,last)=record.ok_or_else(unauthorized)?; + let parsed=PasswordHash::new(&hash).map_err(|_|unauthorized())?; + Argon2::default().verify_password(input.password.as_bytes(),&parsed).map_err(|_|unauthorized())?; + let step=totp_step(&secret,&input.totp_code,now()).map_err(|_|unauthorized())?; + if step<=last {return Err(unauthorized());} + let value=random_token(); + let mut db=s.db.lock().unwrap();let tx=db.transaction()?; + if tx.execute("UPDATE users SET last_totp_step=?1 WHERE id=?2 AND last_totp_step,headers:HeaderMap)->ApiResult { + let user=s.user(&headers,false)?; + s.db.lock().unwrap().execute("DELETE FROM sessions WHERE token_hash=?1",[digest(token(&headers)?)])?; + s.audit(&user,"user.logout",&user)?;Ok(Json(json!({"ok":true}))) +} +async fn profile(State(s):State)->ApiResult { + let mut profile=s.profile().map_err(|_|ApiError(StatusCode::SERVICE_UNAVAILABLE,"Company not configured"))?; + // Renew the signed policy lifetime without changing the administrator's revision. + profile.issued_at=now();profile.expires_at=now()+7*86400; + Ok(Json(serde_json::to_value(SignedEnvelope::sign(&profile,&s.key)?)?)) +} +async fn save_profile(State(s):State,headers:HeaderMap,Json(mut input):Json)->ApiResult { + let user=s.user(&headers,true)?; + let current=s.profile()?; + input.company_id=current.company_id.clone();input.revision=current.revision+1; + input.issued_at=now();input.expires_at=now()+30*86400; + input.validate(¤t.company_id,current.revision,now()).map_err(|_|bad("Invalid profile"))?; + let mut db=s.db.lock().unwrap();let tx=db.transaction()?; + let raw:String=tx.query_row("SELECT body FROM profile WHERE id=1",[],|r|r.get(0))?; + if serde_json::from_str::(&raw)?.revision!=current.revision {return Err(ApiError(StatusCode::CONFLICT,"Profile changed; reload"));} + tx.execute("UPDATE profile SET body=?1 WHERE id=1",[serde_json::to_string(&input)?])?; + tx.execute("INSERT INTO audit(at,actor,event,target) VALUES(?1,?2,'profile.updated',?3)",params![now(),user,input.revision.to_string()])?; + tx.commit()?;Ok(Json(json!({"revision":input.revision}))) +} +#[derive(Deserialize)] +struct CreateUser { username:String,password:String,role:String } +async fn create_user(State(s):State,headers:HeaderMap,Json(input):Json)->ApiResult { + let actor=s.user(&headers,true)?;validate_username(&input.username)?; + if !["admin","technician"].contains(&input.role.as_str()) {return Err(bad("Invalid role"));} + let hash=password_hash(&input.password).map_err(|_|bad("Password must contain 14โ€“256 characters"))?; + let secret=Secret::generate_secret().to_encoded().to_string();let id=Uuid::new_v4().to_string(); + s.db.lock().unwrap().execute("INSERT INTO users(id,username,password_hash,totp_secret,role) VALUES(?1,?2,?3,?4,?5)",params![id,input.username,hash,secret,input.role])?; + s.audit(&actor,"user.created",&id)?; + Ok(Json(json!({"id":id,"totp_secret":secret,"message":"Transfer authenticator secret privately; it will not be returned again."}))) +} +async fn users(State(s):State,headers:HeaderMap)->ApiResult { + s.user(&headers,true)?;let db=s.db.lock().unwrap();let mut statement=db.prepare("SELECT id,username,role,disabled FROM users ORDER BY username")?; + let rows=statement.query_map([],|r|Ok(json!({"id":r.get::<_,String>(0)?,"username":r.get::<_,String>(1)?,"role":r.get::<_,String>(2)?,"disabled":r.get::<_,bool>(3)?})))?.collect::,_>>()?; + Ok(Json(json!(rows))) +} +async fn disable_user(State(s):State,headers:HeaderMap,Path(id):Path)->ApiResult { + let actor=s.user(&headers,true)?;if actor==id {return Err(bad("Cannot disable your own administrator account"));} + s.db.lock().unwrap().execute("UPDATE users SET disabled=1 WHERE id=?1",[&id])?;s.audit(&actor,"user.disabled",&id)?;Ok(Json(json!({"ok":true}))) +} +#[derive(Deserialize)] +struct Enrollment { name:String,rustdesk_id:String,unattended_consent:bool } +async fn enroll(State(s):State,Json(input):Json)->ApiResult { + s.rate("enrollment",10)?; + if input.name.trim().is_empty() || input.name.len()>100 || input.rustdesk_id.len()>20 || input.rustdesk_id.len()<6 || !input.rustdesk_id.bytes().all(|b|b.is_ascii_digit()) {return Err(bad("Invalid device identity"));} + let profile=s.profile()?; + if input.unattended_consent && !profile.allow_unattended {return Err(denied());} + let id=Uuid::new_v4().to_string();let credential=random_token(); + s.db.lock().unwrap().execute("INSERT INTO devices(id,name,rustdesk_id,token_hash,unattended,state,group_id) VALUES(?1,?2,?3,?4,?5,'pending','default')",params![id,input.name,input.rustdesk_id,digest(&credential),input.unattended_consent])?; + s.audit(&id,"device.enrolled",&id)?; + Ok(Json(json!({"device_id":id,"device_token":credential,"state":"pending","company_id":profile.company_id}))) +} +async fn devices(State(s):State,headers:HeaderMap)->ApiResult { + let user=s.user(&headers,false)?;let db=s.db.lock().unwrap(); + let mut statement=db.prepare("SELECT d.id,d.name,d.rustdesk_id,d.state,d.group_id,d.unattended FROM devices d WHERE EXISTS(SELECT 1 FROM users u WHERE u.id=?1 AND u.role='admin') OR (d.state='approved' AND EXISTS(SELECT 1 FROM group_access g WHERE g.group_id=d.group_id AND g.user_id=?1)) ORDER BY d.name")?; + let rows=statement.query_map([user],|r|Ok(json!({"id":r.get::<_,String>(0)?,"name":r.get::<_,String>(1)?,"rustdesk_id":r.get::<_,String>(2)?,"state":r.get::<_,String>(3)?,"group":r.get::<_,String>(4)?,"unattended":r.get::<_,bool>(5)?})))?.collect::,_>>()?; + Ok(Json(json!(rows))) +} +#[derive(Deserialize)] +struct DeviceState { state:String,group:String } +async fn device_state(State(s):State,headers:HeaderMap,Path(id):Path,Json(input):Json)->ApiResult { + let actor=s.user(&headers,true)?; + if !["approved","revoked","pending"].contains(&input.state.as_str()) || input.group.is_empty() || input.group.len()>64 {return Err(bad("Invalid state or group"));} + let count=s.db.lock().unwrap().execute("UPDATE devices SET state=?1,group_id=?2 WHERE id=?3",params![input.state,input.group,id])?; + if count==0 {return Err(ApiError(StatusCode::NOT_FOUND,"Device not found"));} + s.audit(&actor,&format!("device.{}",input.state),&id)?;Ok(Json(json!({"ok":true}))) +} +#[derive(Deserialize)] +struct Consent { unattended:bool } +async fn consent(State(s):State,headers:HeaderMap,Json(input):Json)->ApiResult { + let device=s.device(&headers)?; + if input.unattended && !s.profile()?.allow_unattended {return Err(denied());} + s.db.lock().unwrap().execute("UPDATE devices SET unattended=?1 WHERE id=?2",params![input.unattended,device])?; + s.audit(&device,"device.consent_changed",&device)?;Ok(Json(json!({"ok":true}))) +} +async fn group_access(State(s):State,headers:HeaderMap,Path((group,user)):Path<(String,String)>)->ApiResult { + let actor=s.user(&headers,true)?;if group.len()>64 || group.is_empty(){return Err(bad("Invalid group"));} + s.db.lock().unwrap().execute("INSERT OR IGNORE INTO group_access(group_id,user_id) VALUES(?1,?2)",params![group,user])?; + s.audit(&actor,"group.access_granted",&user)?;Ok(Json(json!({"ok":true}))) +} +#[derive(Deserialize)] +struct GrantRequest { device_id:String, proof_public_key:String, unattended:bool } +async fn grant(State(s):State,headers:HeaderMap,Json(input):Json)->ApiResult { + let user=s.user(&headers,false)?;s.rate(&format!("grant:{user}"),30)?; + public_key(&input.proof_public_key).map_err(|_|bad("Invalid proof key"))?; + let profile=s.profile()?; + let peer:Option=s.db.lock().unwrap().query_row("SELECT d.rustdesk_id FROM devices d JOIN users u ON u.id=?2 WHERE d.id=?1 AND d.state='approved' AND u.disabled=0 AND (?3=0 OR d.unattended=1) AND (u.role='admin' OR EXISTS(SELECT 1 FROM group_access g WHERE g.group_id=d.group_id AND g.user_id=u.id))",params![input.device_id,user,input.unattended],|r|r.get(0)).optional()?; + let peer=peer.ok_or_else(denied)?; + if input.unattended && !profile.allow_unattended {return Err(denied());} + let grant=SessionGrant { schema:SCHEMA,company_id:profile.company_id,grant_id:Uuid::new_v4().to_string(),technician_id:user.clone(),device_id:input.device_id.clone(),rustdesk_id:peer,proof_public_key:input.proof_public_key,unattended:input.unattended,issued_at:now(),expires_at:now()+60 }; + s.db.lock().unwrap().execute("INSERT INTO grants(id,user_id,device_id,body,expires_at) VALUES(?1,?2,?3,?4,?5)",params![grant.grant_id,user,input.device_id,serde_json::to_string(&grant)?,grant.expires_at])?; + s.audit(&user,"session.requested",&grant.grant_id)?; + Ok(Json(serde_json::to_value(SignedEnvelope::sign(&grant,&s.key)?)?)) +} +#[derive(Deserialize)] +struct Claim { grant:SignedEnvelope } +fn active_grant(db:&Connection,id:&str,device:&str)->Result { + Ok(db.query_row("SELECT EXISTS(SELECT 1 FROM grants g JOIN devices d ON d.id=g.device_id JOIN users u ON u.id=g.user_id WHERE g.id=?1 AND g.device_id=?2 AND d.state='approved' AND u.disabled=0 AND g.closed=0 AND (u.role='admin' OR EXISTS(SELECT 1 FROM group_access a WHERE a.user_id=u.id AND a.group_id=d.group_id)))",params![id,device],|r|r.get(0))?) +} +async fn claim(State(s):State,headers:HeaderMap,Json(input):Json)->ApiResult { + let device=s.device(&headers)?;let profile=s.profile()?; + let grant:SessionGrant=input.grant.verify(&s.key.verifying_key()).map_err(|_|denied())?; + let peer:String=s.db.lock().unwrap().query_row("SELECT rustdesk_id FROM devices WHERE id=?1",[&device],|r|r.get(0))?; + grant.validate(&profile.company_id,&device,&peer,now()).map_err(|_|denied())?; + let mut db=s.db.lock().unwrap();let tx=db.transaction()?; + if !active_grant(&tx,&grant.grant_id,&device)? {return Err(denied());} + let consent:bool=tx.query_row("SELECT unattended FROM devices WHERE id=?1",[&device],|r|r.get(0))?; + if grant.unattended && !(consent && profile.allow_unattended) {return Err(denied());} + if tx.execute("UPDATE grants SET claimed=1,lease_until=?1 WHERE id=?2 AND claimed=0 AND expires_at>?3",params![now()+300,grant.grant_id,now()])?!=1 {return Err(denied());} + tx.execute("INSERT INTO audit(at,actor,event,target) VALUES(?1,?2,'session.claimed',?3)",params![now(),device,grant.grant_id])?;tx.commit()?; + Ok(Json(json!({"lease_until":now()+300,"unattended":grant.unattended}))) +} +async fn renew(State(s):State,headers:HeaderMap,Path(id):Path)->ApiResult { + let device=s.device(&headers)?;let profile=s.profile()?; + let db=s.db.lock().unwrap(); + if !active_grant(&db,&id,&device)? {return Err(denied());} + let raw:String=db.query_row("SELECT body FROM grants WHERE id=?1",[&id],|r|r.get(0))?; + let grant:SessionGrant=serde_json::from_str(&raw)?; + let consent:bool=db.query_row("SELECT unattended FROM devices WHERE id=?1",[&device],|r|r.get(0))?; + if grant.unattended && !(consent && profile.allow_unattended){return Err(denied());} + if db.execute("UPDATE grants SET lease_until=?1 WHERE id=?2 AND claimed=1 AND lease_until>?3 AND closed=0",params![now()+300,id,now()])?!=1 {return Err(denied());} + Ok(Json(json!({"lease_until":now()+300}))) +} +async fn close_grant(State(s):State,headers:HeaderMap,Path(id):Path)->ApiResult { + let device=s.device(&headers)?; + s.db.lock().unwrap().execute("UPDATE grants SET closed=1 WHERE id=?1 AND device_id=?2",params![id,device])?; + s.audit(&device,"session.closed",&id)?;Ok(Json(json!({"ok":true}))) +} +async fn audit(State(s):State,headers:HeaderMap)->ApiResult { + s.user(&headers,true)?;let db=s.db.lock().unwrap();let mut statement=db.prepare("SELECT at,actor,event,target FROM audit ORDER BY id DESC LIMIT 500")?; + let rows=statement.query_map([],|r|Ok(json!({"at":r.get::<_,u64>(0)?,"actor":r.get::<_,String>(1)?,"event":r.get::<_,String>(2)?,"target":r.get::<_,String>(3)?})))?.collect::,_>>()?;Ok(Json(json!(rows))) +} +#[derive(Deserialize)] +struct ReleaseImport { envelope:SignedEnvelope, public_key:String } +async fn import_release(State(s):State,headers:HeaderMap,Json(input):Json)->ApiResult { + let user=s.user(&headers,true)?; + // Project trust is configured by the operator, never accepted from the request. + let trusted=std::env::var("SWAN_RELEASE_PUBLIC_KEY").map_err(|_|bad("Project release trust key not configured"))?; + if input.public_key!=trusted {return Err(denied());} + let release:Release=input.envelope.verify(&public_key(&trusted)?).map_err(|_|denied())?; + release.validate(&release.edition,0,now()).map_err(|_|bad("Invalid release"))?; + let id=format!("{}-{:?}-{}",release.sequence,release.edition,release.channel); + s.db.lock().unwrap().execute("INSERT INTO releases(id,body,envelope,approved) VALUES(?1,?2,?3,0)",params![id,serde_json::to_string(&release)?,serde_json::to_string(&input.envelope)?])?; + s.audit(&user,"release.imported",&id)?;Ok(Json(json!({"id":id}))) +} +async fn approve_release(State(s):State,headers:HeaderMap,Path(id):Path)->ApiResult { + let user=s.user(&headers,true)?;let db=s.db.lock().unwrap(); + let raw:String=db.query_row("SELECT body FROM releases WHERE id=?1",[&id],|r|r.get(0))?; + let release:Release=serde_json::from_str(&raw)?; + release.validate(&release.edition,0,now()).map_err(|_|bad("Release expired"))?; + db.execute("UPDATE releases SET approved=1 WHERE id=?1",[&id])?;drop(db); + s.audit(&user,"release.approved",&id)?;Ok(Json(json!({"ok":true}))) +} +async fn releases(State(s):State,headers:HeaderMap)->ApiResult { + s.user(&headers,true)?;list_json(&s,"SELECT id,body,approved FROM releases",true) +} +fn list_json(s:&Store,sql:&str,approved:bool)->ApiResult { + let db=s.db.lock().unwrap();let mut stmt=db.prepare(sql)?; + let rows=stmt.query_map([],|r|Ok((r.get::<_,String>(0)?,r.get::<_,String>(1)?,if approved{r.get::<_,bool>(2)?}else{false})))?.collect::,_>>()?; + let mut values=Vec::new(); + for (id,raw,approved) in rows {values.push(json!({"id":id,"data":serde_json::from_str::(&raw)?,"approved":approved}));} + Ok(Json(json!(values))) +} +async fn device_update(State(s):State,headers:HeaderMap)->ApiResult { + let device=s.device(&headers)?;select_update(&s,&device,Edition::Customer) +} +async fn technician_update(State(s):State,headers:HeaderMap)->ApiResult { + let user=s.user(&headers,false)?;select_update(&s,&user,Edition::Technician) +} +fn select_update(s:&Store,device:&str,edition:Edition)->ApiResult { + let profile=s.profile()?; + let cohort=u8::from_str_radix(&digest(&device)[..2],16).map_err(|_|bad("Invalid device"))? as u32 * 100 / 256; + if profile.updates_paused || cohort>=profile.rollout_percent as u32 || !maintenance_open(profile.maintenance_start_utc,profile.maintenance_end_utc,now()) {return Ok(Json(Value::Null));} + let db=s.db.lock().unwrap();let mut stmt=db.prepare("SELECT body,envelope FROM releases WHERE approved=1")?; + let rows=stmt.query_map([],|r|Ok((r.get::<_,String>(0)?,r.get::<_,String>(1)?)))?.collect::,_>>()?; + let mut best:Option<(u64,Value)>=None; + for (body,envelope) in rows {let release:Release=serde_json::from_str(&body)?; + if release.edition==edition && release.channel==profile.update_channel && release.expires_at>now() && best.as_ref().map(|b|b.0,headers:HeaderMap)->ApiResult { + let user=s.user(&headers,true)?;let credential=random_token();let id=Uuid::new_v4().to_string(); + s.db.lock().unwrap().execute("INSERT INTO workers(id,token_hash) VALUES(?1,?2)",params![id,digest(&credential)])?; + s.audit(&user,"worker.created",&id)?;Ok(Json(json!({"worker_id":id,"worker_token":credential}))) +} +#[derive(Deserialize)] +struct BuildRequest { release_id:String } +async fn create_build(State(s):State,headers:HeaderMap,Json(input):Json)->ApiResult { + let actor=s.user(&headers,true)?;let profile=s.profile()?; + let envelope:String=s.db.lock().unwrap().query_row("SELECT envelope FROM releases WHERE id=?1 AND approved=1",[&input.release_id],|r|r.get(0))?; + let id=Uuid::new_v4().to_string();let body=json!({"release":serde_json::from_str::(&envelope)?,"profile":SignedEnvelope::sign(&profile,&s.key)?,"profile_public_key":STANDARD.encode(s.key.verifying_key().as_bytes()),"release_public_key":std::env::var("SWAN_RELEASE_PUBLIC_KEY").map_err(|_|bad("Release key not configured"))?}); + s.db.lock().unwrap().execute("INSERT INTO builds(id,body,state) VALUES(?1,?2,'queued')",params![id,body.to_string()])?;s.audit(&actor,"build.queued",&id)?;Ok(Json(json!({"id":id}))) +} +async fn builds(State(s):State,headers:HeaderMap)->ApiResult { + s.user(&headers,true)?;let db=s.db.lock().unwrap();let mut stmt=db.prepare("SELECT id,state,result FROM builds ORDER BY rowid DESC LIMIT 100")?; + let rows=stmt.query_map([],|r|Ok(json!({"id":r.get::<_,String>(0)?,"state":r.get::<_,String>(1)?,"result":r.get::<_,String>(2)?})))?.collect::,_>>()?;Ok(Json(json!(rows))) +} +async fn claim_build(State(s):State,headers:HeaderMap)->ApiResult { + s.worker(&headers)?;let worker=digest(token(&headers)?); + let mut db=s.db.lock().unwrap();let tx=db.transaction()?; + // Expired claims are retryable; worker output is named by immutable job ID. + tx.execute("UPDATE builds SET state='queued',worker='' WHERE state='running' AND claimed_at=tx.query_row("SELECT id,body FROM builds WHERE state='queued' ORDER BY rowid LIMIT 1",[],|r|Ok((r.get(0)?,r.get(1)?))).optional()?; + if let Some((id,body))=row {tx.execute("UPDATE builds SET state='running',worker=?1,claimed_at=?2 WHERE id=?3",params![worker,now(),id])?;tx.commit()?;Ok(Json(json!({"id":id,"job":serde_json::from_str::(&body)?})))} else {Ok(Json(Value::Null))} +} +#[derive(Deserialize)] +struct BuildResult { success:bool, artifact_url:String,sha256:String,log:String } +async fn finish_build(State(s):State,headers:HeaderMap,Path(id):Path,Json(input):Json)->ApiResult { + s.worker(&headers)?; + if input.success && !s.artifact_dir.join(format!("{id}.zip")).is_file() {return Err(bad("Verified artifact upload required"));} + if input.log.len()>8192 || (input.success && (https_url(&input.artifact_url).is_err() || input.sha256.len()!=64 || !input.sha256.bytes().all(|b|b.is_ascii_hexdigit()))) {return Err(bad("Invalid build result"));} + let result=json!({"artifact_url":input.artifact_url,"sha256":input.sha256,"log":input.log}); + let count=s.db.lock().unwrap().execute("UPDATE builds SET state=?1,result=?2 WHERE id=?3 AND worker=?4 AND state='running'",params![if input.success{"completed"}else{"failed"},result.to_string(),id,digest(token(&headers)?)])?; + if count!=1 {return Err(denied());}Ok(Json(json!({"ok":true}))) +} + +async fn upload_artifact(State(s):State,Path(id):Path,request:axum::extract::Request)->ApiResult { + s.worker(request.headers())?; + if id.len()!=36 || !id.bytes().all(|c|c.is_ascii_hexdigit() || c==b'-'){return Err(bad("Invalid build identity"));} + let job:String=s.db.lock().unwrap().query_row("SELECT body FROM builds WHERE id=?1 AND worker=?2 AND state='running'",params![id,digest(token(request.headers())?)],|r|r.get(0))?; + let bytes=axum::body::to_bytes(request.into_body(),512*1024*1024).await.map_err(|_|bad("Artifact too large"))?; + let job:Value=serde_json::from_str(&job)?; + let release_envelope:SignedEnvelope=serde_json::from_value(job["release"].clone())?; + let trust=std::env::var("SWAN_RELEASE_PUBLIC_KEY").map_err(|_|bad("Release key not configured"))?; + let release:Release=release_envelope.verify(&public_key(&trust)?).map_err(|_|denied())?; + release.validate(&release.edition,0,now()).map_err(|_|bad("Expired release"))?; + // Verify content without extracting any paths. Download bundles cannot inject executables or setup scripts. + let mut zip=zip::ZipArchive::new(std::io::Cursor::new(&bytes)).map_err(|_|bad("Invalid ZIP artifact"))?; + if zip.len()!=9 {return Err(bad("Unexpected bundle contents"));} + use std::io::Read; + let mut read=|name:&str|->Result,ApiError>{ + let file=zip.by_name(name).map_err(|_|bad("Missing bundle file"))?; + if file.size()>512*1024*1024{return Err(bad("Bundle entry too large"));} + let mut output=Vec::new();file.take(512*1024*1024+1).read_to_end(&mut output).map_err(|_|bad("Unreadable bundle"))?; + if output.len()>512*1024*1024{return Err(bad("Bundle entry too large"));}Ok(output) + }; + if digest(read(&format!("SwanRemoteSupport-install.{}",release.format))?)!=release.sha256.to_lowercase() || digest(read("swan-agent.exe")?)!=release.agent_sha256.to_lowercase(){return Err(bad("Bundle binary hash mismatch"));} + for (name,expected) in [ + ("Install-Company.ps1",include_bytes!("../../../deployment/windows/Install-Company.ps1").as_slice()), + ("Open-Technician.ps1",include_bytes!("../../../deployment/windows/Open-Technician.ps1").as_slice()), + ("Verify-Package.ps1",include_bytes!("../../../deployment/windows/Verify-Package.ps1").as_slice()), + ("LICENSE.txt",include_bytes!("../../../LICENCE").as_slice()) + ] {if read(name)?.as_slice()!=expected{return Err(bad("Bundle script or license mismatch"));}} + let profile:SignedEnvelope=serde_json::from_slice(&read("company-profile.json")?)?; + if serde_json::to_value(&profile)?!=job["profile"] {return Err(bad("Wrong company profile"));} + let copied_release:SignedEnvelope=serde_json::from_slice(&read("release.json")?)?; + if serde_json::to_value(&copied_release)?!=job["release"] {return Err(bad("Wrong release metadata"));} + let bootstrap:swan_agent::Bootstrap=serde_json::from_slice(&read("bootstrap.json")?)?; + let p:CompanyProfile=profile.verify(&s.key.verifying_key()).map_err(|_|denied())?; + if bootstrap.company_id!=p.company_id || bootstrap.edition!=release.edition || bootstrap.management_url!=p.management_url || bootstrap.profile_public_key!=STANDARD.encode(s.key.verifying_key().as_bytes()) || bootstrap.release_public_key!=trust {return Err(bad("Bootstrap trust mismatch"));} + let temporary=s.artifact_dir.join(format!("{id}.partial")); + tokio::fs::write(&temporary,&bytes).await.map_err(|_|bad("Cannot save artifact"))?; + tokio::fs::rename(&temporary,s.artifact_dir.join(format!("{id}.zip"))).await.map_err(|_|bad("Cannot publish artifact"))?; + Ok(Json(json!({"sha256":digest(&bytes)}))) +} +async fn download_artifact(State(s):State,Path(id):Path,headers:HeaderMap)->Result { + if id.len()!=36 || !id.bytes().all(|c|c.is_ascii_hexdigit() || c==b'-'){return Err(bad("Invalid build identity"));} + let job:String=s.db.lock().unwrap().query_row("SELECT body FROM builds WHERE id=?1 AND state='completed'",[&id],|r|r.get(0))?; + let job:Value=serde_json::from_str(&job)?; + let envelope:SignedEnvelope=serde_json::from_value(job["release"].clone())?; + let trust=std::env::var("SWAN_RELEASE_PUBLIC_KEY").map_err(|_|bad("Release trust key unavailable"))?; + let release:Release=envelope.verify(&public_key(&trust)?).map_err(|_|denied())?; + if release.edition==Edition::Technician{s.user(&headers,false)?;} + let bytes=tokio::fs::read(s.artifact_dir.join(format!("{id}.zip"))).await.map_err(|_|ApiError(StatusCode::NOT_FOUND,"Artifact unavailable"))?; + Ok(([(header::CONTENT_TYPE,"application/zip"),(header::CONTENT_DISPOSITION,"attachment; filename=\"SwanRemoteSupport-company.zip\"")],bytes).into_response()) +} diff --git a/product/management/src/tests.rs b/product/management/src/tests.rs new file mode 100644 index 00000000000..f3fb776ae01 --- /dev/null +++ b/product/management/src/tests.rs @@ -0,0 +1,73 @@ +use super::service::*; +use axum::{body::Body,http::{Request,StatusCode}}; +use base64::{engine::general_purpose::STANDARD,Engine}; +use ed25519_dalek::SigningKey; +use http_body_util::BodyExt; +use serde_json::{json,Value}; +use std::sync::Arc; +use swan_protocol::*; +use totp_rs::{Algorithm,Secret,TOTP}; +use tower::ServiceExt; + +async fn request(app:&axum::Router,path:&str,method:&str,token:Option<&str>,body:Value)->(StatusCode,Value) { + let mut request=Request::builder().uri(path).method(method).header("content-type","application/json"); + if let Some(token)=token {request=request.header("authorization",format!("Bearer {token}"));} + let response=app.clone().oneshot(request.body(Body::from(body.to_string())).unwrap()).await.unwrap(); + let status=response.status();let bytes=response.into_body().collect().await.unwrap().to_bytes(); + (status,serde_json::from_slice(&bytes).unwrap()) +} +fn profile_fixture()->CompanyProfile { + let key=SigningKey::from_bytes(&[3;32]); + CompanyProfile{schema:1,company_id:"fixture".into(),revision:1,issued_at:now(),expires_at:now()+3600,management_url:"https://support.example.com".into(),rendezvous:"support.example.com".into(),relay:"support.example.com".into(),transport_public_key:STANDARD.encode(key.verifying_key().as_bytes()),customer:Branding::default(),technician:Branding::default(),allow_unattended:true,updates_paused:true,rollout_percent:10,maintenance_start_utc:22,maintenance_end_utc:5,update_channel:"stable".into(),next_profile_public_key:None} +} + +#[tokio::test] +async fn enrollment_mfa_and_grants_fail_closed() { + let directory=std::env::temp_dir().join(format!("swan-api-{}",random_token())); + let store=Arc::new(Store::open(&directory).unwrap());let app=router(store.clone()); + assert_eq!(request(&app,"/api/v1/devices","GET",None,Value::Null).await.0,StatusCode::UNAUTHORIZED); + let secret=Secret::generate_secret().to_encoded().to_string(); + let generator=TOTP::new(Algorithm::SHA1,6,1,30,Secret::Encoded(secret.clone()).to_bytes().unwrap()).unwrap(); + let time=now();let setup_token=std::fs::read_to_string(directory.join("setup-token.txt")).unwrap(); + let setup=json!({"profile":profile_fixture(),"username":"admin","password":"correct horse battery staple","totp_secret":secret,"totp_code":generator.generate(time)}); + assert_eq!(request(&app,"/api/v1/setup","POST",Some(&random_token()),setup.clone()).await.0,StatusCode::FORBIDDEN); + assert_eq!(request(&app,"/api/v1/setup","POST",Some(&setup_token),setup.clone()).await.0,StatusCode::OK); + assert_eq!(request(&app,"/api/v1/setup","POST",Some(&setup_token),setup).await.0,StatusCode::CONFLICT); + let login=json!({"username":"admin","password":"correct horse battery staple","totp_code":generator.generate(time)}); + assert_eq!(request(&app,"/api/v1/login","POST",None,login).await.0,StatusCode::UNAUTHORIZED,"Setup code cannot be replayed for login"); + let login=json!({"username":"admin","password":"correct horse battery staple","totp_code":generator.generate((time/30+1)*30)}); + let (status,result)=request(&app,"/api/v1/login","POST",None,login.clone()).await;assert_eq!(status,StatusCode::OK); + let admin=result["token"].as_str().unwrap(); + assert_eq!(request(&app,"/api/v1/login","POST",None,login).await.0,StatusCode::UNAUTHORIZED,"MFA codes are single-use"); + let (_,device)=request(&app,"/api/v1/enroll","POST",None,json!({"name":"Customer test PC","rustdesk_id":"123456789","unattended_consent":false})).await; + let device_id=device["device_id"].as_str().unwrap();let credential=device["device_token"].as_str().unwrap(); + let proof=SigningKey::from_bytes(&[9;32]);let grant_request=json!({"device_id":device_id,"proof_public_key":STANDARD.encode(proof.verifying_key().as_bytes()),"unattended":false}); + assert_eq!(request(&app,"/api/v1/grants","POST",Some(admin),grant_request.clone()).await.0,StatusCode::FORBIDDEN,"Pending devices cannot be accessed"); + assert_eq!(request(&app,&format!("/api/v1/devices/{device_id}/state"),"PUT",Some(admin),json!({"state":"approved","group":"customers"})).await.0,StatusCode::OK); + let (status,envelope)=request(&app,"/api/v1/grants","POST",Some(admin),grant_request).await;assert_eq!(status,StatusCode::OK); + let claim=json!({"grant":envelope}); + assert_eq!(request(&app,"/api/v1/grants/claim","POST",Some(credential),claim.clone()).await.0,StatusCode::OK); + assert_eq!(request(&app,"/api/v1/grants/claim","POST",Some(credential),claim).await.0,StatusCode::FORBIDDEN,"Session ticket is single-use"); + assert_eq!(request(&app,"/api/v1/grants","POST",Some(admin),json!({"device_id":device_id,"proof_public_key":STANDARD.encode(proof.verifying_key().as_bytes()),"unattended":true})).await.0,StatusCode::FORBIDDEN,"Unattended access needs device consent"); + let (_,new_user)=request(&app,"/api/v1/users","POST",Some(admin),json!({"username":"technician","password":"another long strong password","role":"technician"})).await; + let tech_secret=new_user["totp_secret"].as_str().unwrap(); + let tech_generator=TOTP::new(Algorithm::SHA1,6,1,30,Secret::Encoded(tech_secret.into()).to_bytes().unwrap()).unwrap(); + let (_,tech_login)=request(&app,"/api/v1/login","POST",None,json!({"username":"technician","password":"another long strong password","totp_code":tech_generator.generate(now())})).await; + let technician=tech_login["token"].as_str().unwrap(); + assert_eq!(request(&app,"/api/v1/users","GET",Some(technician),Value::Null).await.0,StatusCode::UNAUTHORIZED); + assert_eq!(request(&app,"/api/v1/grants","POST",Some(technician),json!({"device_id":device_id,"proof_public_key":STANDARD.encode(proof.verifying_key().as_bytes()),"unattended":false})).await.0,StatusCode::FORBIDDEN,"Technicians need explicit group access"); + let (_,devices)=request(&app,"/api/v1/devices","GET",Some(technician),Value::Null).await;assert_eq!(devices.as_array().unwrap().len(),0); + assert_eq!(request(&app,&format!("/api/v1/groups/customers/users/{}",new_user["id"].as_str().unwrap()),"PUT",Some(admin),Value::Null).await.0,StatusCode::OK); + let (_,devices)=request(&app,"/api/v1/devices","GET",Some(technician),Value::Null).await;assert_eq!(devices.as_array().unwrap().len(),1); + assert_eq!(request(&app,&format!("/api/v1/devices/{device_id}/state"),"PUT",Some(admin),json!({"state":"revoked","group":"customers"})).await.0,StatusCode::OK); + assert_eq!(request(&app,"/api/v1/grants","POST",Some(admin),json!({"device_id":device_id,"proof_public_key":STANDARD.encode(proof.verifying_key().as_bytes()),"unattended":false})).await.0,StatusCode::FORBIDDEN); + drop(app);drop(store);std::fs::remove_dir_all(directory).unwrap(); +} + +#[tokio::test] +async fn profile_revision_is_server_controlled_and_invalid_svg_rejected() { + let mut brand=Branding::default();brand.logo_svg="".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..186d99800c1 --- /dev/null +++ b/product/management/src/windows.rs @@ -0,0 +1,21 @@ +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(super::run(receiver))); + 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..57d71d54fba --- /dev/null +++ b/product/management/web/app.js @@ -0,0 +1,42 @@ +'use strict'; +let accessToken=''; +const content=document.querySelector('#content'); +const notice=document.querySelector('#notice'); +const escapeText=value=>String(value??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); +async function api(path,method='GET',body,token=accessToken){ + 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'}); + const result=await response.json();if(!response.ok)throw new Error(result.error||'Request failed');return result; +} +function showError(error){notice.textContent=error.message;} +function bindForm(id,callback){document.querySelector('#'+id).addEventListener('submit',async event=>{event.preventDefault();notice.textContent='';const button=event.target.querySelector('button[type=submit]');if(button)button.disabled=true;try{await callback(new FormData(event.target));}catch(error){showError(error);}finally{if(button)button.disabled=false;}});} +function field(name,label,type='text',value=''){return ``;} +function title(text,description=''){document.querySelector('#title').textContent=text;document.querySelector('#description').textContent=description;} +function base32(bytes){let bits=0,value=0,out='';for(const byte of bytes){value=(value<<8)|byte;bits+=8;while(bits>=5){out+='ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'[(value>>>(bits-5))&31];bits-=5;}}if(bits)out+='ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'[(value<<(5-bits))&31];return out;} +function defaultProfile(){const brand={display_name:'Swan Remote Support',primary_color:'#004B6E',logo_svg:'',support_url:'',consent_text:'Only allow support from a technician you trust. You can stop support or revoke ongoing access at any time.'};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};} +async function initialize(){try{const state=await api('status');if(!state.configured)setupScreen();else loginScreen();}catch(error){showError(error);}} +function setupScreen(){ + 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))); + content.innerHTML=`

Company and network

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

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

`; + bindForm('setup',async values=>{const profile=defaultProfile();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(){title('Sign in to your company server','Administrator and technician accounts require an authenticator code.');document.querySelector('#nav').hidden=true;content.innerHTML=`
${field('username','Username')}${field('password','Password','password')}${field('code','Authenticator code')}

Your session stays in this pageโ€™s memory and expires after one hour.

`;bindForm('login',async values=>{const login=await api('login','POST',{username:values.get('username'),password:values.get('password'),totp_code:values.get('code')});accessToken=login.token;document.querySelector('#nav').hidden=false;await view('devices');});} +async function view(name){notice.textContent='';for(const button of document.querySelectorAll('[data-view]'))button.classList.toggle('active',button.dataset.view===name);try{await screens[name]();}catch(error){showError(error);}} +const screens={ + async devices(){title('Devices','Approve new devices and choose which support group can access them.');const devices=await api('devices');content.innerHTML='
'+devices.map(d=>``).join('')+'
DeviceStatusAccessActions
${escapeText(d.name)}${escapeText(d.rustdesk_id)}${escapeText(d.state)}${escapeText(d.group)}${d.unattended?'Unattended consent recorded':'Customer approval required'}

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

'; + for(const button of document.querySelectorAll('[data-approve]'))button.onclick=async()=>{const group=prompt('Device group','default');if(!group)return;try{await api('devices/'+encodeURIComponent(button.dataset.approve)+'/state','PUT',{state:'approved',group});await view('devices');}catch(error){showError(error);}}; + for(const button of document.querySelectorAll('[data-revoke]'))button.onclick=async()=>{try{await api('devices/'+encodeURIComponent(button.dataset.revoke)+'/state','PUT',{state:'revoked',group:'default'});await view('devices');}catch(error){showError(error);}}; + }, + async branding(){title('Branding and policies','Company changes synchronize with your installed customer and technician apps.');const envelope=await api('profile');const p=JSON.parse(new TextDecoder().decode(Uint8Array.from(atob(envelope.payload),c=>c.charCodeAt(0))));content.innerHTML=`

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

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)}
${field('rollout','Rollout percentage','number',p.rollout_percent)}Maintenance window: ${p.maintenance_start_utc}:00โ€“${p.maintenance_end_utc}:00 UTC. Full transport and update settings are available through the authenticated profile API.
`;bindForm('branding',async 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('logo');p[edition].consent_text=v.get('consent');}p.allow_unattended=v.has('unattended');p.updates_paused=v.has('paused');p.rollout_percent=Number(v.get('rollout'));await api('profile','PUT',p);notice.textContent='Company profile published.';}); + }, + async users(){title('Technicians and access','Every technician needs MFA and explicit device-group access.');const users=await api('users');content.innerHTML=`
${users.map(u=>``).join('')}
UsernameRoleIdentifier
${escapeText(u.username)}${u.disabled?' (disabled)':''}${escapeText(u.role)}${escapeText(u.id)}

Add technician

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

Grant group access

${field('user_id','Technician identifier')}${field('group','Device group','text','default')}
`;bindForm('user',async v=>{const result=await api('users','POST',{username:v.get('username'),password:v.get('password'),role:'technician'});document.querySelector('#secret').textContent='Transfer privately: '+JSON.stringify(result);});bindForm('access',async v=>{await api('groups/'+encodeURIComponent(v.get('group'))+'/users/'+encodeURIComponent(v.get('user_id')),'PUT');notice.textContent='Group access granted.';}); + }, + async releases(){title('Software releases','Import verified project releases, then approve them for company rollout.');const rows=await api('releases');content.innerHTML=`

Import signed release

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

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

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

Generate installer bundle

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

Connect a Windows worker

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

${rows.map(r=>``).join('')}
JobStatusResult
${escapeText(r.id)}${escapeText(r.state)}
${escapeText(r.result)}
`;bindForm('build',async v=>{await api('builds','POST',{release_id:v.get('release_id')});await view('builds');});document.querySelector('#worker').onclick=async()=>{try{document.querySelector('#worker_secret').textContent=JSON.stringify(await api('workers','POST'),null,2);}catch(error){showError(error);}}; + }, + async audit(){title('Audit history','Account, enrollment, permission, release and session events.');const rows=await api('audit');content.innerHTML=`
${rows.map(r=>``).join('')}
TimeEventActorTarget
${escapeText(new Date(r.at*1000).toISOString())}${escapeText(r.event)}${escapeText(r.actor)}${escapeText(r.target)}
`;} +}; +for(const button of document.querySelectorAll('[data-view]'))button.onclick=()=>view(button.dataset.view); +document.querySelector('#logout').onclick=async()=>{try{await api('logout','POST');}catch{}accessToken='';loginScreen();}; +initialize(); diff --git a/product/management/web/index.html b/product/management/web/index.html new file mode 100644 index 00000000000..8c47a9f0978 --- /dev/null +++ b/product/management/web/index.html @@ -0,0 +1,3 @@ +Swan Remote Support โ€” Company server + +

YOUR SUPPORT INFRASTRUCTURE

Welcome to Swan Remote Support

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

diff --git a/product/management/web/style.css b/product/management/web/style.css new file mode 100644 index 00000000000..1da833256f5 --- /dev/null +++ b/product/management/web/style.css @@ -0,0 +1 @@ +: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:#004b6e;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{border:2px solid #b2dbe5;border-radius:50%;height:49px;width:49px;text-align:center;font-size:32px}.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:#004b6e;color:white;border:0;border-radius:6px;padding:12px 18px;font:inherit;cursor:pointer}button.secondary{background:#e8f0f3;color:#004b6e}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}} diff --git a/product/protocol/Cargo.toml b/product/protocol/Cargo.toml new file mode 100644 index 00000000000..fe38deda4b6 --- /dev/null +++ b/product/protocol/Cargo.toml @@ -0,0 +1,17 @@ +[package] +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/lib.rs b/product/protocol/src/lib.rs new file mode 100644 index 00000000000..34193c4a890 --- /dev/null +++ b/product/protocol/src/lib.rs @@ -0,0 +1,257 @@ +//! Signed company configuration and authorization. Sign the serialized payload bytes, +//! not a re-serialization, so verifiers never depend on JSON object ordering. +use anyhow::{ensure, Context, Result}; +use base64::{engine::general_purpose::STANDARD, Engine}; +use ed25519_dalek::{Signature, Signer, SigningKey, VerifyingKey}; +use serde::{de::DeserializeOwned, Deserialize, Serialize}; +use sha2::{Digest, Sha256}; + +pub const SCHEMA: u32 = 1; +pub const PRODUCT: &str = "Swan Remote Support"; +pub const WIRE_PREFIX: &[u8] = b"SWAN1:"; + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum Edition { Customer, Technician } + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Branding { + pub display_name: String, + pub primary_color: String, + pub logo_svg: String, + pub support_url: String, + pub consent_text: String, +} +impl Default for Branding { + fn default() -> Self { + Self { display_name: PRODUCT.into(), primary_color: "#004B6E".into(), + logo_svg: include_str!("../../../branding/swan-support-mark.svg").into(), + support_url: String::new(), + consent_text: "Only allow remote support from a technician you trust. You can stop support or revoke ongoing access at any time.".into() } + } +} +impl Branding { + pub fn validate(&self) -> Result<()> { + ensure!(!self.display_name.trim().is_empty() && self.display_name.len() <= 100, "Invalid display name"); + ensure!(self.primary_color.len() == 7 && self.primary_color.starts_with('#') && self.primary_color[1..].bytes().all(|b| b.is_ascii_hexdigit()), "Invalid color"); + ensure!(self.consent_text.len() <= 4000 && !self.consent_text.trim().is_empty(), "Invalid consent text"); + ensure!(self.logo_svg.len() <= 128 * 1024, "Logo too large"); + // SVG is displayed as an image, never injected as HTML. Reject active/external content as defense in depth. + let svg = self.logo_svg.to_ascii_lowercase(); + for forbidden in [", +} +impl CompanyProfile { + pub fn validate(&self, company: &str, minimum_revision: u64, now: u64) -> Result<()> { + ensure!(self.schema == SCHEMA && self.company_id == company, "Wrong company or schema"); + ensure!(self.revision >= minimum_revision && self.revision > 0, "Replayed profile"); + ensure!(self.issued_at <= now + 60 && self.expires_at > now && self.expires_at > self.issued_at, "Expired or future profile"); + https_url(&self.management_url)?; + validate_host(&self.rendezvous)?; + validate_host(&self.relay)?; + public_key(&self.transport_public_key)?; + self.customer.validate()?; + self.technician.validate()?; + ensure!(self.rollout_percent <= 100 && self.maintenance_start_utc <= 23 && self.maintenance_end_utc <= 23, "Invalid update policy"); + ensure!(["stable", "test"].contains(&self.update_channel.as_str()), "Invalid channel"); + if let Some(key) = &self.next_profile_public_key { public_key(key)?; } + Ok(()) + } +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct SignedEnvelope { pub payload: String, pub signature: String } +impl SignedEnvelope { + pub fn sign(value: &T, key: &SigningKey) -> Result { + let bytes = serde_json::to_vec(value)?; + Ok(Self { payload: STANDARD.encode(&bytes), signature: STANDARD.encode(key.sign(&bytes).to_bytes()) }) + } + pub fn verify(&self, key: &VerifyingKey) -> Result { + ensure!(self.payload.len() <= 1024 * 1024, "Payload too large"); + let bytes = STANDARD.decode(&self.payload)?; + let signature = Signature::from_slice(&STANDARD.decode(&self.signature)?)?; + key.verify_strict(&bytes, &signature).context("Invalid signature")?; + Ok(serde_json::from_slice(&bytes)?) + } +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct SessionGrant { + pub schema: u32, + pub company_id: String, + pub grant_id: String, + pub technician_id: String, + pub device_id: String, + pub rustdesk_id: String, + pub proof_public_key: String, + pub unattended: bool, + pub issued_at: u64, + pub expires_at: u64, +} +impl SessionGrant { + pub fn validate(&self, company: &str, device: &str, peer: &str, now: u64) -> Result<()> { + ensure!(self.schema == SCHEMA && self.company_id == company && self.device_id == device && self.rustdesk_id == peer, "Wrong grant target"); + ensure!(self.issued_at <= now + 30 && self.expires_at > now && self.expires_at <= self.issued_at + 60, "Grant expired or lifetime invalid"); + public_key(&self.proof_public_key)?; + Ok(()) + } +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ManagedLogin { pub grant: SignedEnvelope, pub challenge_signature: String } +impl ManagedLogin { + pub fn to_wire(&self) -> Result> { + let mut wire = WIRE_PREFIX.to_vec(); + wire.extend(serde_json::to_vec(self)?); + Ok(wire) + } + pub fn from_wire(wire: &[u8]) -> Result { + ensure!(wire.len() <= 16384, "Login payload too large"); + ensure!(wire.starts_with(WIRE_PREFIX), "Managed grant required"); + Ok(serde_json::from_slice(&wire[WIRE_PREFIX.len()..])?) + } + pub fn prove(grant: SignedEnvelope, key: &SigningKey, challenge: &str) -> Self { + Self { grant, challenge_signature: STANDARD.encode(key.sign(challenge.as_bytes()).to_bytes()) } + } + pub fn verify_proof(&self, grant: &SessionGrant, challenge: &str) -> Result<()> { + public_key(&grant.proof_public_key)?.verify_strict(challenge.as_bytes(), &Signature::from_slice(&STANDARD.decode(&self.challenge_signature)?)?).context("Invalid session proof") + } +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Release { + pub schema: u32, + pub product: String, + pub version: String, + pub sequence: u64, + pub edition: Edition, + pub architecture: String, + pub channel: String, + pub expires_at: u64, + pub artifact_url: String, + pub sha256: String, + pub installed_sha256: String, + pub agent_url: String, + pub agent_sha256: String, + pub publisher: String, + pub source_url: String, + pub format: String, +} +impl Release { + pub fn validate(&self, edition: &Edition, minimum_sequence: u64, now: u64) -> Result<()> { + ensure!(self.schema == SCHEMA && self.product == PRODUCT && &self.edition == edition, "Wrong product or edition"); + ensure!(self.architecture == "x64" && self.sequence > minimum_sequence && self.expires_at > now, "Incompatible, expired or replayed release"); + ensure!(["stable", "test"].contains(&self.channel.as_str()) && ["exe", "msi"].contains(&self.format.as_str()), "Invalid release type"); + ensure!(self.sha256.len() == 64 && self.sha256.bytes().all(|x| x.is_ascii_hexdigit()) && !self.publisher.is_empty(), "Invalid artifact identity"); + ensure!(self.installed_sha256.len()==64 && self.installed_sha256.bytes().all(|x|x.is_ascii_hexdigit()),"Invalid installed executable identity"); + let fields: Vec<_> = self.version.split('.').collect(); + ensure!(fields.len() == 3 && fields.iter().all(|v| !v.is_empty() && v.bytes().all(|c| c.is_ascii_digit())), "Expected numeric major.minor.patch"); + https_url(&self.artifact_url)?; + https_url(&self.agent_url)?; + ensure!(self.agent_sha256.len() == 64 && self.agent_sha256.bytes().all(|x| x.is_ascii_hexdigit()), "Invalid agent hash"); + https_url(&self.source_url)?; + Ok(()) + } +} + +pub fn https_url(value: &str) -> Result { + let url = url::Url::parse(value)?; + ensure!(url.scheme() == "https" && url.host_str().is_some() && url.username().is_empty() && url.password().is_none() && url.fragment().is_none(), "Expected HTTPS URL without credentials or fragment"); + Ok(url) +} +pub fn validate_host(value: &str) -> Result<()> { + ensure!(!value.is_empty() && value.len() <= 253 && !value.contains("100.82.236.84"), "Invalid or legacy private endpoint"); + ensure!(value.bytes().all(|c| c.is_ascii_alphanumeric() || b".-:[]".contains(&c)), "Invalid transport address"); + Ok(()) +} +pub fn public_key(value: &str) -> Result { + let bytes: [u8; 32] = STANDARD.decode(value)?.try_into().map_err(|_| anyhow::anyhow!("Expected 32-byte key"))?; + Ok(VerifyingKey::from_bytes(&bytes)?) +} +pub fn random_token() -> String { + use rand::RngCore; + let mut bytes = [0u8; 32]; rand::rngs::OsRng.fill_bytes(&mut bytes); hex::encode(bytes) +} +pub fn signing_key_from_hex(value:&str)->Result { + let bytes:[u8;32]=hex::decode(value)?.try_into().map_err(|_|anyhow::anyhow!("Expected 32-byte signing key"))?; + Ok(SigningKey::from_bytes(&bytes)) +} +pub fn digest(bytes: impl AsRef<[u8]>) -> String { hex::encode(Sha256::digest(bytes.as_ref())) } +pub fn now() -> u64 { std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0) } +pub fn maintenance_open(start: u8, end: u8, now: u64) -> bool { + let hour = ((now / 3600) % 24) as u8; + if start == end { true } else if start < end { hour >= start && hour < end } else { hour >= start || hour < end } +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn signed_payload_rejects_tampering_and_wrong_key() { + let key = SigningKey::from_bytes(&[7;32]); + let mut envelope = SignedEnvelope::sign(&"company", &key).unwrap(); + assert_eq!(envelope.verify::(&key.verifying_key()).unwrap(), "company"); + assert!(envelope.verify::(&SigningKey::from_bytes(&[8;32]).verifying_key()).is_err()); + envelope.payload = STANDARD.encode(b"\"attacker\""); + assert!(envelope.verify::(&key.verifying_key()).is_err()); + } + #[test] + fn grant_is_target_bound_expiring_and_requires_challenge_proof() { + let key = SigningKey::from_bytes(&[3;32]); + let grant = SessionGrant { schema: 1, company_id: "a".into(), grant_id: "jti".into(), technician_id:"t".into(), device_id:"d".into(), rustdesk_id:"123".into(), proof_public_key: STANDARD.encode(key.verifying_key().as_bytes()), unattended:false, issued_at:100, expires_at:160 }; + assert!(grant.validate("a","d","123",101).is_ok()); + assert!(grant.validate("b","d","123",101).is_err()); + assert!(grant.validate("a","d","124",101).is_err()); + assert!(grant.validate("a","d","123",160).is_err()); + let login = ManagedLogin::prove(SignedEnvelope::sign(&grant,&key).unwrap(), &key,"nonce"); + assert!(login.verify_proof(&grant,"nonce").is_ok()); + assert!(login.verify_proof(&grant,"different nonce").is_err()); + assert!(ManagedLogin::from_wire(b"legacy-password").is_err()); + } + #[test] + fn maintenance_windows_wrap_midnight() { + assert!(maintenance_open(22,3,23*3600)); assert!(maintenance_open(22,3,3600)); + assert!(!maintenance_open(22,3,12*3600)); assert!(maintenance_open(0,0,12*3600)); + } + #[test] + fn releases_reject_replay_wrong_edition_expiry_and_missing_installed_identity() { + let mut release=Release {schema:1,product:PRODUCT.into(),version:"1.5.0".into(),sequence:2,edition:Edition::Customer,architecture:"x64".into(),channel:"stable".into(),expires_at:200,artifact_url:"https://releases.example/package.exe".into(),sha256:"a".repeat(64),installed_sha256:"b".repeat(64),agent_url:"https://releases.example/agent.exe".into(),agent_sha256:"c".repeat(64),publisher:"Example".into(),source_url:"https://releases.example/source.tar.gz".into(),format:"exe".into()}; + assert!(release.validate(&Edition::Customer,1,100).is_ok()); + assert!(release.validate(&Edition::Customer,2,100).is_err()); + assert!(release.validate(&Edition::Technician,0,100).is_err()); + assert!(release.validate(&Edition::Customer,0,200).is_err()); + release.installed_sha256.clear();assert!(release.validate(&Edition::Customer,0,100).is_err()); + } +} diff --git a/product/worker/Cargo.toml b/product/worker/Cargo.toml new file mode 100644 index 00000000000..e6a1db10e10 --- /dev/null +++ b/product/worker/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "swan-worker" +version.workspace = true +edition.workspace = true +license.workspace = true +rust-version.workspace = true + +[dependencies] +swan-protocol = { path = "../protocol" } +swan-agent = { path = "../agent" } +anyhow.workspace = true +serde_json.workspace = true +reqwest.workspace = true +tokio.workspace = true +zip = { version = "2", default-features = false, features = ["deflate"] } diff --git a/product/worker/src/main.rs b/product/worker/src/main.rs new file mode 100644 index 00000000000..06779952b27 --- /dev/null +++ b/product/worker/src/main.rs @@ -0,0 +1,92 @@ +use anyhow::{ensure,Context,Result}; +use serde_json::{json,Value}; +use std::{io::Write,path::{Path,PathBuf}}; +use swan_agent::Bootstrap; +use swan_protocol::*; + +#[tokio::main] +async fn main()->Result<()> { + ensure!(cfg!(windows),"Installer worker requires Windows for Authenticode verification"); + let server=std::env::var("SWAN_MANAGEMENT_URL").context("Set SWAN_MANAGEMENT_URL")?;https_url(&server)?; + let token=std::env::var("SWAN_WORKER_TOKEN").context("Set SWAN_WORKER_TOKEN")?; + let release_key=std::env::var("SWAN_RELEASE_PUBLIC_KEY").context("Pin SWAN_RELEASE_PUBLIC_KEY on the worker")?;public_key(&release_key)?; + let company_key=std::env::var("SWAN_PROFILE_PUBLIC_KEY").context("Pin SWAN_PROFILE_PUBLIC_KEY on the worker")?;public_key(&company_key)?; + let output=PathBuf::from(std::env::var("SWAN_ARTIFACT_DIR").context("Set worker artifact output directory")?); + std::fs::create_dir_all(&output)?; + let client=reqwest::Client::builder().timeout(std::time::Duration::from_secs(600)).redirect(reqwest::redirect::Policy::none()).https_only(true).build()?; + loop { + let response=client.post(format!("{}/api/v1/worker/claim",server.trim_end_matches('/'))).bearer_auth(&token).send().await; + match response { + Ok(response) if response.status().is_success()=>{ + let value:Value=response.json().await?; + if !value.is_null(){ + let id=value["id"].as_str().context("Missing build identity")?; + // Server-generated UUIDs become filenames, never executable commands. + ensure!(id.len()==36 && id.bytes().all(|c|c.is_ascii_hexdigit() || c==b'-'),"Invalid job identity"); + let outcome=build(&client,&value["job"],&output,id,&release_key,&company_key).await; + let result=match outcome { + Ok((name,hash))=>{ + let bytes=tokio::fs::read(output.join(name)).await?; + client.put(format!("{}/api/v1/worker/builds/{id}/artifact",server.trim_end_matches('/'))).bearer_auth(&token).header("Content-Type","application/zip").body(bytes).send().await?.error_for_status()?; + json!({"success":true,"artifact_url":format!("{}/api/v1/downloads/{id}",server.trim_end_matches('/')),"sha256":hash,"log":"Verified signed inputs and uploaded company bundle."}) + }, + Err(error)=>{eprintln!("Build {id} failed: {error}");json!({"success":false,"artifact_url":"","sha256":"","log":"Build failed; see the protected worker log. No package was published."})} + }; + client.put(format!("{}/api/v1/worker/builds/{id}",server.trim_end_matches('/'))).bearer_auth(&token).json(&result).send().await?.error_for_status()?; + } + } + Ok(response)=>eprintln!("Worker polling failed: {}",response.status()), + Err(_)=>eprintln!("Worker management endpoint unavailable; retrying"), + } + tokio::time::sleep(std::time::Duration::from_secs(15)).await; + } +} + +async fn build(client:&reqwest::Client,job:&Value,output:&Path,id:&str,release_key:&str,company_key:&str)->Result<(String,String)> { + ensure!(job["release_public_key"].as_str()==Some(release_key) && job["profile_public_key"].as_str()==Some(company_key),"Worker trust pins do not match the company job"); + let release_envelope:SignedEnvelope=serde_json::from_value(job["release"].clone())?; + let release:Release=release_envelope.verify(&public_key(release_key)?)?; + release.validate(&release.edition,0,now())?; + let profile_envelope:SignedEnvelope=serde_json::from_value(job["profile"].clone())?; + let profile:CompanyProfile=profile_envelope.verify(&public_key(company_key)?)?; + profile.validate(&profile.company_id,0,now())?; + let work=output.join(format!("work-{id}"));std::fs::create_dir_all(&work)?; + let installer=work.join(format!("SwanRemoteSupport-install.{}",release.format)); + let agent=work.join("swan-agent.exe"); + download(client,&release.artifact_url,&installer,&release.sha256).await?; + download(client,&release.agent_url,&agent,&release.agent_sha256).await?; + verify_windows(&installer,&release.publisher,&work)?; + verify_windows(&agent,&release.publisher,&work)?; + let bootstrap=Bootstrap{schema:SCHEMA,edition:release.edition.clone(),company_id:profile.company_id,management_url:profile.management_url,profile_public_key:company_key.into(),release_public_key:release_key.into()}; + let file_name=format!("SwanRemoteSupport-{}-{}-{}.zip",if release.edition==Edition::Customer{"Customer"}else{"Technician"},release.version,id); + let temporary=output.join(format!("{file_name}.partial")); + let mut zip=zip::ZipWriter::new(std::fs::File::create(&temporary)?); + let options=zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated); + for path in [&installer,&agent] {zip.start_file(path.file_name().context("Missing artifact name")?.to_string_lossy(),options)?;zip.write_all(&std::fs::read(path)?)?;} + for (name,bytes) in [ + ("bootstrap.json",serde_json::to_vec_pretty(&bootstrap)?), + ("company-profile.json",serde_json::to_vec_pretty(&profile_envelope)?), + ("release.json",serde_json::to_vec_pretty(&release_envelope)?), + ("Install-Company.ps1",include_bytes!("../../../deployment/windows/Install-Company.ps1").to_vec()), + ("Open-Technician.ps1",include_bytes!("../../../deployment/windows/Open-Technician.ps1").to_vec()), + ("Verify-Package.ps1",include_bytes!("../../../deployment/windows/Verify-Package.ps1").to_vec()), + ("LICENSE.txt",include_bytes!("../../../LICENCE").to_vec()), + ] {zip.start_file(name,options)?;zip.write_all(&bytes)?;} + zip.finish()?.sync_all()?; + let hash=digest(std::fs::read(&temporary)?);std::fs::rename(&temporary,output.join(&file_name))?; + Ok((file_name,hash)) +} +pub async fn download(client:&reqwest::Client,url:&str,path:&Path,hash:&str)->Result<()> { + https_url(url)?; + let mut response=client.get(url).send().await?.error_for_status()?; + ensure!(response.content_length().map(|n|n<=512*1024*1024).unwrap_or(true),"Artifact too large"); + let mut bytes=Vec::new(); + while let Some(chunk)=response.chunk().await?{ensure!(bytes.len()+chunk.len()<=512*1024*1024,"Artifact too large");bytes.extend(chunk);} + ensure!(digest(&bytes).eq_ignore_ascii_case(hash),"Artifact hash mismatch"); + std::fs::write(path,bytes)?;Ok(()) +} +fn verify_windows(path:&Path,publisher:&str,directory:&Path)->Result<()> { + let verifier=directory.join("Verify-Package.ps1");std::fs::write(&verifier,include_bytes!("../../../deployment/windows/Verify-Package.ps1"))?; + let status=std::process::Command::new("powershell.exe").args(["-NoProfile","-NonInteractive","-ExecutionPolicy","Bypass","-File"]).arg(verifier).arg("-Path").arg(path).arg("-Publisher").arg(publisher).status()?; + ensure!(status.success(),"Artifact signature or publisher rejected");Ok(()) +} diff --git a/src/client.rs b/src/client.rs index dcd5941dfbe..d7750e94e01 100644 --- a/src/client.rs +++ b/src/client.rs @@ -3480,6 +3480,15 @@ pub async fn handle_hash( peer: &mut Stream, ) { lc.write().unwrap().hash = hash.clone(); + #[cfg(feature = "swan_custom")] + { + let target = lc.read().unwrap().id.clone(); + match crate::managed::login(&hash.challenge, &target) { + Ok(proof) => send_login(lc, String::new(), String::new(), proof, peer).await, + Err(_) => interface.msgbox("error", "Company authorization required", "Start this session from the authenticated technician launcher.", ""), + } + return; + } // Take care of password application order // switch_uuid diff --git a/src/common.rs b/src/common.rs index 5b06a6930d8..0796ffb9953 100644 --- a/src/common.rs +++ b/src/common.rs @@ -2082,7 +2082,12 @@ pub fn rustdesk_interval(i: Interval) -> ThrottledInterval { pub fn load_custom_client() { #[cfg(feature = "swan_custom")] - load_swan_client_defaults(); + { + load_swan_client_defaults(); + // Company configuration has its own pinned trust key. Never load an + // upstream custom.txt over the managed security and routing policy. + return; + } #[cfg(debug_assertions)] if let Ok(data) = std::fs::read_to_string("./custom.txt") { @@ -2107,79 +2112,7 @@ pub fn load_custom_client() { #[cfg(feature = "swan_custom")] fn load_swan_client_defaults() { - const APP_NAME: &str = "Swan Remote Support"; - const RENDEZVOUS_SERVER: &str = env!("RENDEZVOUS_SERVER"); - const RS_PUB_KEY: &str = env!("RS_PUB_KEY"); - const RELAY_SERVER: Option<&str> = option_env!("RELAY_SERVER"); - const API_SERVER: Option<&str> = option_env!("API_SERVER"); - - *config::APP_NAME.write().unwrap() = APP_NAME.to_owned(); - - // Keep every Swan build pinned to the intended self-hosted network. The - // Tailscale rendezvous/relay address and Ed25519 public key are public - // routing/trust configuration values, not credentials. - let mut overrides = config::OVERWRITE_SETTINGS.write().unwrap(); - overrides.insert( - keys::OPTION_CUSTOM_RENDEZVOUS_SERVER.to_owned(), - RENDEZVOUS_SERVER.to_owned(), - ); - overrides.insert(keys::OPTION_KEY.to_owned(), RS_PUB_KEY.to_owned()); - if let Some(relay_server) = RELAY_SERVER.filter(|value| !value.trim().is_empty()) { - overrides.insert( - keys::OPTION_RELAY_SERVER.to_owned(), - relay_server.to_owned(), - ); - } - if let Some(api_server) = API_SERVER.filter(|value| !value.trim().is_empty()) { - overrides.insert(keys::OPTION_API_SERVER.to_owned(), api_server.to_owned()); - } - // Never replace this branded client with an upstream public-network build. - // Swan updates must be built, tested, and signed through the Swan release - // process. - overrides.insert(keys::OPTION_ALLOW_AUTO_UPDATE.to_owned(), "N".to_owned()); - drop(overrides); - - // This managed-support edition is installed in person by Swan Computing. - // It is ready for unattended operation only after the express setup tool - // creates a unique permanent password on that specific device. - let mut defaults = config::DEFAULT_SETTINGS.write().unwrap(); - defaults.insert(keys::OPTION_APPROVE_MODE.to_owned(), "password".to_owned()); - defaults.insert( - keys::OPTION_VERIFICATION_METHOD.to_owned(), - "use-permanent-password".to_owned(), - ); - defaults.insert( - keys::OPTION_TEMPORARY_PASSWORD_LENGTH.to_owned(), - "10".to_owned(), - ); - defaults.insert( - keys::OPTION_ALLOW_ONLY_CONN_WINDOW_OPEN.to_owned(), - "N".to_owned(), - ); - drop(defaults); - - // Customer devices receive support but do not initiate outbound RustDesk - // sessions. Network/server controls are hidden so the embedded Tailscale - // configuration cannot be accidentally replaced. The tray and stop-service - // controls remain visible so the computer owner can see and pause support. - let mut hard_settings = config::HARD_SETTINGS.write().unwrap(); - hard_settings.insert("conn-type".to_owned(), "incoming".to_owned()); - hard_settings.insert("disable-account".to_owned(), "Y".to_owned()); - hard_settings.insert("disable-ab".to_owned(), "Y".to_owned()); - drop(hard_settings); - - let mut builtin_settings = config::BUILTIN_SETTINGS.write().unwrap(); - builtin_settings.insert( - keys::OPTION_HIDE_NETWORK_SETTINGS.to_owned(), - "Y".to_owned(), - ); - builtin_settings.insert(keys::OPTION_HIDE_SERVER_SETTINGS.to_owned(), "Y".to_owned()); - builtin_settings.insert(keys::OPTION_HIDE_PROXY_SETTINGS.to_owned(), "Y".to_owned()); - builtin_settings.insert(keys::OPTION_DISABLE_CHANGE_ID.to_owned(), "Y".to_owned()); - builtin_settings.insert( - keys::OPTION_ALLOW_LOGON_SCREEN_PASSWORD.to_owned(), - "Y".to_owned(), - ); + crate::managed::apply_defaults(); } fn read_custom_client_advanced_settings( diff --git a/src/core_main.rs b/src/core_main.rs index 6b437a98845..2e75129ef81 100644 --- a/src/core_main.rs +++ b/src/core_main.rs @@ -269,6 +269,8 @@ pub fn core_main() -> Option> { Ok(_) => translate("Installation Successful!".to_string()), Err(err) => { println!("Failed with error: {err}"); + #[cfg(feature = "swan_custom")] + std::process::exit(1); translate("Installation failed!".to_string()) } }; diff --git a/src/flutter_ffi.rs b/src/flutter_ffi.rs index 0777443cf3b..f01eb085918 100644 --- a/src/flutter_ffi.rs +++ b/src/flutter_ffi.rs @@ -2811,6 +2811,10 @@ pub fn main_get_printer_names() -> SyncReturn { } pub fn main_get_common(key: String) -> String { + #[cfg(feature = "swan_custom")] + if key == "company-overview" { + return crate::ipc::get_config("company-overview").ok().flatten().unwrap_or_else(crate::managed::overview); + } if key == "is-printer-installed" { #[cfg(target_os = "windows")] { @@ -2900,6 +2904,13 @@ pub fn main_get_common_sync(key: String) -> SyncReturn { } pub fn main_set_common(_key: String, _value: String) { + #[cfg(feature = "swan_custom")] + if _key == "company-revoke-unattended" && _value == "N" { + if let Err(error) = crate::ipc::set_config("company-revoke-unattended", "N".into()) { + log::error!("Cannot revoke company unattended support: {}", error); + } + return; + } #[cfg(target_os = "windows")] if _key == "install-printer" && crate::platform::is_win_10_or_greater() { std::thread::spawn(move || { diff --git a/src/ipc.rs b/src/ipc.rs index 68c987f4ece..3d3064ad96b 100644 --- a/src/ipc.rs +++ b/src/ipc.rs @@ -827,6 +827,11 @@ async fn handle(data: Data, stream: &mut Connection) { let value; if name == "id" { value = Some(Config::get_id()); + } else if name == "company-overview" { + #[cfg(feature = "swan_custom")] + { value = Some(crate::managed::overview()); } + #[cfg(not(feature = "swan_custom"))] + { value = None; } } else if name == "temporary-password" { value = Some(password::temporary_password()); } else if name == "permanent-password-storage-and-salt" { @@ -879,6 +884,25 @@ async fn handle(data: Data, stream: &mut Connection) { allow_err!(stream.send(&Data::Config((name, value))).await); } Some(value) => { + #[cfg(feature = "swan_custom")] + if name == "company-revoke-unattended" && value == "N" { + match swan_agent::AgentState::load(&swan_agent::state_directory()) { + Ok(mut state) => { + state.unattended_consent = false; + match state.save(&swan_agent::state_directory()) { + Ok(()) => { + // Revocation is already durable locally even if the API is offline. + if let Err(error) = state.consent(false).await { + log::warn!("Server consent synchronization failed: {}", error); + } + } + Err(error) => log::error!("Cannot persist consent revocation: {}", error), + } + } + Err(error) => log::error!("Cannot load company consent: {}", error), + } + return; + } let mut updated = true; if name == "id" { Config::set_key_confirmed(false); diff --git a/src/lib.rs b/src/lib.rs index 49cb2b7e97c..4c53ddb6553 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -58,6 +58,8 @@ mod whiteboard; #[cfg(not(any(target_os = "android", target_os = "ios")))] mod updater; +#[cfg(feature = "swan_custom")] +mod managed; mod ui_cm_interface; mod ui_interface; diff --git a/src/managed.rs b/src/managed.rs new file mode 100644 index 00000000000..f6b3369cdf2 --- /dev/null +++ b/src/managed.rs @@ -0,0 +1,72 @@ +//! Managed edition authorization is enforced on the receiving device, independent +//! of the ID server, relay, direct transport, or customer approval UI. +use hbb_common::{config::{self, keys}, ResultType}; +use swan_agent::{protocol::{self, Edition, ManagedLogin, SignedEnvelope}, AgentState, Lease}; + +pub fn apply_defaults() { + *config::APP_NAME.write().unwrap() = "Swan Remote Support".into(); + let state = AgentState::load(&swan_agent::state_directory()); + let profile = state.as_ref().ok().and_then(|s| s.company_profile().ok()); + let technician = state.as_ref().map(|s| s.bootstrap.edition == Edition::Technician).unwrap_or(false); + let mut overrides = config::OVERWRITE_SETTINGS.write().unwrap(); + // Fail closed before setup; never use an upstream public server or legacy tailnet. + overrides.insert(keys::OPTION_CUSTOM_RENDEZVOUS_SERVER.into(), profile.as_ref().map(|p|p.rendezvous.clone()).unwrap_or_else(||"unconfigured.invalid".into())); + overrides.insert(keys::OPTION_RELAY_SERVER.into(), profile.as_ref().map(|p|p.relay.clone()).unwrap_or_else(||"unconfigured.invalid".into())); + overrides.insert(keys::OPTION_KEY.into(),profile.as_ref().map(|p|p.transport_public_key.clone()).unwrap_or_default()); + overrides.insert(keys::OPTION_API_SERVER.into(),String::new()); + // Company release updates are handled by the configuration agent, not upstream. + overrides.insert(keys::OPTION_ALLOW_AUTO_UPDATE.into(),"N".into()); + overrides.insert(keys::OPTION_APPROVE_MODE.into(),"click".into()); + overrides.insert(keys::OPTION_ALLOW_ONLY_CONN_WINDOW_OPEN.into(),"N".into()); + drop(overrides); + let mut hard=config::HARD_SETTINGS.write().unwrap(); + hard.insert("conn-type".into(),if technician{"outgoing"}else{"incoming"}.into()); + hard.insert("disable-account".into(),"Y".into());hard.insert("disable-ab".into(),"Y".into()); + drop(hard); + let mut builtin=config::BUILTIN_SETTINGS.write().unwrap(); + for key in [keys::OPTION_HIDE_NETWORK_SETTINGS,keys::OPTION_HIDE_SERVER_SETTINGS,keys::OPTION_HIDE_PROXY_SETTINGS,keys::OPTION_DISABLE_CHANGE_ID] {builtin.insert(key.into(),"Y".into());} + // Consent and signed grants, rather than Windows-lock-screen password fallback, authorize sessions. + builtin.insert(keys::OPTION_ALLOW_LOGON_SCREEN_PASSWORD.into(),"N".into()); +} + +pub async fn claim(password:&[u8],challenge:&str,peer:&str)->ResultType { + let login=ManagedLogin::from_wire(password)?; + let state=AgentState::load(&swan_agent::state_directory())?; + Ok(state.claim(&login,challenge,peer).await?) +} + +pub async fn renew(lease:&mut Lease)->ResultType<()> { + let state=AgentState::load(&swan_agent::state_directory())?; + state.renew(lease).await?;Ok(()) +} + +pub fn login(challenge:&str,target:&str)->ResultType> { + // Hold the shared activity lock for the outgoing app process. Incoming leases + // hold their own locks. The updater acquires the exclusive side before install. + static ACTIVITY:std::sync::Mutex>=std::sync::Mutex::new(None); + let mut activity=ACTIVITY.lock().unwrap(); + if activity.is_none(){*activity=Some(swan_agent::lock_session(&swan_agent::state_directory())?);} + drop(activity); + let state=AgentState::load(&swan_agent::state_directory())?; + if state.bootstrap.edition!=Edition::Technician {hbb_common::bail!("Technician edition required");} + let envelope:SignedEnvelope=serde_json::from_str(&std::env::var("SWAN_SESSION_GRANT")?)?; + let grant:protocol::SessionGrant=envelope.verify(&protocol::public_key(&state.bootstrap.profile_public_key)?)?; + grant.validate(&state.bootstrap.company_id,&grant.device_id,target,protocol::now())?; + // A ticket stolen from a connection cannot be used without this ephemeral proof key. + let key=protocol::signing_key_from_hex(&std::env::var("SWAN_SESSION_PROOF_KEY")?)?; + Ok(ManagedLogin::prove(envelope,&key,challenge).to_wire()?) +} + +pub fn overview()->String { + let state=AgentState::load(&swan_agent::state_directory()); + match state { + Ok(state)=>match state.company_profile(){ + Ok(profile)=>{ + let brand=if state.bootstrap.edition==Edition::Technician{&profile.technician}else{&profile.customer}; + serde_json::json!({"configured":true,"enrolled":state.device_id.is_some(),"unattended":state.unattended_consent,"display_name":brand.display_name,"primary_color":brand.primary_color,"logo_svg":brand.logo_svg,"support_url":brand.support_url,"consent_text":brand.consent_text,"domain":profile.management_url}).to_string() + } + Err(_)=>"{\"configured\":false}".into() + }, + Err(_)=>"{\"configured\":false}".into() + } +} diff --git a/src/server/connection.rs b/src/server/connection.rs index bdf9cba8b18..5c108333f6d 100644 --- a/src/server/connection.rs +++ b/src/server/connection.rs @@ -316,6 +316,8 @@ pub struct Connection { port_forward_address: String, tx_to_cm: mpsc::UnboundedSender, authorized: bool, + #[cfg(feature = "swan_custom")] + managed_lease: Option, require_2fa: Option, keyboard: bool, clipboard: bool, @@ -517,6 +519,8 @@ impl Connection { port_forward_address: "".to_owned(), tx_to_cm, authorized: false, + #[cfg(feature = "swan_custom")] + managed_lease: None, keyboard: Self::permission(keys::OPTION_ENABLE_KEYBOARD, &control_permissions), clipboard: Self::permission(keys::OPTION_ENABLE_CLIPBOARD, &control_permissions), audio: Self::permission(keys::OPTION_ENABLE_AUDIO, &control_permissions), @@ -1065,6 +1069,18 @@ impl Connection { } } _ = second_timer.tick() => { + #[cfg(feature = "swan_custom")] + if let Some(lease) = conn.managed_lease.as_mut() { + let expired = swan_agent::protocol::now() >= lease.expires_at; + let renewal_failed = !expired + && swan_agent::protocol::now().saturating_sub(lease.last_renewed) >= 60 + && crate::managed::renew(lease).await.is_err(); + if expired || renewal_failed { + conn.send_close_reason_no_retry("Company session authorization expired or revoked").await; + conn.on_close("Managed authorization unavailable", true).await; + break; + } + } #[cfg(windows)] conn.portable_check(); raii::AuthedConnID::check_wake_lock_on_setting_changed(); @@ -1613,6 +1629,11 @@ impl Connection { // Returns whether this connection should be kept alive. // `true` does not necessarily mean authorization succeeded (e.g. REQUIRE_2FA case). async fn send_logon_response_and_keep_alive(&mut self) -> bool { + #[cfg(feature = "swan_custom")] + if self.managed_lease.as_ref().map(|lease| swan_agent::protocol::now() < lease.expires_at) != Some(true) { + self.send_login_error("A valid company session grant is required").await; + return false; + } if self.authorized { return true; } @@ -2523,11 +2544,26 @@ impl Connection { } // After handling CloseReason messages, proceed to process other message types if let Some(message::Union::LoginRequest(lr)) = msg.union { + #[cfg(feature = "swan_custom")] + if self.managed_lease.is_none() { + match crate::managed::claim(&lr.password, &self.hash.challenge, &Config::get_id()).await { + Ok(lease) => self.managed_lease = Some(lease), + Err(_) => { + self.send_login_error("Company session authorization denied").await; + return false; + } + } + } self.handle_login_request_without_validation(&lr).await; if self.authorized { return true; } self.reset_session_scope_for_login(); + #[cfg(feature = "swan_custom")] + if matches!(lr.union.as_ref(), Some(login_request::Union::Terminal(_)) | Some(login_request::Union::PortForward(_)) | Some(login_request::Union::ViewCamera(_))) { + self.send_login_error("This company edition permits desktop support and file transfer only").await; + return false; + } match lr.union { Some(login_request::Union::FileTransfer(ft)) => { if !Self::permission( @@ -2673,6 +2709,19 @@ impl Connection { crate::get_builtin_option(keys::OPTION_ALLOW_LOGON_SCREEN_PASSWORD) == "Y" && is_logon(); + #[cfg(feature = "swan_custom")] + { + // Server-verified technician identity replaces all password/recent-session paths. + if self.managed_lease.as_ref().map(|lease| lease.unattended) == Some(true) { + if !self.send_logon_response_and_keep_alive().await { return false; } + self.try_start_cm(lr.my_id, lr.my_name, self.authorized); + } else { + self.try_start_cm(lr.my_id, lr.my_name, false); + self.send_login_error(crate::client::LOGIN_MSG_NO_PASSWORD_ACCESS).await; + } + return true; + } + if (password::approve_mode() == ApproveMode::Click && !allow_logon_screen_password) || password::approve_mode() == ApproveMode::Both && !password::has_valid_password() { From e98732374a1eb27d69d50a4e1b290a102ed7cde2 Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Thu, 1 Oct 2026 12:48:52 +0200 Subject: [PATCH 002/652] Verify local HTTPS lifecycle and harden artifact publication and state recovery --- .github/workflows/bridge.yml | 2 +- .github/workflows/swan-windows-build.yml | 4 + Cargo.toml | 2 +- deployment/windows/LOCAL-TESTING.md | 4 +- deployment/windows/Start-LocalTest.ps1 | 2 +- deployment/windows/local-https.js | 30 ++++++++ deployment/windows/test-company-lifecycle.js | 60 +++++++++++++++ product/Cargo.lock | 79 +++++++++++++++++--- product/Cargo.toml | 2 +- product/agent/Cargo.toml | 1 + product/agent/src/lib.rs | 57 ++++++++++++-- product/agent/src/update.rs | 3 +- product/management/Cargo.toml | 1 + product/management/src/service.rs | 58 +++++++++++--- product/management/src/tests.rs | 4 + product/management/web/app.js | 2 +- product/protocol/Cargo.toml | 1 + product/protocol/src/lib.rs | 8 +- product/worker/Cargo.toml | 1 + product/worker/src/main.rs | 23 +++--- src/managed.rs | 1 + src/server/connection.rs | 11 +++ 22 files changed, 307 insertions(+), 49 deletions(-) create mode 100644 deployment/windows/local-https.js create mode 100644 deployment/windows/test-company-lifecycle.js diff --git a/.github/workflows/bridge.yml b/.github/workflows/bridge.yml index a7b74fa55a1..163f9f19cc9 100644 --- a/.github/workflows/bridge.yml +++ b/.github/workflows/bridge.yml @@ -8,7 +8,7 @@ on: 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: diff --git a/.github/workflows/swan-windows-build.yml b/.github/workflows/swan-windows-build.yml index 4f0e20aaa43..8ab870650f5 100644 --- a/.github/workflows/swan-windows-build.yml +++ b/.github/workflows/swan-windows-build.yml @@ -10,6 +10,7 @@ env: 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" @@ -70,6 +71,9 @@ jobs: 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 diff --git a/Cargo.toml b/Cargo.toml index 8bb91d9e0bf..d87671340cf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -204,7 +204,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/deployment/windows/LOCAL-TESTING.md b/deployment/windows/LOCAL-TESTING.md index 418a2de55d9..836fdb1f5d4 100644 --- a/deployment/windows/LOCAL-TESTING.md +++ b/deployment/windows/LOCAL-TESTING.md @@ -6,6 +6,6 @@ Copy `local-test.env.example` to `local-test.env`, build `product/Cargo.toml`, t The default local administration address is http://127.0.0.1:18080. Use the one-time token in `swan-data/local-test/setup-token.txt` for initial setup. Do not paste the token into issues or build logs. Existing RustDesk services are independent of this test process. -This loopback HTTP listener is for administration smoke tests. Endpoint enrollment and profile synchronization require HTTPS with a trusted certificate; put a local TLS reverse proxy in front before testing those flows. Do not disable certificate validation. Public direct and relay testing requires a company-owned reachable endpoint and a separate external test machine. +This loopback HTTP listener is for administration smoke tests. Endpoint enrollment and profile synchronization require HTTPS with a trusted certificate. `local-https.js` provides an isolated loopback proxy using PFX/password settings from the ignored environment file. Debug agents accept a DER test certificate through `SWAN_TEST_CA_FILE`; release builds ignore this variable and use operating-system trust roots. Certificate validation remains enabled. Public direct and relay testing requires a company-owned reachable endpoint and a separate external test machine. -Verified on this development machine: management starts, administration responds with HTTP 200, and the seven product-workspace tests pass. The native Flutter/RustDesk integration, production signatures, complete installers, automatic update execution and clean-machine Windows compatibility remain release requirements; this development build is not a production release. +Verified on this development machine: management starts, administration responds with HTTP 200, and eleven product-workspace tests pass. `test-company-lifecycle.js` also passed against the running HTTPS proxy and built agent: fresh company setup, device enrollment, approval denial, technician MFA/group inventory, consent denial, grant replay denial, signed branding sync and revocation. It refuses to alter an already configured test company. This API integration uses a test device ID and does not demonstrate native remote desktop. The native Flutter/RustDesk integration, production signatures, complete installers, update installation/recovery and clean-machine Windows compatibility remain release requirements; this development build is not a production release. diff --git a/deployment/windows/Start-LocalTest.ps1 b/deployment/windows/Start-LocalTest.ps1 index ca4397ebf69..5df2e062fe6 100644 --- a/deployment/windows/Start-LocalTest.ps1 +++ b/deployment/windows/Start-LocalTest.ps1 @@ -5,7 +5,7 @@ param( $ErrorActionPreference = 'Stop' if (!(Test-Path -LiteralPath $EnvironmentFile)) { throw 'Create local-test.env from local-test.env.example first.' } # Only named server settings are accepted; the file is data, never PowerShell code. -$allowed = @('SWAN_LISTEN', 'SWAN_DATA_DIR', 'SWAN_RELEASE_PUBLIC_KEY') +$allowed = @('SWAN_LISTEN', 'SWAN_DATA_DIR', 'SWAN_RELEASE_PUBLIC_KEY', 'SWAN_TEST_TLS_PORT', 'SWAN_TEST_TLS_PFX', 'SWAN_TEST_TLS_PASSWORD') foreach ($line in Get-Content -LiteralPath $EnvironmentFile) { if (!$line.Trim() -or $line.TrimStart().StartsWith('#')) { continue } if ($line -notmatch '^([A-Z_]+)=(.*)$' -or $Matches[1] -notin $allowed) { throw 'Invalid local environment setting.' } diff --git a/deployment/windows/local-https.js b/deployment/windows/local-https.js new file mode 100644 index 00000000000..ef077a56aed --- /dev/null +++ b/deployment/windows/local-https.js @@ -0,0 +1,30 @@ +// Local test TLS termination. PFX and password are private local environment data. +const fs = require('node:fs'); +const https = require('node:https'); +const http = require('node:http'); +const envFile = process.argv[2]; +if (!envFile) throw new Error('Pass the ignored local-test.env path'); +const settings = {}; +for (const line of fs.readFileSync(envFile, 'utf8').split(/\r?\n/)) { + if (!line.trim() || line.trimStart().startsWith('#')) continue; + const match = /^([A-Z_]+)=(.*)$/.exec(line); + if (!match) throw new Error('Invalid local environment file'); + settings[match[1]] = match[2]; +} +if (!/^127\.0\.0\.1:\d+$/.test(settings.SWAN_LISTEN)) throw new Error('Management must use loopback'); +const port = Number(settings.SWAN_TEST_TLS_PORT); +if (!Number.isInteger(port) || port < 1024 || port > 65535) throw new Error('Invalid local TLS port'); +const upstreamPort = Number(settings.SWAN_LISTEN.split(':')[1]); +const server = https.createServer({ + pfx: fs.readFileSync(settings.SWAN_TEST_TLS_PFX), + passphrase: settings.SWAN_TEST_TLS_PASSWORD, + minVersion: 'TLSv1.2', +}, (request, response) => { + const upstream = http.request({hostname:'127.0.0.1', port:upstreamPort, + path:request.url, method:request.method, headers:{...request.headers, host:`localhost:${upstreamPort}`}}, result => { + response.writeHead(result.statusCode, result.headers); result.pipe(response); + }); + upstream.on('error', () => {if (!response.headersSent) response.writeHead(502);response.end('Local management unavailable');}); + request.on('aborted', () => upstream.destroy());request.pipe(upstream); +}); +server.listen(port, '127.0.0.1', () => console.log(`Local HTTPS test listener: https://localhost:${port}`)); diff --git a/deployment/windows/test-company-lifecycle.js b/deployment/windows/test-company-lifecycle.js new file mode 100644 index 00000000000..8dfd0d1fa10 --- /dev/null +++ b/deployment/windows/test-company-lifecycle.js @@ -0,0 +1,60 @@ +// Real local HTTPS/API and built-agent integration. Requires a fresh test server. +// This does not substitute for native remote desktop or clean-machine tests. +const fs=require('node:fs'), path=require('node:path'), https=require('node:https'); +const crypto=require('node:crypto'), assert=require('node:assert/strict'); +const {spawnSync}=require('node:child_process'); +const root=path.resolve(__dirname,'../..'); +const data=path.join(root,'swan-data/local-test'); +const agent=path.join(root,'product/target/debug/swan-agent.exe'); +const ca=new crypto.X509Certificate(fs.readFileSync(path.join(data,'localhost.cer'))).toString(); +const base='https://localhost:18443'; +const alphabet='ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'; +function base32(bytes){let bits=0,value=0,result='';for(const byte of bytes){value=(value<<8)|byte;bits+=8;while(bits>=5){result+=alphabet[(value>>>(bits-5))&31];bits-=5;}}if(bits)result+=alphabet[(value<<(5-bits))&31];return result;} +function decode32(text){let bits=0,value=0,result=[];for(const c of text.replace(/=/g,'')){value=(value<<5)|alphabet.indexOf(c);bits+=5;if(bits>=8){result.push((value>>>(bits-8))&255);bits-=8;}}return Buffer.from(result);} +function totp(secret,offset=0){const step=BigInt(Math.floor(Date.now()/30000)+offset);const counter=Buffer.alloc(8);counter.writeBigUInt64BE(step);const hash=crypto.createHmac('sha1',decode32(secret)).update(counter).digest();const pos=hash[19]&15;return String((hash.readUInt32BE(pos)&0x7fffffff)%1000000).padStart(6,'0');} +function api(endpoint,method='GET',token,body){return new Promise((resolve,reject)=>{ + const bytes=body===undefined?null:Buffer.from(JSON.stringify(body)); + const request=https.request(base+'/api/v1/'+endpoint,{method,ca,headers:{'content-type':'application/json',...(token?{authorization:'Bearer '+token}:{}),...(bytes?{'content-length':bytes.length}:{})}},response=>{ + const chunks=[];response.on('data',chunk=>chunks.push(chunk));response.on('end',()=>{try{resolve({status:response.statusCode,body:JSON.parse(Buffer.concat(chunks))});}catch(error){reject(error);}}); + });request.on('error',reject);if(bytes)request.write(bytes);request.end(); +});} +function runAgent(directory,args,extra={}){const result=spawnSync(agent,args,{env:{...process.env,SWAN_STATE_DIR:directory,SWAN_TEST_CA_FILE:path.join(data,'localhost.cer'),...extra},encoding:'utf8',timeout:30000});assert.equal(result.status,0,`Agent ${args[0]} failed: ${result.stderr}`);return result.stdout;} +function state(directory){return JSON.parse(fs.readFileSync(path.join(directory,'managed-state.json')));} +async function main(){ + const status=(await api('status')).body;assert.equal(status.configured,false,'Refuse to change an already configured test company'); + const password=crypto.randomBytes(24).toString('hex'), secret=base32(crypto.randomBytes(20)); + const brand={display_name:'Local Test Company Support',primary_color:'#007F82',logo_svg:'',support_url:base,consent_text:'Local test support requires approval.'}; + const transport=crypto.generateKeyPairSync('ed25519').publicKey.export({type:'spki',format:'der'}).subarray(-32).toString('base64'); + const profile={schema:1,company_id:'setup',revision:1,issued_at:Math.floor(Date.now()/1000),expires_at:Math.floor(Date.now()/1000)+3600,management_url:base,rendezvous:'localhost:22116',relay:'localhost:22117',transport_public_key:transport,customer:brand,technician:{...brand,display_name:'Local Test Technician'},allow_unattended:true,updates_paused:true,rollout_percent:100,maintenance_start_utc:0,maintenance_end_utc:0,update_channel:'test',next_profile_public_key:null}; + const setup=await api('setup','POST',fs.readFileSync(path.join(data,'setup-token.txt'),'utf8').trim(),{profile,username:'test-admin',password,totp_secret:secret,totp_code:totp(secret)});assert.equal(setup.status,200); + fs.writeFileSync(path.join(data,'test-admin.private.json'),JSON.stringify({username:'test-admin',password,totp_secret:secret})); + const login=await api('login','POST',null,{username:'test-admin',password,totp_code:totp(secret,1)});assert.equal(login.status,200);const admin=login.body.token; + const signed=(await api('profile')).body;const current=JSON.parse(Buffer.from(signed.payload,'base64')); + const bootstrap={schema:1,edition:'customer',company_id:current.company_id,management_url:base,profile_public_key:status.profile_public_key,release_public_key:''}; + const customerDir=path.join(data,'customer-agent'), techDir=path.join(data,'technician-agent'); + const customerBootstrap=path.join(data,'customer-bootstrap.json');fs.writeFileSync(customerBootstrap,JSON.stringify(bootstrap)); + runAgent(customerDir,['setup',customerBootstrap,'--accept-company']);runAgent(customerDir,['enroll','Local HTTPS test device','987654321']); + const customer=state(customerDir);assert.equal(customer.unattended_consent,false); + const proof=crypto.generateKeyPairSync('ed25519').publicKey.export({type:'spki',format:'der'}).subarray(-32).toString('base64'); + const ticket={device_id:customer.device_id,proof_public_key:proof,unattended:false}; + assert.equal((await api('grants','POST',admin,ticket)).status,403,'Pending device was accessible'); + assert.equal((await api(`devices/${customer.device_id}/state`,'PUT',admin,{state:'approved',group:'test-customers'})).status,200); + const technicianPassword=crypto.randomBytes(24).toString('hex'); + const technician=(await api('users','POST',admin,{username:'test-technician',password:technicianPassword,role:'technician'})).body; + const techBootstrap=path.join(data,'technician-bootstrap.json');fs.writeFileSync(techBootstrap,JSON.stringify({...bootstrap,edition:'technician'}));runAgent(techDir,['setup',techBootstrap,'--accept-company']); + const technicianLogin=JSON.parse(runAgent(techDir,['login','test-technician'],{SWAN_LOGIN_PASSWORD:technicianPassword,SWAN_LOGIN_TOTP:totp(technician.totp_secret)})); + const auth={SWAN_TECHNICIAN_TOKEN:technicianLogin.token};assert.equal(JSON.parse(runAgent(techDir,['devices'],auth)).length,0,'Technician without group access saw device'); + assert.equal((await api(`groups/test-customers/users/${technician.id}`,'PUT',admin,{})).status,200); + assert.equal(JSON.parse(runAgent(techDir,['devices'],auth)).length,1); + assert.equal((await api('grants','POST',technicianLogin.token,{...ticket,unattended:true})).status,403,'Unattended grant issued without consent'); + const grant=await api('grants','POST',technicianLogin.token,ticket);assert.equal(grant.status,200); + assert.equal((await api('grants/claim','POST',customer.device_token,{grant:grant.body})).status,200); + assert.equal((await api('grants/claim','POST',customer.device_token,{grant:grant.body})).status,403,'Grant replay accepted'); + current.customer.display_name='Changed Local Test Branding';assert.equal((await api('profile','PUT',admin,current)).status,200); + runAgent(customerDir,['sync']);const updated=JSON.parse(Buffer.from(state(customerDir).profile.payload,'base64'));assert.equal(updated.customer.display_name,'Changed Local Test Branding');assert.equal(updated.revision,2); + assert.equal((await api(`devices/${customer.device_id}/state`,'PUT',admin,{state:'revoked',group:'test-customers'})).status,200); + assert.equal((await api('grants','POST',technicianLogin.token,ticket)).status,403,'Revoked device accessible'); + fs.writeFileSync(path.join(data,'lifecycle-result.json'),JSON.stringify({passed:true,at:new Date().toISOString(),checks:['HTTPS certificate validation','fresh company setup','built agent bootstrap and enrollment','pending approval denial','technician MFA login and group inventory','unattended consent denial','grant replay denial','signed branding sync','device revocation']},null,2)); + console.log('PASS: real local HTTPS company lifecycle and built-agent checks. Native sessions and installers remain separate tests.'); +} +main().catch(error=>{console.error(error.message);process.exitCode=1;}); diff --git a/product/Cargo.lock b/product/Cargo.lock index 84e9283c56e..5707240b66d 100644 --- a/product/Cargo.lock +++ b/product/Cargo.lock @@ -255,6 +255,22 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c74b8349d32d297c9134b8c88677813a227df8f779daa29bfc29c183fe3dca6" +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + [[package]] name = "cpufeatures" version = "0.2.17" @@ -695,10 +711,10 @@ dependencies = [ "hyper", "hyper-util", "rustls", + "rustls-native-certs", "tokio", "tokio-rustls", "tower-service", - "webpki-roots", ] [[package]] @@ -966,6 +982,12 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + [[package]] name = "parking_lot" version = "0.12.5" @@ -1261,6 +1283,7 @@ dependencies = [ "pin-project-lite", "quinn", "rustls", + "rustls-native-certs", "rustls-pki-types", "serde", "serde_json", @@ -1275,7 +1298,6 @@ dependencies = [ "wasm-bindgen", "wasm-bindgen-futures", "web-sys", - "webpki-roots", ] [[package]] @@ -1335,6 +1357,18 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-native-certs" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + [[package]] name = "rustls-pki-types" version = "1.15.1" @@ -1368,12 +1402,44 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "scopeguard" version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "semver" version = "1.0.28" @@ -1992,15 +2058,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "webpki-roots" -version = "1.0.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" -dependencies = [ - "rustls-pki-types", -] - [[package]] name = "widestring" version = "1.2.1" diff --git a/product/Cargo.toml b/product/Cargo.toml index a4c0c23a86c..a1f6b1fa82f 100644 --- a/product/Cargo.toml +++ b/product/Cargo.toml @@ -20,4 +20,4 @@ hex = "0.4" url = "2" uuid = { version = "1", features = ["v4", "serde"] } tokio = { version = "1", features = ["full"] } -reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "blocking"] } +reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls-native-roots", "blocking"] } diff --git a/product/agent/Cargo.toml b/product/agent/Cargo.toml index 00beec22844..c18f61a3b20 100644 --- a/product/agent/Cargo.toml +++ b/product/agent/Cargo.toml @@ -1,4 +1,5 @@ [package] +workspace = ".." name = "swan-agent" version.workspace = true edition.workspace = true diff --git a/product/agent/src/lib.rs b/product/agent/src/lib.rs index a9dac2095cd..c61b16a7f48 100644 --- a/product/agent/src/lib.rs +++ b/product/agent/src/lib.rs @@ -48,8 +48,7 @@ impl AgentState { pub fn load_for_refresh(directory:&Path)->Result { let state:Self=serde_json::from_slice(&std::fs::read(directory.join("managed-state.json"))?)?; state.bootstrap.validate()?; - let profile:CompanyProfile=state.profile.verify(&public_key(&state.bootstrap.profile_public_key)?)?; - ensure!(profile.company_id==state.bootstrap.company_id && profile.revision>=state.accepted_revision,"Invalid cached company profile"); + state.cached_profile()?; Ok(state) } pub fn save(&self,directory:&Path)->Result<()> { @@ -76,12 +75,19 @@ impl AgentState { replace_state(&temporary,&path)?;Ok(()) } pub fn company_profile(&self)->Result { - let p:CompanyProfile=self.profile.verify(&public_key(&self.bootstrap.profile_public_key)?)?; + let p=self.cached_profile()?; p.validate(&self.bootstrap.company_id,self.accepted_revision,now())?; Ok(p) } + fn cached_profile(&self)->Result { + let p:CompanyProfile=self.profile.verify(&public_key(&self.bootstrap.profile_public_key)?)?; + // Expiration blocks use, but a previously signed rotation key remains a + // trust anchor for obtaining fresh policy after a long offline period. + p.validate(&self.bootstrap.company_id,self.accepted_revision,now().min(p.expires_at.saturating_sub(1)))?; + Ok(p) + } pub fn client(&self)->Result { - Ok(reqwest::Client::builder().timeout(std::time::Duration::from_secs(15)).redirect(reqwest::redirect::Policy::none()).https_only(true).build()?) + http_client(15,false) } pub fn endpoint(&self,path:&str)->Result { self.bootstrap.validate()?; @@ -93,7 +99,7 @@ impl AgentState { let profile:CompanyProfile=match envelope.verify(&key){ Ok(p)=>p, Err(error)=>{ - let old=self.company_profile()?; + let old=self.cached_profile()?; let next=old.next_profile_public_key.as_ref().context("Profile signature invalid and no trusted key rotation")?; let p=envelope.verify(&public_key(next)?)?; // Commit the new pin only after every validation succeeds below. @@ -103,7 +109,7 @@ impl AgentState { }; profile.validate(&self.bootstrap.company_id,self.accepted_revision,now())?; if envelope.verify::(&key).is_err(){ - self.bootstrap.profile_public_key=self.company_profile()?.next_profile_public_key.context("Missing rotation key")?; + self.bootstrap.profile_public_key=self.cached_profile()?.next_profile_public_key.context("Missing rotation key")?; } self.bootstrap.management_url=profile.management_url.clone(); self.accepted_revision=profile.revision;self.profile=envelope; @@ -154,6 +160,9 @@ impl AgentState { ensure!(expires>now() && expires<=now()+310,"Invalid session lease"); lease.expires_at=expires;lease.last_renewed=now();Ok(()) } + pub async fn close(&self,grant_id:&str)->Result<()> { + self.client()?.post(self.endpoint(&format!("grants/{grant_id}/close"))?).bearer_auth(self.device_token.as_ref().context("Not enrolled")?).send().await?.error_for_status()?;Ok(()) + } } fn replace_state(temporary:&Path,path:&Path)->Result<()> { @@ -184,16 +193,50 @@ pub fn state_directory()->PathBuf { } pub async fn bootstrap(input:Bootstrap)->Result { input.validate()?; - let client=reqwest::Client::builder().timeout(std::time::Duration::from_secs(15)).redirect(reqwest::redirect::Policy::none()).https_only(true).build()?; + let client=http_client(15,false)?; let profile:SignedEnvelope=client.get(format!("{}/api/v1/profile",input.management_url.trim_end_matches('/'))).send().await?.error_for_status()?.json().await?; let parsed:CompanyProfile=profile.verify(&public_key(&input.profile_public_key)?)?; parsed.validate(&input.company_id,0,now())?; Ok(AgentState {bootstrap:input,profile,accepted_revision:parsed.revision,device_id:None,device_token:None,unattended_consent:false,last_release_sequence:0}) } +pub fn http_client(timeout_seconds:u64,artifact_redirects:bool)->Result { + let builder=reqwest::Client::builder().timeout(std::time::Duration::from_secs(timeout_seconds)).https_only(true) + .redirect(if artifact_redirects{reqwest::redirect::Policy::limited(5)}else{reqwest::redirect::Policy::none()}); + #[cfg(debug_assertions)] + let builder=if let Some(path)=std::env::var_os("SWAN_TEST_CA_FILE") { + builder.add_root_certificate(reqwest::Certificate::from_der(&std::fs::read(path)?)?) + }else{builder}; + Ok(builder.build()?) +} + #[cfg(test)] mod tests { use super::*; + fn fixture()->AgentState { + let key=SigningKey::from_bytes(&[7;32]);let public=STANDARD.encode(key.verifying_key().as_bytes()); + let profile=CompanyProfile{schema:1,company_id:"test-company".into(),revision:1,issued_at:now()-1000,expires_at:now()+3600,management_url:"https://support.example.com".into(),rendezvous:"support.example.com".into(),relay:"support.example.com".into(),transport_public_key:public.clone(),customer:Branding::default(),technician:Branding::default(),allow_unattended:true,updates_paused:true,rollout_percent:100,maintenance_start_utc:0,maintenance_end_utc:0,update_channel:"test".into(),next_profile_public_key:None}; + AgentState{bootstrap:Bootstrap{schema:1,edition:Edition::Customer,company_id:profile.company_id.clone(),management_url:profile.management_url.clone(),profile_public_key:public.clone(),release_public_key:public},profile:SignedEnvelope::sign(&profile,&key).unwrap(),accepted_revision:1,device_id:Some("device".into()),device_token:Some(random_token()),unattended_consent:true,last_release_sequence:0} + } + #[test] + fn stale_refresh_cannot_restore_consent_or_replay_update_state() { + let directory=std::env::temp_dir().join(format!("swan-state-{}",random_token())); + let mut state=fixture();state.save(&directory).unwrap();let stale=state.clone(); + state.unattended_consent=false;state.last_release_sequence=10;state.save(&directory).unwrap(); + stale.save(&directory).unwrap();let actual=AgentState::load(&directory).unwrap(); + assert!(!actual.unattended_consent);assert_eq!(actual.last_release_sequence,10); + assert_eq!(actual.device_id,state.device_id);assert_eq!(actual.device_token,state.device_token); + std::fs::remove_dir_all(directory).unwrap(); + } + #[test] + fn expired_cache_only_allows_refresh_and_never_unsigned_policy() { + let directory=std::env::temp_dir().join(format!("swan-expired-{}",random_token()));let mut state=fixture(); + let mut profile=state.company_profile().unwrap();profile.expires_at=now()-1; + state.profile=SignedEnvelope::sign(&profile,&SigningKey::from_bytes(&[7;32])).unwrap();state.save(&directory).unwrap(); + assert!(AgentState::load(&directory).is_err());assert!(AgentState::load_for_refresh(&directory).is_ok()); + state.profile.payload=STANDARD.encode(b"{}");std::fs::write(directory.join("managed-state.json"),serde_json::to_vec(&state).unwrap()).unwrap(); + assert!(AgentState::load_for_refresh(&directory).is_err());std::fs::remove_dir_all(directory).unwrap(); + } #[test] fn bootstrap_never_accepts_http_or_embedded_credentials() { let key=STANDARD.encode(SigningKey::from_bytes(&[1;32]).verifying_key().as_bytes()); diff --git a/product/agent/src/update.rs b/product/agent/src/update.rs index 2f8fc08aaec..6d1ffff7769 100644 --- a/product/agent/src/update.rs +++ b/product/agent/src/update.rs @@ -21,8 +21,7 @@ async fn download(url:&str,hash:&str,path:&Path)->Result<()> { https_url(url)?; // Artifact endpoints may redirect to HTTPS object storage. No credentials // are attached, and reqwest rejects any redirect to plain HTTP. - let client=reqwest::Client::builder().https_only(true).timeout(std::time::Duration::from_secs(600)) - .redirect(reqwest::redirect::Policy::limited(5)).build()?; + let client=http_client(600,true)?; let mut response=client.get(url).send().await?.error_for_status()?; ensure!(response.content_length().unwrap_or(0)<=512*1024*1024,"Package too large"); let mut bytes=Vec::new(); diff --git a/product/management/Cargo.toml b/product/management/Cargo.toml index 35b2dad9abc..448fa8a2114 100644 --- a/product/management/Cargo.toml +++ b/product/management/Cargo.toml @@ -1,4 +1,5 @@ [package] +workspace = ".." name = "swan-management" version.workspace = true edition.workspace = true diff --git a/product/management/src/service.rs b/product/management/src/service.rs index 32117889ca5..087d4cdbdd4 100644 --- a/product/management/src/service.rs +++ b/product/management/src/service.rs @@ -70,6 +70,8 @@ impl Store { db.pragma_update(None,"journal_mode","WAL")?; db.pragma_update(None,"foreign_keys","ON")?; db.busy_timeout(std::time::Duration::from_secs(5))?; + let version:u32=db.pragma_query_value(None,"user_version",|r|r.get(0))?; + ensure!(version<=1,"Database was created by a newer server; restore the pre-upgrade backup rather than downgrading it"); db.execute_batch(include_str!("schema.sql"))?; let artifact_dir=directory.join("artifacts");std::fs::create_dir_all(&artifact_dir)?; Ok(Self {db:Mutex::new(db),key:SigningKey::from_bytes(&key_bytes),setup_hash,limits:Mutex::new(HashMap::new()),artifact_dir}) @@ -134,6 +136,7 @@ pub fn router(store:Shared)->Router { .route("/api/v1/grants/{id}/renew",post(renew)) .route("/api/v1/grants/{id}/close",post(close_grant)) .route("/api/v1/audit",get(audit)) + .route("/api/v1/sessions",get(session_history)) .route("/api/v1/releases",get(releases).post(import_release)) .route("/api/v1/releases/{id}/approve",post(approve_release)) .route("/api/v1/device/update",get(device_update)) @@ -333,6 +336,15 @@ async fn audit(State(s):State,headers:HeaderMap)->ApiResult { s.user(&headers,true)?;let db=s.db.lock().unwrap();let mut statement=db.prepare("SELECT at,actor,event,target FROM audit ORDER BY id DESC LIMIT 500")?; let rows=statement.query_map([],|r|Ok(json!({"at":r.get::<_,u64>(0)?,"actor":r.get::<_,String>(1)?,"event":r.get::<_,String>(2)?,"target":r.get::<_,String>(3)?})))?.collect::,_>>()?;Ok(Json(json!(rows))) } +async fn session_history(State(s):State,headers:HeaderMap)->ApiResult { + let user=s.user(&headers,false)?;let db=s.db.lock().unwrap(); + let administrator:bool=db.query_row("SELECT role='admin' FROM users WHERE id=?1",[&user],|r|r.get(0))?; + let mut statement=db.prepare("SELECT g.id,g.body,g.claimed,g.closed,g.lease_until,d.name FROM grants g JOIN devices d ON d.id=g.device_id WHERE (?1=1 OR g.user_id=?2) ORDER BY g.rowid DESC LIMIT 200")?; + let rows=statement.query_map(params![administrator,user],|r|Ok((r.get::<_,String>(0)?,r.get::<_,String>(1)?,r.get::<_,bool>(2)?,r.get::<_,bool>(3)?,r.get::<_,u64>(4)?,r.get::<_,String>(5)?)))?.collect::,_>>()?; + let mut result=Vec::new(); + for(id,body,claimed,closed,lease,name)in rows {let grant:SessionGrant=serde_json::from_str(&body)?;result.push(json!({"id":id,"technician_id":grant.technician_id,"device_id":grant.device_id,"device_name":name,"requested_at":grant.issued_at,"unattended":grant.unattended,"claimed":claimed,"closed":closed,"lease_until":lease}));} + Ok(Json(json!(result))) +} #[derive(Deserialize)] struct ReleaseImport { envelope:SignedEnvelope, public_key:String } async fn import_release(State(s):State,headers:HeaderMap,Json(input):Json)->ApiResult { @@ -389,7 +401,8 @@ async fn create_worker(State(s):State,headers:HeaderMap)->ApiResult { #[derive(Deserialize)] struct BuildRequest { release_id:String } async fn create_build(State(s):State,headers:HeaderMap,Json(input):Json)->ApiResult { - let actor=s.user(&headers,true)?;let profile=s.profile()?; + let actor=s.user(&headers,true)?;let mut profile=s.profile()?; + profile.issued_at=now();profile.expires_at=now()+7*86400; let envelope:String=s.db.lock().unwrap().query_row("SELECT envelope FROM releases WHERE id=?1 AND approved=1",[&input.release_id],|r|r.get(0))?; let id=Uuid::new_v4().to_string();let body=json!({"release":serde_json::from_str::(&envelope)?,"profile":SignedEnvelope::sign(&profile,&s.key)?,"profile_public_key":STANDARD.encode(s.key.verifying_key().as_bytes()),"release_public_key":std::env::var("SWAN_RELEASE_PUBLIC_KEY").map_err(|_|bad("Release key not configured"))?}); s.db.lock().unwrap().execute("INSERT INTO builds(id,body,state) VALUES(?1,?2,'queued')",params![id,body.to_string()])?;s.audit(&actor,"build.queued",&id)?;Ok(Json(json!({"id":id}))) @@ -402,7 +415,7 @@ async fn claim_build(State(s):State,headers:HeaderMap)->ApiResult { s.worker(&headers)?;let worker=digest(token(&headers)?); let mut db=s.db.lock().unwrap();let tx=db.transaction()?; // Expired claims are retryable; worker output is named by immutable job ID. - tx.execute("UPDATE builds SET state='queued',worker='' WHERE state='running' AND claimed_at=tx.query_row("SELECT id,body FROM builds WHERE state='queued' ORDER BY rowid LIMIT 1",[],|r|Ok((r.get(0)?,r.get(1)?))).optional()?; if let Some((id,body))=row {tx.execute("UPDATE builds SET state='running',worker=?1,claimed_at=?2 WHERE id=?3",params![worker,now(),id])?;tx.commit()?;Ok(Json(json!({"id":id,"job":serde_json::from_str::(&body)?})))} else {Ok(Json(Value::Null))} } @@ -410,17 +423,27 @@ async fn claim_build(State(s):State,headers:HeaderMap)->ApiResult { struct BuildResult { success:bool, artifact_url:String,sha256:String,log:String } async fn finish_build(State(s):State,headers:HeaderMap,Path(id):Path,Json(input):Json)->ApiResult { s.worker(&headers)?; - if input.success && !s.artifact_dir.join(format!("{id}.zip")).is_file() {return Err(bad("Verified artifact upload required"));} + if Uuid::parse_str(&id).is_err(){return Err(bad("Invalid build identity"));} + let worker=digest(token(&headers)?); + let owned:bool=s.db.lock().unwrap().query_row("SELECT EXISTS(SELECT 1 FROM builds WHERE id=?1 AND worker=?2 AND state IN ('running','uploaded'))",params![id,worker],|r|r.get(0))?; + if !owned{return Err(denied());} if input.log.len()>8192 || (input.success && (https_url(&input.artifact_url).is_err() || input.sha256.len()!=64 || !input.sha256.bytes().all(|b|b.is_ascii_hexdigit()))) {return Err(bad("Invalid build result"));} + if input.success { + let expected=format!("{}/api/v1/downloads/{id}",s.profile()?.management_url.trim_end_matches('/')); + if input.artifact_url!=expected{return Err(bad("Artifact URL must use this company server"));} + let bytes=tokio::fs::read(s.artifact_dir.join(format!("{id}.zip"))).await.map_err(|_|bad("Verified artifact upload required"))?; + if !digest(bytes).eq_ignore_ascii_case(&input.sha256){return Err(bad("Uploaded artifact hash mismatch"));} + } let result=json!({"artifact_url":input.artifact_url,"sha256":input.sha256,"log":input.log}); - let count=s.db.lock().unwrap().execute("UPDATE builds SET state=?1,result=?2 WHERE id=?3 AND worker=?4 AND state='running'",params![if input.success{"completed"}else{"failed"},result.to_string(),id,digest(token(&headers)?)])?; + let count=s.db.lock().unwrap().execute("UPDATE builds SET state=?1,result=?2 WHERE id=?3 AND worker=?4 AND (state='uploaded' OR (state='running' AND ?5=0))",params![if input.success{"completed"}else{"failed"},result.to_string(),id,worker,input.success])?; if count!=1 {return Err(denied());}Ok(Json(json!({"ok":true}))) } async fn upload_artifact(State(s):State,Path(id):Path,request:axum::extract::Request)->ApiResult { s.worker(request.headers())?; if id.len()!=36 || !id.bytes().all(|c|c.is_ascii_hexdigit() || c==b'-'){return Err(bad("Invalid build identity"));} - let job:String=s.db.lock().unwrap().query_row("SELECT body FROM builds WHERE id=?1 AND worker=?2 AND state='running'",params![id,digest(token(request.headers())?)],|r|r.get(0))?; + let worker=digest(token(request.headers())?); + let job:String=s.db.lock().unwrap().query_row("SELECT body FROM builds WHERE id=?1 AND worker=?2 AND state='running'",params![id,worker],|r|r.get(0)).optional()?.ok_or_else(denied)?; let bytes=axum::body::to_bytes(request.into_body(),512*1024*1024).await.map_err(|_|bad("Artifact too large"))?; let job:Value=serde_json::from_str(&job)?; let release_envelope:SignedEnvelope=serde_json::from_value(job["release"].clone())?; @@ -443,7 +466,7 @@ async fn upload_artifact(State(s):State,Path(id):Path,request:ax ("Open-Technician.ps1",include_bytes!("../../../deployment/windows/Open-Technician.ps1").as_slice()), ("Verify-Package.ps1",include_bytes!("../../../deployment/windows/Verify-Package.ps1").as_slice()), ("LICENSE.txt",include_bytes!("../../../LICENCE").as_slice()) - ] {if read(name)?.as_slice()!=expected{return Err(bad("Bundle script or license mismatch"));}} + ] {if read(name)?!=bundle_text(expected)?{return Err(bad("Bundle script or license mismatch"));}} let profile:SignedEnvelope=serde_json::from_slice(&read("company-profile.json")?)?; if serde_json::to_value(&profile)?!=job["profile"] {return Err(bad("Wrong company profile"));} let copied_release:SignedEnvelope=serde_json::from_slice(&read("release.json")?)?; @@ -451,14 +474,27 @@ async fn upload_artifact(State(s):State,Path(id):Path,request:ax let bootstrap:swan_agent::Bootstrap=serde_json::from_slice(&read("bootstrap.json")?)?; let p:CompanyProfile=profile.verify(&s.key.verifying_key()).map_err(|_|denied())?; if bootstrap.company_id!=p.company_id || bootstrap.edition!=release.edition || bootstrap.management_url!=p.management_url || bootstrap.profile_public_key!=STANDARD.encode(s.key.verifying_key().as_bytes()) || bootstrap.release_public_key!=trust {return Err(bad("Bootstrap trust mismatch"));} - let temporary=s.artifact_dir.join(format!("{id}.partial")); - tokio::fs::write(&temporary,&bytes).await.map_err(|_|bad("Cannot save artifact"))?; - tokio::fs::rename(&temporary,s.artifact_dir.join(format!("{id}.zip"))).await.map_err(|_|bad("Cannot publish artifact"))?; - Ok(Json(json!({"sha256":digest(&bytes)}))) + drop(read);drop(zip); + let hash=digest(&bytes); + tokio::task::spawn_blocking(move || ->Result<(),ApiError>{ + let mut db=s.db.lock().unwrap();let tx=db.transaction()?; + let owned:bool=tx.query_row("SELECT EXISTS(SELECT 1 FROM builds WHERE id=?1 AND worker=?2 AND state='running')",params![id,worker],|r|r.get(0))?; + if !owned{return Err(denied());} + let temporary=s.artifact_dir.join(format!("{id}.partial")); + use std::io::Write; + let mut artifact=std::fs::File::create(&temporary).map_err(|_|bad("Cannot save artifact"))?; + artifact.write_all(&bytes).map_err(|_|bad("Cannot write artifact"))?; + artifact.sync_all().map_err(|_|bad("Cannot flush artifact"))?;drop(artifact); + let target=s.artifact_dir.join(format!("{id}.zip")); + if target.exists(){std::fs::remove_file(&target).map_err(|_|bad("Cannot replace retry artifact"))?;} + std::fs::rename(&temporary,target).map_err(|_|bad("Cannot publish artifact"))?; + tx.execute("UPDATE builds SET state='uploaded' WHERE id=?1",[&id])?;tx.commit()?;Ok(()) + }).await.map_err(|_|bad("Artifact persistence failed"))??; + Ok(Json(json!({"sha256":hash}))) } async fn download_artifact(State(s):State,Path(id):Path,headers:HeaderMap)->Result { if id.len()!=36 || !id.bytes().all(|c|c.is_ascii_hexdigit() || c==b'-'){return Err(bad("Invalid build identity"));} - let job:String=s.db.lock().unwrap().query_row("SELECT body FROM builds WHERE id=?1 AND state='completed'",[&id],|r|r.get(0))?; + let job:String=s.db.lock().unwrap().query_row("SELECT body FROM builds WHERE id=?1 AND state='completed'",[&id],|r|r.get(0)).optional()?.ok_or(ApiError(StatusCode::NOT_FOUND,"Artifact unavailable"))?; let job:Value=serde_json::from_str(&job)?; let envelope:SignedEnvelope=serde_json::from_value(job["release"].clone())?; let trust=std::env::var("SWAN_RELEASE_PUBLIC_KEY").map_err(|_|bad("Release trust key unavailable"))?; diff --git a/product/management/src/tests.rs b/product/management/src/tests.rs index f3fb776ae01..d79b9792ef5 100644 --- a/product/management/src/tests.rs +++ b/product/management/src/tests.rs @@ -38,6 +38,7 @@ async fn enrollment_mfa_and_grants_fail_closed() { let login=json!({"username":"admin","password":"correct horse battery staple","totp_code":generator.generate((time/30+1)*30)}); let (status,result)=request(&app,"/api/v1/login","POST",None,login.clone()).await;assert_eq!(status,StatusCode::OK); let admin=result["token"].as_str().unwrap(); + assert_eq!(request(&app,"/api/v1/sessions","GET",None,Value::Null).await.0,StatusCode::UNAUTHORIZED); assert_eq!(request(&app,"/api/v1/login","POST",None,login).await.0,StatusCode::UNAUTHORIZED,"MFA codes are single-use"); let (_,device)=request(&app,"/api/v1/enroll","POST",None,json!({"name":"Customer test PC","rustdesk_id":"123456789","unattended_consent":false})).await; let device_id=device["device_id"].as_str().unwrap();let credential=device["device_token"].as_str().unwrap(); @@ -48,6 +49,9 @@ async fn enrollment_mfa_and_grants_fail_closed() { let claim=json!({"grant":envelope}); assert_eq!(request(&app,"/api/v1/grants/claim","POST",Some(credential),claim.clone()).await.0,StatusCode::OK); assert_eq!(request(&app,"/api/v1/grants/claim","POST",Some(credential),claim).await.0,StatusCode::FORBIDDEN,"Session ticket is single-use"); + let (_,history)=request(&app,"/api/v1/sessions","GET",Some(admin),Value::Null).await; + assert_eq!(history.as_array().unwrap().len(),1); + assert_eq!(history[0]["claimed"],true); assert_eq!(request(&app,"/api/v1/grants","POST",Some(admin),json!({"device_id":device_id,"proof_public_key":STANDARD.encode(proof.verifying_key().as_bytes()),"unattended":true})).await.0,StatusCode::FORBIDDEN,"Unattended access needs device consent"); let (_,new_user)=request(&app,"/api/v1/users","POST",Some(admin),json!({"username":"technician","password":"another long strong password","role":"technician"})).await; let tech_secret=new_user["totp_secret"].as_str().unwrap(); diff --git a/product/management/web/app.js b/product/management/web/app.js index 57d71d54fba..cbaca843de2 100644 --- a/product/management/web/app.js +++ b/product/management/web/app.js @@ -33,7 +33,7 @@ const screens={ }, async releases(){title('Software releases','Import verified project releases, then approve them for company rollout.');const rows=await api('releases');content.innerHTML=`

Import signed release

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

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

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

Generate installer bundle

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

Connect a Windows worker

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

${rows.map(r=>``).join('')}
JobStatusResult
${escapeText(r.id)}${escapeText(r.state)}
${escapeText(r.result)}
`;bindForm('build',async v=>{await api('builds','POST',{release_id:v.get('release_id')});await view('builds');});document.querySelector('#worker').onclick=async()=>{try{document.querySelector('#worker_secret').textContent=JSON.stringify(await api('workers','POST'),null,2);}catch(error){showError(error);}}; + async builds(){title('Installer generation','Your Windows worker creates company bundles from approved signed releases.');const rows=await api('builds');content.innerHTML=`

Generate installer bundle

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

Connect a Windows worker

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

${rows.map(r=>``).join('')}
JobStatusResult
${escapeText(r.id)}${escapeText(r.state)}
${escapeText(r.result)}
${r.state==='completed'?`Website link: /api/v1/downloads/${escapeText(r.id)}`:''}
`;bindForm('build',async v=>{await api('builds','POST',{release_id:v.get('release_id')});await view('builds');});for(const button of document.querySelectorAll('[data-download]'))button.onclick=async()=>{try{const response=await fetch('/api/v1/downloads/'+encodeURIComponent(button.dataset.download),{headers:{Authorization:'Bearer '+accessToken},redirect:'error'});if(!response.ok)throw new Error('Bundle download failed');const url=URL.createObjectURL(await response.blob());const link=document.createElement('a');link.href=url;link.download='SwanRemoteSupport-company.zip';link.click();setTimeout(()=>URL.revokeObjectURL(url),1000);}catch(error){showError(error);}};document.querySelector('#worker').onclick=async()=>{try{document.querySelector('#worker_secret').textContent=JSON.stringify(await api('workers','POST'),null,2);}catch(error){showError(error);}}; }, async audit(){title('Audit history','Account, enrollment, permission, release and session events.');const rows=await api('audit');content.innerHTML=`
${rows.map(r=>``).join('')}
TimeEventActorTarget
${escapeText(new Date(r.at*1000).toISOString())}${escapeText(r.event)}${escapeText(r.actor)}${escapeText(r.target)}
`;} }; diff --git a/product/protocol/Cargo.toml b/product/protocol/Cargo.toml index fe38deda4b6..9a17c042dcb 100644 --- a/product/protocol/Cargo.toml +++ b/product/protocol/Cargo.toml @@ -1,4 +1,5 @@ [package] +workspace = ".." name = "swan-protocol" version.workspace = true edition.workspace = true diff --git a/product/protocol/src/lib.rs b/product/protocol/src/lib.rs index 34193c4a890..7c1789f7d3f 100644 --- a/product/protocol/src/lib.rs +++ b/product/protocol/src/lib.rs @@ -192,8 +192,10 @@ pub fn https_url(value: &str) -> Result { Ok(url) } pub fn validate_host(value: &str) -> Result<()> { - ensure!(!value.is_empty() && value.len() <= 253 && !value.contains("100.82.236.84"), "Invalid or legacy private endpoint"); + ensure!(!value.is_empty() && value.len() <= 253, "Invalid transport endpoint"); ensure!(value.bytes().all(|c| c.is_ascii_alphanumeric() || b".-:[]".contains(&c)), "Invalid transport address"); + let endpoint=url::Url::parse(&format!("tcp://{value}"))?; + ensure!(endpoint.host_str().is_some() && endpoint.username().is_empty() && endpoint.password().is_none() && endpoint.query().is_none() && endpoint.fragment().is_none() && ["","/"].contains(&endpoint.path()),"Expected transport hostname and optional port"); Ok(()) } pub fn public_key(value: &str) -> Result { @@ -209,6 +211,10 @@ pub fn signing_key_from_hex(value:&str)->Result { Ok(SigningKey::from_bytes(&bytes)) } pub fn digest(bytes: impl AsRef<[u8]>) -> String { hex::encode(Sha256::digest(bytes.as_ref())) } +pub fn bundle_text(bytes:&[u8])->Result> { + // Windows and Linux source checkouts must produce identical bundled scripts. + Ok(std::str::from_utf8(bytes)?.replace("\r\n","\n").into_bytes()) +} pub fn now() -> u64 { std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0) } pub fn maintenance_open(start: u8, end: u8, now: u64) -> bool { let hour = ((now / 3600) % 24) as u8; diff --git a/product/worker/Cargo.toml b/product/worker/Cargo.toml index e6a1db10e10..91f696ceaa8 100644 --- a/product/worker/Cargo.toml +++ b/product/worker/Cargo.toml @@ -1,4 +1,5 @@ [package] +workspace = ".." name = "swan-worker" version.workspace = true edition.workspace = true diff --git a/product/worker/src/main.rs b/product/worker/src/main.rs index 06779952b27..1d4d66c311e 100644 --- a/product/worker/src/main.rs +++ b/product/worker/src/main.rs @@ -13,7 +13,7 @@ async fn main()->Result<()> { let company_key=std::env::var("SWAN_PROFILE_PUBLIC_KEY").context("Pin SWAN_PROFILE_PUBLIC_KEY on the worker")?;public_key(&company_key)?; let output=PathBuf::from(std::env::var("SWAN_ARTIFACT_DIR").context("Set worker artifact output directory")?); std::fs::create_dir_all(&output)?; - let client=reqwest::Client::builder().timeout(std::time::Duration::from_secs(600)).redirect(reqwest::redirect::Policy::none()).https_only(true).build()?; + let client=swan_agent::http_client(600,false)?; loop { let response=client.post(format!("{}/api/v1/worker/claim",server.trim_end_matches('/'))).bearer_auth(&token).send().await; match response { @@ -42,7 +42,7 @@ async fn main()->Result<()> { } } -async fn build(client:&reqwest::Client,job:&Value,output:&Path,id:&str,release_key:&str,company_key:&str)->Result<(String,String)> { +async fn build(_client:&reqwest::Client,job:&Value,output:&Path,id:&str,release_key:&str,company_key:&str)->Result<(String,String)> { ensure!(job["release_public_key"].as_str()==Some(release_key) && job["profile_public_key"].as_str()==Some(company_key),"Worker trust pins do not match the company job"); let release_envelope:SignedEnvelope=serde_json::from_value(job["release"].clone())?; let release:Release=release_envelope.verify(&public_key(release_key)?)?; @@ -53,8 +53,9 @@ async fn build(client:&reqwest::Client,job:&Value,output:&Path,id:&str,release_k let work=output.join(format!("work-{id}"));std::fs::create_dir_all(&work)?; let installer=work.join(format!("SwanRemoteSupport-install.{}",release.format)); let agent=work.join("swan-agent.exe"); - download(client,&release.artifact_url,&installer,&release.sha256).await?; - download(client,&release.agent_url,&agent,&release.agent_sha256).await?; + let artifact_client=swan_agent::http_client(600,true)?; + download(&artifact_client,&release.artifact_url,&installer,&release.sha256).await?; + download(&artifact_client,&release.agent_url,&agent,&release.agent_sha256).await?; verify_windows(&installer,&release.publisher,&work)?; verify_windows(&agent,&release.publisher,&work)?; let bootstrap=Bootstrap{schema:SCHEMA,edition:release.edition.clone(),company_id:profile.company_id,management_url:profile.management_url,profile_public_key:company_key.into(),release_public_key:release_key.into()}; @@ -67,14 +68,16 @@ async fn build(client:&reqwest::Client,job:&Value,output:&Path,id:&str,release_k ("bootstrap.json",serde_json::to_vec_pretty(&bootstrap)?), ("company-profile.json",serde_json::to_vec_pretty(&profile_envelope)?), ("release.json",serde_json::to_vec_pretty(&release_envelope)?), - ("Install-Company.ps1",include_bytes!("../../../deployment/windows/Install-Company.ps1").to_vec()), - ("Open-Technician.ps1",include_bytes!("../../../deployment/windows/Open-Technician.ps1").to_vec()), - ("Verify-Package.ps1",include_bytes!("../../../deployment/windows/Verify-Package.ps1").to_vec()), - ("LICENSE.txt",include_bytes!("../../../LICENCE").to_vec()), + ("Install-Company.ps1",bundle_text(include_bytes!("../../../deployment/windows/Install-Company.ps1"))?), + ("Open-Technician.ps1",bundle_text(include_bytes!("../../../deployment/windows/Open-Technician.ps1"))?), + ("Verify-Package.ps1",bundle_text(include_bytes!("../../../deployment/windows/Verify-Package.ps1"))?), + ("LICENSE.txt",bundle_text(include_bytes!("../../../LICENCE"))?), ] {zip.start_file(name,options)?;zip.write_all(&bytes)?;} zip.finish()?.sync_all()?; - let hash=digest(std::fs::read(&temporary)?);std::fs::rename(&temporary,output.join(&file_name))?; - Ok((file_name,hash)) + let hash=digest(std::fs::read(&temporary)?); + let final_name=file_name.replace(".zip",&format!("-{}.zip",&hash[..16]));let target=output.join(&final_name); + if target.exists(){ensure!(digest(std::fs::read(&target)?)==hash,"Conflicting immutable artifact");std::fs::remove_file(&temporary)?;}else{std::fs::rename(&temporary,target)?;} + Ok((final_name,hash)) } pub async fn download(client:&reqwest::Client,url:&str,path:&Path,hash:&str)->Result<()> { https_url(url)?; diff --git a/src/managed.rs b/src/managed.rs index f6b3369cdf2..cfa98bfe680 100644 --- a/src/managed.rs +++ b/src/managed.rs @@ -16,6 +16,7 @@ pub fn apply_defaults() { overrides.insert(keys::OPTION_API_SERVER.into(),String::new()); // Company release updates are handled by the configuration agent, not upstream. overrides.insert(keys::OPTION_ALLOW_AUTO_UPDATE.into(),"N".into()); + overrides.insert(keys::OPTION_ENABLE_RECORD_SESSION.into(),"N".into()); overrides.insert(keys::OPTION_APPROVE_MODE.into(),"click".into()); overrides.insert(keys::OPTION_ALLOW_ONLY_CONN_WINDOW_OPEN.into(),"N".into()); drop(overrides); diff --git a/src/server/connection.rs b/src/server/connection.rs index 5c108333f6d..629f1e6d7ba 100644 --- a/src/server/connection.rs +++ b/src/server/connection.rs @@ -4830,6 +4830,17 @@ impl Connection { } async fn on_close(&mut self, reason: &str, lock: bool) { + #[cfg(feature = "swan_custom")] + if let Some(lease)=self.managed_lease.take() { + let grant_id=lease.grant_id.clone();drop(lease); + // Local stop does not wait for a management endpoint to respond. + tokio::spawn(async move { + match swan_agent::AgentState::load_for_refresh(&swan_agent::state_directory()) { + Ok(state)=>if let Err(error)=state.close(&grant_id).await {log::warn!("Cannot report company session closure: {}",error);}, + Err(error)=>log::warn!("Cannot load company state for session closure: {}",error), + } + }); + } if self.closed { return; } From 411269bc285e50780fbca837d2dfd5722373cbf9 Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Thu, 1 Oct 2026 13:39:07 +0200 Subject: [PATCH 003/652] Add graphical company technician flow and pinned publisher compatibility checks --- .github/workflows/swan-windows-build.yml | 2 +- .../windows/Get-WindowsCompatibility.ps1 | 15 ++ deployment/windows/New-TestVm.ps1 | 40 +++++ deployment/windows/Open-Technician.ps1 | 48 ++--- deployment/windows/VM-TESTING.md | 44 +++++ deployment/windows/Verify-Package.ps1 | 5 +- .../pages/company_technician_page.dart | 168 ++++++++++++++++++ .../lib/desktop/pages/desktop_home_page.dart | 35 +++- flutter/lib/web/bridge.dart | 4 + product/README.md | 6 +- product/agent/src/lib.rs | 6 + product/agent/src/main.rs | 14 +- product/agent/src/technician.rs | 35 ++++ product/agent/src/update.rs | 37 +++- product/protocol/src/lib.rs | 12 +- product/worker/src/main.rs | 8 +- src/flutter.rs | 41 ++++- src/flutter_ffi.rs | 9 + src/managed.rs | 112 +++++++++++- src/rendezvous_mediator.rs | 4 + 20 files changed, 577 insertions(+), 68 deletions(-) create mode 100644 deployment/windows/Get-WindowsCompatibility.ps1 create mode 100644 deployment/windows/New-TestVm.ps1 create mode 100644 deployment/windows/VM-TESTING.md create mode 100644 flutter/lib/desktop/pages/company_technician_page.dart create mode 100644 product/agent/src/technician.rs diff --git a/.github/workflows/swan-windows-build.yml b/.github/workflows/swan-windows-build.yml index 8ab870650f5..9bcd1b1223e 100644 --- a/.github/workflows/swan-windows-build.yml +++ b/.github/workflows/swan-windows-build.yml @@ -154,7 +154,7 @@ jobs: run: | 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/Verify-Package.ps1 artifacts/ + Copy-Item deployment/windows/Install-Company.ps1,deployment/windows/Open-Technician.ps1,deployment/windows/Verify-Package.ps1,deployment/windows/Get-WindowsCompatibility.ps1 artifacts/ Copy-Item LICENCE artifacts/ - name: Upload unsigned installer and checksum diff --git a/deployment/windows/Get-WindowsCompatibility.ps1 b/deployment/windows/Get-WindowsCompatibility.ps1 new file mode 100644 index 00000000000..a9de16199b1 --- /dev/null +++ b/deployment/windows/Get-WindowsCompatibility.ps1 @@ -0,0 +1,15 @@ +$ErrorActionPreference = 'Stop' +if (-not [Environment]::Is64BitOperatingSystem) { throw 'This release requires Windows x64.' } +$version = Get-ItemProperty -LiteralPath 'HKLM:/SOFTWARE/Microsoft/Windows NT/CurrentVersion' +$build = [int]$version.CurrentBuildNumber +if ($version.InstallationType -eq 'Server Core') { throw 'Windows Server Core is not supported.' } +if ($version.InstallationType -eq 'Server') { + foreach ($year in @(2016, 2019, 2022, 2025)) { + if ($version.ProductName -match ('Windows Server ' + $year + '\b')) { Write-Output ('server_' + $year); exit 0 } + } + throw 'This Windows Server release is not supported.' +} +if ($version.InstallationType -ne 'Client') { throw 'This Windows installation type is not supported.' } +if ($build -ge 22000) { Write-Output 'windows_11' } +elseif ($build -ge 10240) { Write-Output 'windows_10' } +else { throw 'Windows 10 or 11 is required.' } diff --git a/deployment/windows/New-TestVm.ps1 b/deployment/windows/New-TestVm.ps1 new file mode 100644 index 00000000000..25a92664fca --- /dev/null +++ b/deployment/windows/New-TestVm.ps1 @@ -0,0 +1,40 @@ +param( + [Parameter(Mandatory=$true)][string]$QemuDirectory, + [Parameter(Mandatory=$true)][string]$IsoPath, + [Parameter(Mandatory=$true)][string]$LabDirectory, + [ValidatePattern('^[a-z0-9-]+$')][string]$Name = 'server-2025', + [ValidateSet('whpx','tcg')][string]$Accelerator = 'whpx', + [ValidateRange(2048,8192)][int]$MemoryMiB = 4096, + [ValidateRange(32,128)][int]$DiskGiB = 64 +) +$ErrorActionPreference = 'Stop' +$qemu = Join-Path $QemuDirectory 'qemu-system-x86_64.exe' +$qemuImg = Join-Path $QemuDirectory 'qemu-img.exe' +foreach ($required in @($qemu,$qemuImg,$IsoPath)) { + if (-not (Test-Path -LiteralPath $required -PathType Leaf)) { throw "Missing file: $required" } +} +$lab = [IO.Path]::GetFullPath($LabDirectory) +$vm = [IO.Path]::GetFullPath((Join-Path $lab $Name)) +if (-not $vm.StartsWith($lab.TrimEnd('\')+'\',[StringComparison]::OrdinalIgnoreCase)) { + throw 'VM directory must stay within the test lab' +} +if (Test-Path -LiteralPath $vm) { throw 'VM already exists; refusing to overwrite its disk' } +$availableMiB = (Get-CimInstance Win32_OperatingSystem).FreePhysicalMemory / 1024 +if ($availableMiB -lt ($MemoryMiB + 1536)) { throw 'Insufficient available memory; leave at least 1.5 GiB for the host' } +New-Item -ItemType Directory -Path $vm | Out-Null +$disk = Join-Path $vm 'system.qcow2' +& $qemuImg create -f qcow2 $disk ($DiskGiB.ToString()+'G') +if ($LASTEXITCODE -ne 0) { throw 'QEMU disk creation failed' } +$arguments = @('-name',$Name,'-machine','q35','-accel',$Accelerator, + '-m',$MemoryMiB.ToString(),'-smp','2','-drive',"file=$disk,format=qcow2", + '-cdrom',[IO.Path]::GetFullPath($IsoPath),'-boot','order=d', + '-nic','user,model=e1000','-display','none', + '-vnc','127.0.0.1:20','-monitor','tcp:127.0.0.1:4444,server=on,wait=off') +# User-mode NAT avoids host firewall/bridge changes. Management sockets are loopback only. +# Only one guest may use these ports at a time. No incoming guest ports are forwarded. +[ordered]@{name=$Name;disk=$disk;iso=[IO.Path]::GetFullPath($IsoPath); + accelerator=$Accelerator;arguments=$arguments;created_utc=[DateTime]::UtcNow.ToString('o') +} | ConvertTo-Json -Depth 4 | Set-Content -LiteralPath (Join-Path $vm 'vm.json') -Encoding UTF8 +# Launch through PowerShell's native argument passing rather than string-built shell code. +& $qemu @arguments +if ($LASTEXITCODE -ne 0) { throw "QEMU exited with code $LASTEXITCODE; inspect acceleration availability before retrying" } diff --git a/deployment/windows/Open-Technician.ps1 b/deployment/windows/Open-Technician.ps1 index 7907e029874..bead6fabb28 100644 --- a/deployment/windows/Open-Technician.ps1 +++ b/deployment/windows/Open-Technician.ps1 @@ -5,36 +5,20 @@ $env:SWAN_STATE_DIR = Join-Path $env:LOCALAPPDATA 'SwanRemoteSupport-Technician' $agent = Join-Path $PSScriptRoot 'swan-agent.exe' if ((Get-AuthenticodeSignature -LiteralPath $agent).Status -ne 'Valid') { throw 'Technician agent requires a valid signature.' } & $agent sync -if ($LASTEXITCODE -ne 0) { throw 'Company synchronization failed.' } -$user = Read-Host 'Technician username' -$secure = Read-Host 'Password' -AsSecureString -$passwordPointer = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($secure) -try { - $env:SWAN_LOGIN_PASSWORD = [Runtime.InteropServices.Marshal]::PtrToStringBSTR($passwordPointer) - $env:SWAN_LOGIN_TOTP = Read-Host 'Authenticator code' - $loginJson = & $agent login $user - if ($LASTEXITCODE -ne 0) { throw 'Technician authentication failed.' } - $login = $loginJson | ConvertFrom-Json - $env:SWAN_TECHNICIAN_TOKEN = $login.token -} finally { - [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($passwordPointer) - Remove-Item Env:SWAN_LOGIN_PASSWORD,Env:SWAN_LOGIN_TOTP -ErrorAction SilentlyContinue -} -try { - & $agent update - if ($LASTEXITCODE -ne 0) { throw 'Update validation failed or recovery is required.' } - & $agent devices - if ($LASTEXITCODE -ne 0) { throw 'Device inventory unavailable.' } - $device = Read-Host 'Approved device identifier' - $app = Join-Path $PSScriptRoot 'SwanRemoteSupport-install.exe' +if ($LASTEXITCODE -ne 0) { Write-Warning 'Company server unavailable. Cached branding can be displayed; authorization still requires valid company policy.' } +$app = Join-Path $PSScriptRoot 'SwanRemoteSupport-install.exe' +$portable = Join-Path $env:SWAN_STATE_DIR 'SwanRemoteSupport-Technician.exe' +if (-not (Test-Path -LiteralPath $portable)) { if (-not (Test-Path -LiteralPath $app)) { throw 'Portable technician EXE is required.' } - # Avoid the portable packer opening its installer when the filename ends in install.exe. - $portable = Join-Path $env:SWAN_STATE_DIR 'SwanRemoteSupport-Technician.exe' - if (-not (Test-Path -LiteralPath $portable)) { - & $agent verify-package (Join-Path $PSScriptRoot 'release.json') $app - if ($LASTEXITCODE -ne 0) { throw 'Technician package verification failed.' } - Copy-Item -LiteralPath $app -Destination $portable - } - & $agent connect $device $portable - if ($LASTEXITCODE -ne 0) { throw 'Support session could not start.' } -} finally { Remove-Item Env:SWAN_TECHNICIAN_TOKEN -ErrorAction SilentlyContinue } + & $agent verify-package (Join-Path $PSScriptRoot 'release.json') $app + if ($LASTEXITCODE -ne 0) { throw 'Technician package verification failed.' } + # The fixed filename avoids the portable packer opening installation mode. + Copy-Item -LiteralPath $app -Destination $portable + Copy-Item -LiteralPath (Join-Path $PSScriptRoot 'release.json') -Destination (Join-Path $env:SWAN_STATE_DIR 'installed-release.json') +} +& $agent verify-installed +if ($LASTEXITCODE -ne 0) { throw 'Technician installed identity or pinned publisher verification failed.' } +# Login, inventory and ticket creation take place in the graphical application. +# No password, MFA code, token or grant is passed in process arguments/environment. +& $portable +if ($LASTEXITCODE -ne 0) { throw 'Technician application could not start.' } diff --git a/deployment/windows/VM-TESTING.md b/deployment/windows/VM-TESTING.md new file mode 100644 index 00000000000..c0277340cce --- /dev/null +++ b/deployment/windows/VM-TESTING.md @@ -0,0 +1,44 @@ +# Isolated Windows acceptance lab + +Use official Microsoft evaluation media, within its evaluation period. Never +disable activation checks or redistribute evaluation images with project releases. +Media, VM disks, local credentials, screenshots and raw test logs stay outside Git. + +The initial local lab is `D:\SwanRemoteSupport-TestLab`. It uses QEMU with one +guest at a time, 4 GiB RAM, two virtual CPUs and a sparse 64 GiB disk. User-mode +NAT does not change the host bridge, firewall or existing RustDesk services. +VNC (port 5920) and the QEMU monitor (4444) listen on loopback only. These sockets +provide full guest access; close QEMU when the guest is not under test. + +`New-TestVm.ps1` requires an already verified QEMU installation and ISO. It +refuses existing VM directories. Prefer WHPX after checking it starts on the +host; TCG is a slower fallback. The host must retain 1.5 GiB available memory. +Windows 11 also requires supported UEFI/Secure Boot and TPM configuration: +the generic starter script is for Server guests and does not establish Windows +11 compatibility. Do not bypass Windows 11 hardware requirements for acceptance. + +Choose **Desktop Experience** for every Server installation. Record edition, +build, patch level, ISO origin and SHA-256, accelerator and product commit. +Keep a clean installation checkpoint before testing each package. + +## Required matrix + +Windows 10 and 11 x64; Windows Server 2016, 2019, 2022 and 2025 Desktop Experience. +Each row remains **unverified** until actual installation and upgrade evidence +exists for that guest. Unit tests and host installation are insufficient. + +For each guest, capture installation, repair, restart, uninstall, branding sync +and offline cache, attended approval, unattended opt-in/revocation, authorized +and denied access, UAC/logon screen, clipboard, transfer, multiple monitors, +interrupted upgrade recovery and identity preservation. Test direct and relay +connections between separate guests. Public Internet traversal needs a separate +external network; local NAT success does not prove public reachability. + +Official media starting points: + +- +- +- + +Evaluation expiration or missing supported media must be reported as an +unverified matrix row, never as a passing compatibility test. diff --git a/deployment/windows/Verify-Package.ps1 b/deployment/windows/Verify-Package.ps1 index 60173c733f5..84a6072d3cf 100644 --- a/deployment/windows/Verify-Package.ps1 +++ b/deployment/windows/Verify-Package.ps1 @@ -1,5 +1,5 @@ [CmdletBinding()] -param([Parameter(Mandatory)][string]$Path,[Parameter(Mandatory)][string]$Publisher,[string]$Sha256) +param([Parameter(Mandatory)][string]$Path,[Parameter(Mandatory)][string]$Publisher,[Parameter(Mandatory)][string]$CertificateSha256,[string]$Sha256) $ErrorActionPreference = 'Stop' $resolved = (Resolve-Path -LiteralPath $Path).Path if ($Sha256 -and (Get-FileHash -LiteralPath $resolved -Algorithm SHA256).Hash -ne $Sha256) { throw 'Package hash does not match signed release metadata.' } @@ -7,4 +7,7 @@ $signature = Get-AuthenticodeSignature -LiteralPath $resolved if ($signature.Status -ne 'Valid' -or -not $signature.SignerCertificate) { throw 'Package requires a valid trusted Authenticode signature.' } $actualPublisher = $signature.SignerCertificate.GetNameInfo([System.Security.Cryptography.X509Certificates.X509NameType]::SimpleName, $false) if ($actualPublisher -cne $Publisher) { throw 'Package publisher does not match the approved release publisher.' } +$hasher = [Security.Cryptography.SHA256]::Create() +try { $certificateHash = [BitConverter]::ToString($hasher.ComputeHash($signature.SignerCertificate.RawData)).Replace('-', '') } finally { $hasher.Dispose() } +if ($certificateHash -ine $CertificateSha256) { throw 'Signing certificate does not match the approved release certificate.' } Write-Output 'Package signature and publisher verified.' diff --git a/flutter/lib/desktop/pages/company_technician_page.dart b/flutter/lib/desktop/pages/company_technician_page.dart new file mode 100644 index 00000000000..3bb1b980c4a --- /dev/null +++ b/flutter/lib/desktop/pages/company_technician_page.dart @@ -0,0 +1,168 @@ +import 'dart:convert'; +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:flutter_svg/flutter_svg.dart'; +import 'package:flutter_hbb/common.dart'; + +/// Company authorization lives in Rust and on the receiver. This view never +/// receives a bearer token, signing key or reusable device credential. +class CompanyTechnicianPage extends StatefulWidget { + const CompanyTechnicianPage({super.key}); + @override + State createState() => _CompanyTechnicianPageState(); +} + +class _CompanyTechnicianPageState extends State { + final _username = TextEditingController(); + final _password = TextEditingController(); + final _code = TextEditingController(); + Map _company = {}; + List _devices = []; + List _history = []; + bool _loggedIn = false; + bool _busy = false; + String _error = ''; + Timer? _refreshTimer; + + Future _request(Map input) async { + final text = await bind.mainCompanyRequest(request: jsonEncode(input)) + .first.timeout(const Duration(seconds: 25)); + final response = jsonDecode(text) as Map; + if (response['ok'] != true) { + throw Exception(response['error'] ?? 'Company request failed'); + } + return response['data']; + } + + Future _run(Future Function() action) async { + if (_busy) return; + setState(() { _busy = true; _error = ''; }); + try { await action(); } + catch (error) { if (mounted) setState(() { _error = error.toString(); }); } + finally { if (mounted) setState(() { _busy = false; }); } + } + + Future _refresh() async { + // Retain cached branding if the server is offline; authenticated operations + // still require a current signed policy on the native side. + try { await _request({'action': 'sync'}); } catch (_) {} + final company = jsonDecode(await bind.mainGetCommon(key: 'company-overview')) + as Map; + final status = await _request({'action': 'status'}) as Map; + final loggedIn = status['logged_in'] == true; + final devices = loggedIn ? await _request({'action': 'devices'}) as List : []; + final history = loggedIn ? await _request({'action': 'history'}) as List : []; + if (mounted) setState(() { + _company = company; _loggedIn = loggedIn; _devices = devices; _history = history; + }); + } + + Future _login() async { + final request = {'action': 'login', 'username': _username.text.trim(), + 'password': _password.text, 'code': _code.text.trim()}; + _password.clear(); _code.clear(); + await _request(request); + await _refresh(); + } + + Future _logout() async { + // Clear display state even when the server is offline. Rust clears its + // token and pending tickets before attempting server logout. + try { await _request({'action': 'logout'}); } + finally { if (mounted) setState(() { _loggedIn = false; _devices = []; _history = []; }); } + } + + Future _connect(Map device, {bool unattended = false}) async { + final response = await _request({'action': 'connect', 'device_id': device['id'], + 'unattended': unattended}) as Map; + if (!mounted) return; + await connect(context, response['rustdesk_id'] as String); + } + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) _run(_refresh); }); + _refreshTimer = Timer.periodic(const Duration(seconds: 60), (_) { + if (mounted && !_busy) _run(_refresh); + }); + } + + @override + void dispose() { + _refreshTimer?.cancel(); + _username.dispose(); _password.dispose(); _code.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final color = _company['primary_color'] as String? ?? '#007F82'; + final brandColor = RegExp(r'^#[0-9a-fA-F]{6}$').hasMatch(color) + ? Color(0xFF000000 | int.parse(color.substring(1), radix: 16)) : const Color(0xFF007F82); + return SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + if ((_company['logo_svg'] as String? ?? '').isNotEmpty) + SvgPicture.string(_company['logo_svg'] as String, height: 64, width: 180), + const SizedBox(height: 12), + Text(_company['display_name'] as String? ?? 'Swan Remote Support Technician', + style: Theme.of(context).textTheme.headlineSmall?.copyWith(color: brandColor)), + Text(_company['domain'] as String? ?? 'Company setup required'), + const SizedBox(height: 16), + if (_busy) const LinearProgressIndicator(), + if (_error.isNotEmpty) Padding(padding: const EdgeInsets.symmetric(vertical: 12), + child: Text(_error, style: const TextStyle(color: Colors.red))), + if (!_loggedIn) SizedBox(width: 360, child: Column(children: [ + TextField(controller: _username, decoration: const InputDecoration(labelText: 'Technician username')), + TextField(controller: _password, obscureText: true, enableSuggestions: false, + autocorrect: false, decoration: const InputDecoration(labelText: 'Password')), + TextField(controller: _code, keyboardType: TextInputType.number, maxLength: 6, + decoration: const InputDecoration(labelText: 'Authenticator code'), + onSubmitted: (_) { if (!_busy) _run(_login); }), + const SizedBox(height: 12), + ElevatedButton(onPressed: _busy ? null : () => _run(_login), child: const Text('Sign in')), + ])), + if (_loggedIn) ...[ + Row(children: [ + ElevatedButton.icon(onPressed: _busy ? null : () => _run(_refresh), + icon: const Icon(Icons.refresh), label: const Text('Refresh')), + const SizedBox(width: 12), + TextButton(onPressed: _busy ? null : () => _run(_logout), child: const Text('Sign out')), + ]), + const SizedBox(height: 16), + Text('Authorized devices', style: Theme.of(context).textTheme.titleLarge), + if (_devices.isEmpty) const Padding(padding: EdgeInsets.symmetric(vertical: 12), + child: Text('No approved devices are assigned to your account.')), + for (final value in _devices) Builder(builder: (context) { + final device = value as Map; + final approved = device['state'] == 'approved'; + return Card(child: ListTile(title: Text(device['name'] as String), + subtitle: Text('${device['group']} ยท ${device['state']}'), + trailing: Wrap(spacing: 8, children: [ + TextButton(onPressed: !_busy && approved ? () => _run(() => _connect(device)) : null, + child: const Text('Request support')), + if (device['unattended'] == true) + TextButton(onPressed: !_busy && approved ? () => _run(() => _connect(device, unattended: true)) : null, + child: const Text('Unattended')), + ]))); + }), + const SizedBox(height: 20), + Text('Session history', style: Theme.of(context).textTheme.titleLarge), + if (_history.isEmpty) const Text('No sessions yet.'), + for (final value in _history) Builder(builder: (context) { + final session = value as Map; + final time = DateTime.fromMillisecondsSinceEpoch((session['requested_at'] as int) * 1000).toLocal(); + final active = session['claimed'] == true && session['closed'] != true && + (session['lease_until'] as int) * 1000 > DateTime.now().millisecondsSinceEpoch; + return ListTile(title: Text(session['device_name'] as String), + subtitle: Text('$time ยท ${session['unattended'] == true ? 'Unattended' : 'Customer approval'}'), + trailing: Text(active ? 'Active' : session['claimed'] == true ? 'Ended' : 'Requested')); + }), + ], + const SizedBox(height: 24), + const Text('Powered by Swan Remote Support and RustDesk ยท AGPL-3.0'), + ]), + ); + } +} diff --git a/flutter/lib/desktop/pages/desktop_home_page.dart b/flutter/lib/desktop/pages/desktop_home_page.dart index d37a8fe9862..86f13c7fd19 100644 --- a/flutter/lib/desktop/pages/desktop_home_page.dart +++ b/flutter/lib/desktop/pages/desktop_home_page.dart @@ -5,11 +5,13 @@ import 'dart:convert'; import 'package:auto_size_text/auto_size_text.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:flutter_svg/flutter_svg.dart'; import 'package:flutter_hbb/common.dart'; import 'package:flutter_hbb/common/widgets/animated_rotation_widget.dart'; import 'package:flutter_hbb/common/widgets/custom_password.dart'; import 'package:flutter_hbb/consts.dart'; import 'package:flutter_hbb/desktop/pages/connection_page.dart'; +import 'package:flutter_hbb/desktop/pages/company_technician_page.dart'; import 'package:flutter_hbb/desktop/pages/desktop_setting_page.dart'; import 'package:flutter_hbb/desktop/pages/desktop_tab_page.dart'; import 'package:flutter_hbb/desktop/widgets/update_progress.dart'; @@ -50,6 +52,7 @@ class _DesktopHomePageState extends State var watchIsInputMonitoring = false; var watchIsCanRecordAudio = false; Timer? _updateTimer; + Timer? _companyRefreshTimer; bool isCardClosed = false; final RxBool _editHover = false.obs; @@ -60,6 +63,9 @@ class _DesktopHomePageState extends State @override Widget build(BuildContext context) { super.build(context); + if (bind.mainGetAppNameSync() == '$swanManagedAppName Technician' && bind.isOutgoingOnly()) { + return const CompanyTechnicianPage(); + } final isIncomingOnly = bind.isIncomingOnly(); return _buildBlock( child: Row( @@ -88,7 +94,7 @@ class _DesktopHomePageState extends State alignment: Alignment.center, child: loadPowered(context), ), - Align( + if (!isSwanManagedClient) Align( alignment: Alignment.center, child: loadLogo(), ), @@ -200,21 +206,28 @@ class _DesktopHomePageState extends State try { company = jsonDecode(snapshot.data ?? '{}') as Map; } catch (_) {} - final isReady = company['configured'] == true && company['enrolled'] == true; - final statusColor = isReady ? const Color(0xFF0A7D5A) : Colors.orange; + final configured = company['configured'] == true && company['enrolled'] == true; + final isReady = configured && company['profile_valid'] == true; + final color = company['primary_color'] as String? ?? '#007F82'; + final brandColor = RegExp(r'^#[0-9a-fA-F]{6}$').hasMatch(color) + ? Color(0xFF000000 | int.parse(color.substring(1), radix: 16)) + : const Color(0xFF007F82); + final statusColor = isReady ? brandColor : Colors.orange; final statusIcon = isReady ? Icons.verified_user : Icons.warning_amber; final statusTitle = isReady ? (company['unattended'] == true ? 'Unattended support enabled' : 'Customer approval required') - : 'Company setup is not complete'; + : (configured ? 'Company authorization is unavailable' : 'Company setup is not complete'); final statusText = isReady ? 'Only company-authorized technicians can request access. Your company must approve this device before support begins.' - : 'Run the company setup included with your download to verify its server and enroll this computer.'; + : (configured ? 'Cached company branding is shown. A fresh signed configuration is required before support can begin.' : 'Run the company setup included with your download to verify its server and enroll this computer.'); return Padding( padding: const EdgeInsets.fromLTRB(20, 16, 16, 8), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ + if ((company['logo_svg'] as String? ?? '').isNotEmpty) + Padding(padding: const EdgeInsets.only(bottom: 12), child: SvgPicture.string(company['logo_svg'] as String, height: 64, width: 180)), Text( company['display_name'] as String? ?? 'Swan Remote Support', style: Theme.of(context).textTheme.titleLarge, @@ -260,9 +273,9 @@ class _DesktopHomePageState extends State const SizedBox(height: 12), if (company['unattended'] == true) TextButton.icon( - onPressed: () { - bind.mainSetCommon(key: 'company-revoke-unattended', value: 'N'); - setState(() {}); + onPressed: () async { + await bind.mainSetCommon(key: 'company-revoke-unattended', value: 'N'); + if (mounted) setState(() {}); }, icon: const Icon(Icons.shield_outlined), label: const Text('Revoke unattended access'), @@ -788,6 +801,11 @@ class _DesktopHomePageState extends State @override void initState() { super.initState(); + if (bind.mainGetAppNameSync().startsWith(swanManagedAppName)) { + _companyRefreshTimer = Timer.periodic(const Duration(seconds: 30), (_) { + if (mounted) setState(() {}); + }); + } _updateTimer = periodic_immediate(const Duration(seconds: 1), () async { await gFFI.serverModel.fetchID(); final error = await bind.mainGetError(); @@ -970,6 +988,7 @@ class _DesktopHomePageState extends State _uniLinksSubscription?.cancel(); Get.delete(tag: 'stop-service'); _updateTimer?.cancel(); + _companyRefreshTimer?.cancel(); WidgetsBinding.instance.removeObserver(this); super.dispose(); } diff --git a/flutter/lib/web/bridge.dart b/flutter/lib/web/bridge.dart index ac48dfb0ffd..4819e8a255c 100644 --- a/flutter/lib/web/bridge.dart +++ b/flutter/lib/web/bridge.dart @@ -1896,6 +1896,10 @@ class RustdeskImpl { throw UnimplementedError("sessionPrinterResponse"); } + Stream mainCompanyRequest({required String request, dynamic hint}) { + throw UnimplementedError("mainCompanyRequest"); + } + Future mainGetCommon({required String key, dynamic hint}) { throw UnimplementedError("mainGetCommon"); } diff --git a/product/README.md b/product/README.md index d0a8a743a43..8b942de55f4 100644 --- a/product/README.md +++ b/product/README.md @@ -23,7 +23,9 @@ Windows production builds use MSVC. GNU compilation has verified the product com - Company release import and approval, stable/test selection, staged cohorts, pause and UTC maintenance windows. Endpoint updater checks signatures, hashes, publisher and installed executable identity, and excludes sessions during installation. - Encrypted management backup and empty-directory restore. -Installers contain public bootstrap information only. The worker pins both profile and project release verification keys locally. Release metadata must include package, installed executable and agent SHA-256 identities, exact source URL and publisher. No profile value becomes a shell command. +Installers contain public bootstrap information only. The worker pins both profile and project release verification keys locally. Release metadata must include package, installed executable and agent SHA-256 identities, exact source URL, publisher, signing-certificate SHA-256 and supported Windows families. No profile value becomes a shell command. + +The native Flutter technician home now includes company branding, MFA login, authorized inventory, attended/unattended connection requests and session history. Its Rust broker retains tokens and proof keys in memory and consumes the selected peer's ticket during the transport challenge. The graphical flow requires native build and end-to-end validation before it is considered delivered. The launcher opens this interface; automatic technician update handoff while closing the running executable remains incomplete. The updater writes `pending-update.json` before installation and advances the sequence only after verifying the installed executable. `swan-agent recover-update` completes recovery only if signed installed identity matches; partial failures remain blocked for repair. Automatic repair/retry, rollback and updating the configuration agent itself remain incomplete. @@ -31,6 +33,6 @@ The updater writes `pending-update.json` before installation and advances the se See `deployment/windows/LOCAL-TESTING.md` for this machine's isolated test process. Windows service installation is in `deployment/windows/Install-Server.ps1`; Linux container definitions are in `deployment/linux/`. Management must sit behind trusted HTTPS before endpoint use. Companies supply DNS and publicly reachable transport or their own relay. Local tests do not prove internet reachability, NAT traversal or relay fallback. -Still required: complete native graphical technician login/inventory/history, live logo/color branding, equivalent first-run deployment wizards and reachability diagnostics, executable/MSI packaging integration and repair, company signing integration, full transport-key backup, migrations with rollback, full update recovery and agent replacement, pinned native dependency artifacts, native builds and end-to-end sessions, and clean-machine installation/upgrade tests across every advertised Windows version. Windows 10/11 x64 and Windows Server 2016/2019/2022/2025 Desktop Experience remain intended targets, not verified compatibility claims. +Still required: verify native graphical technician login/inventory/history and live logo/color branding, equivalent first-run deployment wizards and reachability diagnostics, executable/MSI packaging integration and repair, company signing integration, full transport-key backup, migrations with rollback, full update recovery and agent replacement, pinned native dependency artifacts, native builds and end-to-end sessions, and clean-machine installation/upgrade tests across every advertised Windows version. Windows 10/11 x64 and Windows Server 2016/2019/2022/2025 Desktop Experience remain intended targets, not verified compatibility claims. See `deployment/windows/VM-TESTING.md` for the evaluation guest acceptance lab. Never publish this development build as production. Signing-provider approval, valid production signatures, exact corresponding AGPL source and all acceptance checks are mandatory release gates. Retain RustDesk attribution and license notices when distributing changed binaries or hosting modified server code. diff --git a/product/agent/src/lib.rs b/product/agent/src/lib.rs index c61b16a7f48..84e626a1c00 100644 --- a/product/agent/src/lib.rs +++ b/product/agent/src/lib.rs @@ -7,6 +7,7 @@ use std::path::{Path, PathBuf}; use swan_protocol::*; pub use swan_protocol as protocol; pub mod update; +pub mod technician; #[derive(Clone, Serialize, Deserialize)] #[serde(deny_unknown_fields)] @@ -79,6 +80,9 @@ impl AgentState { p.validate(&self.bootstrap.company_id,self.accepted_revision,now())?; Ok(p) } + /// Signed cached branding may still be displayed offline. This is never an + /// authorization policy; company_profile() remains mandatory for access. + pub fn display_profile(&self)->Result {self.cached_profile()} fn cached_profile(&self)->Result { let p:CompanyProfile=self.profile.verify(&public_key(&self.bootstrap.profile_public_key)?)?; // Expiration blocks use, but a previously signed rotation key remains a @@ -138,6 +142,7 @@ impl AgentState { let grant:SignedEnvelope=self.client()?.post(self.endpoint("grants")?).bearer_auth(token).json(&json!({"device_id":device,"proof_public_key":STANDARD.encode(key.verifying_key().as_bytes()),"unattended":unattended})).send().await?.error_for_status()?.json().await?; let parsed:SessionGrant=grant.verify(&public_key(&self.bootstrap.profile_public_key)?)?; parsed.validate(&self.bootstrap.company_id,device,&parsed.rustdesk_id,now())?; + ensure!(parsed.proof_public_key==STANDARD.encode(key.verifying_key().as_bytes()) && parsed.unattended==unattended,"Grant differs from requested proof or consent mode"); Ok(grant) } pub async fn claim(&self,login:&ManagedLogin,challenge:&str,peer:&str)->Result { @@ -234,6 +239,7 @@ mod tests { let mut profile=state.company_profile().unwrap();profile.expires_at=now()-1; state.profile=SignedEnvelope::sign(&profile,&SigningKey::from_bytes(&[7;32])).unwrap();state.save(&directory).unwrap(); assert!(AgentState::load(&directory).is_err());assert!(AgentState::load_for_refresh(&directory).is_ok()); + assert_eq!(AgentState::load_for_refresh(&directory).unwrap().display_profile().unwrap().customer.display_name,PRODUCT); state.profile.payload=STANDARD.encode(b"{}");std::fs::write(directory.join("managed-state.json"),serde_json::to_vec(&state).unwrap()).unwrap(); assert!(AgentState::load_for_refresh(&directory).is_err());std::fs::remove_dir_all(directory).unwrap(); } diff --git a/product/agent/src/main.rs b/product/agent/src/main.rs index a82930f806c..89991bdfb8a 100644 --- a/product/agent/src/main.rs +++ b/product/agent/src/main.rs @@ -1,7 +1,7 @@ use anyhow::{bail, ensure, Context, Result}; use ed25519_dalek::SigningKey; use rand::rngs::OsRng; -use serde_json::{json,Value}; +use serde_json::json; use std::path::PathBuf; use swan_agent::*; use swan_protocol::*; @@ -41,6 +41,12 @@ async fn main()->Result<()> { #[cfg(windows)] "recover-update"=>{let mut state=AgentState::load_for_refresh(&directory)?;state.recover_update(&directory)?;println!("Installed release verified and update state recovered.");} #[cfg(windows)] + "verify-installed"=>{ + let state=AgentState::load_for_refresh(&directory)?; + swan_agent::update::verify_installed_metadata(&state,&directory)?; + println!("Installed executable and pinned release publisher verified."); + } + #[cfg(windows)] "verify-package"=>{ let state=AgentState::load(&directory)?; let metadata=args.get(2).context("Usage: swan-agent verify-package release.json INSTALLER")?; @@ -59,14 +65,14 @@ async fn main()->Result<()> { let username=args.get(2).context("Usage: swan-agent login USERNAME (password and code from SWAN_LOGIN_PASSWORD and SWAN_LOGIN_TOTP)")?; let password=std::env::var("SWAN_LOGIN_PASSWORD").context("Set SWAN_LOGIN_PASSWORD for this process only")?; let code=std::env::var("SWAN_LOGIN_TOTP").context("Set SWAN_LOGIN_TOTP for this process only")?; - let result:Value=state.client()?.post(state.endpoint("login")?).json(&json!({"username":username,"password":password,"totp_code":code})).send().await?.error_for_status()?.json().await?; + let token=state.technician_login(username,&password,&code).await?; // Explicit CLI exchange for automation. Never write tokens to logs or persistent profile files. - println!("{}",result); + println!("{}",json!({"token":token})); } "devices"=>{ let state=AgentState::load(&directory)?; let token=std::env::var("SWAN_TECHNICIAN_TOKEN").context("Set authenticated technician token")?; - let devices:Value=state.client()?.get(state.endpoint("devices")?).bearer_auth(token).send().await?.error_for_status()?.json().await?; + let devices=state.technician_inventory(&token).await?; println!("{}",serde_json::to_string_pretty(&devices)?); } "connect"=>{ diff --git a/product/agent/src/technician.rs b/product/agent/src/technician.rs new file mode 100644 index 00000000000..b38e31b004f --- /dev/null +++ b/product/agent/src/technician.rs @@ -0,0 +1,35 @@ +//! HTTPS technician operations. Credentials and session tokens are never saved. +use crate::{protocol::*, AgentState}; +use anyhow::{ensure, Context, Result}; +use ed25519_dalek::SigningKey; +use serde_json::{json, Value}; + +impl AgentState { + pub async fn technician_login(&self, username:&str, password:&str, code:&str)->Result { + ensure!(self.bootstrap.edition==Edition::Technician,"Technician edition required"); + self.company_profile()?; + ensure!(!username.is_empty() && username.len()<=100 && password.len()<=256 && code.len()==6 && code.bytes().all(|b|b.is_ascii_digit()),"Invalid login input"); + let response:Value=self.client()?.post(self.endpoint("login")?).json(&json!({"username":username,"password":password,"totp_code":code})).send().await?.error_for_status()?.json().await?; + Ok(response["token"].as_str().context("Missing authenticated token")?.to_owned()) + } + pub async fn technician_inventory(&self, token:&str)->Result { + ensure!(self.bootstrap.edition==Edition::Technician,"Technician edition required"); + self.company_profile()?; + Ok(self.client()?.get(self.endpoint("devices")?).bearer_auth(token).send().await?.error_for_status()?.json().await?) + } + pub async fn technician_history(&self, token:&str)->Result { + ensure!(self.bootstrap.edition==Edition::Technician,"Technician edition required"); + self.company_profile()?; + Ok(self.client()?.get(self.endpoint("sessions")?).bearer_auth(token).send().await?.error_for_status()?.json().await?) + } + pub async fn technician_logout(&self, token:&str)->Result<()> { + ensure!(self.bootstrap.edition==Edition::Technician,"Technician edition required"); + self.client()?.post(self.endpoint("logout")?).bearer_auth(token).send().await?.error_for_status()?; + Ok(()) + } + pub async fn technician_ticket(&self, token:&str, device:&str, unattended:bool, key:&SigningKey)->Result<(SignedEnvelope,SessionGrant)> { + let envelope=self.request_grant(token,device,unattended,key).await?; + let grant:SessionGrant=envelope.verify(&public_key(&self.bootstrap.profile_public_key)?)?; + Ok((envelope,grant)) + } +} diff --git a/product/agent/src/update.rs b/product/agent/src/update.rs index 6d1ffff7769..eb1c81e09d6 100644 --- a/product/agent/src/update.rs +++ b/product/agent/src/update.rs @@ -35,17 +35,49 @@ fn verify_publisher(path:&Path,release:&Release)->Result<()> { let script=path.parent().context("Missing update directory")?.join("Verify-Package.ps1"); std::fs::write(&script,include_str!("../../../deployment/windows/Verify-Package.ps1"))?; let status=std::process::Command::new("powershell.exe").args(["-NoLogo","-NoProfile","-NonInteractive","-ExecutionPolicy","Bypass","-File"]) - .arg(script).arg("-Path").arg(path).arg("-Publisher").arg(&release.publisher).arg("-Sha256").arg(&release.sha256).status()?; + .arg(script).arg("-Path").arg(path).arg("-Publisher").arg(&release.publisher).arg("-CertificateSha256").arg(&release.publisher_certificate_sha256).arg("-Sha256").arg(&release.sha256).status()?; ensure!(status.success(),"Package publisher verification failed");Ok(()) } #[cfg(windows)] pub fn verify_package(state:&AgentState,envelope:&SignedEnvelope,path:&Path)->Result<()> { let release=validate_release(state,envelope)?; + verify_compatibility(path.parent().context("Missing package directory")?,&release)?; ensure!(digest(std::fs::read(path)?).eq_ignore_ascii_case(&release.sha256),"Installer hash differs from signed metadata"); verify_publisher(path,&release) } +#[cfg(windows)] +fn verify_compatibility(directory:&Path,release:&Release)->Result<()> { + let script=directory.join("Get-WindowsCompatibility.ps1"); + std::fs::write(&script,include_str!("../../../deployment/windows/Get-WindowsCompatibility.ps1"))?; + let output=std::process::Command::new("powershell.exe").args(["-NoLogo","-NoProfile","-NonInteractive","-ExecutionPolicy","Bypass","-File"]).arg(script).output()?; + ensure!(output.status.success(),"Unsupported Windows version or installation type"); + let platform=String::from_utf8(output.stdout)?; + ensure!(release.windows_versions.iter().any(|v|v==platform.trim()),"Release does not support this Windows version");Ok(()) +} + +#[cfg(windows)] +pub fn verify_installed_metadata(state:&AgentState,directory:&Path)->Result<()> { + let envelope:SignedEnvelope=serde_json::from_slice(&std::fs::read(directory.join("installed-release.json"))?)?; + let release:Release=envelope.verify(&public_key(&state.bootstrap.release_public_key)?)?; + // Previously installed software may start after metadata expiry to show + // cached branding. This never authorizes a new install or remote session. + release.validate(&state.bootstrap.edition,state.last_release_sequence.saturating_sub(1),now().min(release.expires_at.saturating_sub(1)))?; + ensure!(state.last_release_sequence==0 || release.sequence==state.last_release_sequence,"Installed release sequence differs from state"); + verify_compatibility(directory,&release)?; + verify_installed(directory,&release) +} + +#[cfg(windows)] +fn save_installed_metadata(directory:&Path,envelope:&SignedEnvelope)->Result<()> { + use std::io::Write; + let temporary=directory.join(format!("installed-release-{}.tmp",random_token())); + let mut file=std::fs::OpenOptions::new().create_new(true).write(true).open(&temporary)?; + file.write_all(&serde_json::to_vec(envelope)?)?;file.sync_all()?;drop(file); + crate::replace_state(&temporary,&directory.join("installed-release.json")) +} + impl AgentState { #[cfg(windows)] pub fn recover_update(&mut self,directory:&Path)->Result<()> { @@ -56,6 +88,7 @@ impl AgentState { ensure!(release.edition==self.bootstrap.edition && release.product==PRODUCT && release.schema==SCHEMA,"Wrong recovery release"); ensure!(self.last_release_sequence==receipt.previous_sequence || self.last_release_sequence==release.sequence,"Recovery sequence conflict"); verify_installed(directory,&release)?; + save_installed_metadata(directory,&receipt.release)?; // A crashed updater may have installed successfully or saved state before // removing the receipt. Exact signed executable identity proves either case. self.last_release_sequence=release.sequence;self.save(directory)?; @@ -77,6 +110,7 @@ impl AgentState { let release=validate_release(self,&envelope)?; ensure!(release.edition!=Edition::Technician || (release.format=="exe" && release.installed_sha256.eq_ignore_ascii_case(&release.sha256)),"Technician updates require a portable EXE with matching installed identity"); let folder=directory.join("updates").join(release.sequence.to_string());std::fs::create_dir_all(&folder)?; + verify_compatibility(&folder,&release)?; let package=folder.join(format!("SwanRemoteSupport-install.{}",release.format)); download(&release.artifact_url,&release.sha256,&package).await?; verify_publisher(&package,&release)?; @@ -103,6 +137,7 @@ impl AgentState { ensure!(matches!(status.code(),Some(0|3010)),"Installation failed; retain signed recovery receipt"); } verify_installed(directory,&release)?; + save_installed_metadata(directory,&receipt.release)?; latest.last_release_sequence=release.sequence;latest.save(directory)?; std::fs::remove_file(receipt_path)?;*self=latest;Ok(true) } diff --git a/product/protocol/src/lib.rs b/product/protocol/src/lib.rs index 7c1789f7d3f..6ca88e5f35f 100644 --- a/product/protocol/src/lib.rs +++ b/product/protocol/src/lib.rs @@ -166,6 +166,8 @@ pub struct Release { pub agent_url: String, pub agent_sha256: String, pub publisher: String, + pub publisher_certificate_sha256: String, + pub windows_versions: Vec, pub source_url: String, pub format: String, } @@ -176,6 +178,8 @@ impl Release { ensure!(["stable", "test"].contains(&self.channel.as_str()) && ["exe", "msi"].contains(&self.format.as_str()), "Invalid release type"); ensure!(self.sha256.len() == 64 && self.sha256.bytes().all(|x| x.is_ascii_hexdigit()) && !self.publisher.is_empty(), "Invalid artifact identity"); ensure!(self.installed_sha256.len()==64 && self.installed_sha256.bytes().all(|x|x.is_ascii_hexdigit()),"Invalid installed executable identity"); + ensure!(self.publisher_certificate_sha256.len()==64 && self.publisher_certificate_sha256.bytes().all(|x|x.is_ascii_hexdigit()),"Invalid publisher certificate identity"); + ensure!(!self.windows_versions.is_empty() && self.windows_versions.len()<=6 && self.windows_versions.iter().all(|v|WINDOWS_VERSIONS.contains(&v.as_str())),"Invalid Windows compatibility declaration"); let fields: Vec<_> = self.version.split('.').collect(); ensure!(fields.len() == 3 && fields.iter().all(|v| !v.is_empty() && v.bytes().all(|c| c.is_ascii_digit())), "Expected numeric major.minor.patch"); https_url(&self.artifact_url)?; @@ -186,6 +190,8 @@ impl Release { } } +pub const WINDOWS_VERSIONS:&[&str]=&["windows_10","windows_11","server_2016","server_2019","server_2022","server_2025"]; + pub fn https_url(value: &str) -> Result { let url = url::Url::parse(value)?; ensure!(url.scheme() == "https" && url.host_str().is_some() && url.username().is_empty() && url.password().is_none() && url.fragment().is_none(), "Expected HTTPS URL without credentials or fragment"); @@ -253,11 +259,13 @@ mod tests { } #[test] fn releases_reject_replay_wrong_edition_expiry_and_missing_installed_identity() { - let mut release=Release {schema:1,product:PRODUCT.into(),version:"1.5.0".into(),sequence:2,edition:Edition::Customer,architecture:"x64".into(),channel:"stable".into(),expires_at:200,artifact_url:"https://releases.example/package.exe".into(),sha256:"a".repeat(64),installed_sha256:"b".repeat(64),agent_url:"https://releases.example/agent.exe".into(),agent_sha256:"c".repeat(64),publisher:"Example".into(),source_url:"https://releases.example/source.tar.gz".into(),format:"exe".into()}; + let mut release=Release {schema:1,product:PRODUCT.into(),version:"1.5.0".into(),sequence:2,edition:Edition::Customer,architecture:"x64".into(),channel:"stable".into(),expires_at:200,artifact_url:"https://releases.example/package.exe".into(),sha256:"a".repeat(64),installed_sha256:"b".repeat(64),agent_url:"https://releases.example/agent.exe".into(),agent_sha256:"c".repeat(64),publisher:"Example".into(),publisher_certificate_sha256:"d".repeat(64),windows_versions:vec!["windows_11".into()],source_url:"https://releases.example/source.tar.gz".into(),format:"exe".into()}; assert!(release.validate(&Edition::Customer,1,100).is_ok()); assert!(release.validate(&Edition::Customer,2,100).is_err()); assert!(release.validate(&Edition::Technician,0,100).is_err()); assert!(release.validate(&Edition::Customer,0,200).is_err()); - release.installed_sha256.clear();assert!(release.validate(&Edition::Customer,0,100).is_err()); + release.windows_versions=vec!["server_core".into()];assert!(release.validate(&Edition::Customer,0,100).is_err()); + release.windows_versions=vec!["windows_11".into()];release.publisher_certificate_sha256="publisher label".into();assert!(release.validate(&Edition::Customer,0,100).is_err()); + release.publisher_certificate_sha256="d".repeat(64);release.installed_sha256.clear();assert!(release.validate(&Edition::Customer,0,100).is_err()); } } diff --git a/product/worker/src/main.rs b/product/worker/src/main.rs index 1d4d66c311e..de70b788435 100644 --- a/product/worker/src/main.rs +++ b/product/worker/src/main.rs @@ -56,8 +56,8 @@ async fn build(_client:&reqwest::Client,job:&Value,output:&Path,id:&str,release_ let artifact_client=swan_agent::http_client(600,true)?; download(&artifact_client,&release.artifact_url,&installer,&release.sha256).await?; download(&artifact_client,&release.agent_url,&agent,&release.agent_sha256).await?; - verify_windows(&installer,&release.publisher,&work)?; - verify_windows(&agent,&release.publisher,&work)?; + verify_windows(&installer,&release,&work)?; + verify_windows(&agent,&release,&work)?; let bootstrap=Bootstrap{schema:SCHEMA,edition:release.edition.clone(),company_id:profile.company_id,management_url:profile.management_url,profile_public_key:company_key.into(),release_public_key:release_key.into()}; let file_name=format!("SwanRemoteSupport-{}-{}-{}.zip",if release.edition==Edition::Customer{"Customer"}else{"Technician"},release.version,id); let temporary=output.join(format!("{file_name}.partial")); @@ -88,8 +88,8 @@ pub async fn download(client:&reqwest::Client,url:&str,path:&Path,hash:&str)->Re ensure!(digest(&bytes).eq_ignore_ascii_case(hash),"Artifact hash mismatch"); std::fs::write(path,bytes)?;Ok(()) } -fn verify_windows(path:&Path,publisher:&str,directory:&Path)->Result<()> { +fn verify_windows(path:&Path,release:&Release,directory:&Path)->Result<()> { let verifier=directory.join("Verify-Package.ps1");std::fs::write(&verifier,include_bytes!("../../../deployment/windows/Verify-Package.ps1"))?; - let status=std::process::Command::new("powershell.exe").args(["-NoProfile","-NonInteractive","-ExecutionPolicy","Bypass","-File"]).arg(verifier).arg("-Path").arg(path).arg("-Publisher").arg(publisher).status()?; + let status=std::process::Command::new("powershell.exe").args(["-NoProfile","-NonInteractive","-ExecutionPolicy","Bypass","-File"]).arg(verifier).arg("-Path").arg(path).arg("-Publisher").arg(&release.publisher).arg("-CertificateSha256").arg(&release.publisher_certificate_sha256).status()?; ensure!(status.success(),"Artifact signature or publisher rejected");Ok(()) } diff --git a/src/flutter.rs b/src/flutter.rs index e6b325cbeb6..a7a2a44e4e0 100644 --- a/src/flutter.rs +++ b/src/flutter.rs @@ -2317,19 +2317,23 @@ pub(super) mod async_tasks { use std::{ collections::HashMap, sync::{ - mpsc::{sync_channel, SyncSender}, Arc, Mutex, }, }; - type TxQueryOnlines = SyncSender>; + type TxQueryOnlines = tokio::sync::mpsc::Sender>; + #[cfg(feature = "swan_custom")] + type CompanyTask = (String, flutter_rust_bridge::StreamSink); lazy_static::lazy_static! { static ref TX_QUERY_ONLINES: Arc>> = Default::default(); + #[cfg(feature = "swan_custom")] + static ref TX_COMPANY: Mutex>> = Default::default(); } #[inline] pub fn start_flutter_async_runner() { - std::thread::spawn(start_flutter_async_runner_); + static STARTED:std::sync::Once=std::sync::Once::new(); + STARTED.call_once(|| {std::thread::spawn(start_flutter_async_runner_);}); } #[allow(dead_code)] @@ -2340,12 +2344,29 @@ pub(super) mod async_tasks { #[tokio::main(flavor = "current_thread")] async fn start_flutter_async_runner_() { // Only one task is allowed to run at the same time. - let (tx_onlines, rx_onlines) = sync_channel::>(1); + let (tx_onlines, mut rx_onlines) = tokio::sync::mpsc::channel::>(1); TX_QUERY_ONLINES.lock().unwrap().replace(tx_onlines); + #[cfg(feature = "swan_custom")] + let mut rx_company = { + let (tx,rx)=tokio::sync::mpsc::channel::(16); + *TX_COMPANY.lock().unwrap()=Some(tx);rx + }; loop { - match rx_onlines.recv() { - Ok(ids) => { + #[cfg(feature = "swan_custom")] + tokio::select! { + task=rx_company.recv()=>match task { + Some((request,sink))=>{let response=crate::managed::technician_request(&request).await;sink.add(response);}, + None=>break, + }, + ids=rx_onlines.recv()=>match ids { + Some(ids)=>crate::client::peer_online::query_online_states(ids,handle_query_onlines).await, + None=>break, + } + } + #[cfg(not(feature = "swan_custom"))] + match rx_onlines.recv().await { + Some(ids) => { crate::client::peer_online::query_online_states(ids, handle_query_onlines).await } _ => { @@ -2356,6 +2377,14 @@ pub(super) mod async_tasks { } } + #[cfg(feature = "swan_custom")] + pub fn company_request(request:String,sink:flutter_rust_bridge::StreamSink)->ResultType<()> { + if let Some(tx)=TX_COMPANY.lock().unwrap().as_ref(){ + if tx.try_send((request,sink)).is_err(){bail!("Company request queue is full");}Ok(()) + } + else {bail!("Company request runner is not ready")} + } + pub fn query_onlines(ids: Vec) -> ResultType<()> { if let Some(tx) = TX_QUERY_ONLINES.lock().unwrap().as_ref() { // Ignore if the channel is full. diff --git a/src/flutter_ffi.rs b/src/flutter_ffi.rs index f01eb085918..2fb76208ce4 100644 --- a/src/flutter_ffi.rs +++ b/src/flutter_ffi.rs @@ -2810,6 +2810,15 @@ pub fn main_get_printer_names() -> SyncReturn { return SyncReturn("".to_owned()); } +/// Responses contain display data only. Login tokens and grant proof keys stay +/// in Rust process memory, shared by this app's Flutter window engines. +pub fn main_company_request(request:String,sink:StreamSink)->ResultType<()> { + #[cfg(feature = "swan_custom")] + {return flutter::async_tasks::company_request(request,sink);} + #[cfg(not(feature = "swan_custom"))] + {let _=request; sink.add("{\"ok\":false,\"error\":\"Managed company edition required\"}".into());Ok(())} +} + pub fn main_get_common(key: String) -> String { #[cfg(feature = "swan_custom")] if key == "company-overview" { diff --git a/src/managed.rs b/src/managed.rs index cfa98bfe680..f0ea5f9086c 100644 --- a/src/managed.rs +++ b/src/managed.rs @@ -3,11 +3,92 @@ use hbb_common::{config::{self, keys}, ResultType}; use swan_agent::{protocol::{self, Edition, ManagedLogin, SignedEnvelope}, AgentState, Lease}; +struct TechnicianLogin {token:String,company:String,username:String} +struct PendingTicket {envelope:SignedEnvelope,key_hex:String,expires_at:u64,company:String} +#[derive(Default)] +struct TechnicianMemory { + generation:u64, + login:Option, + tickets:std::collections::HashMap, +} +hbb_common::lazy_static::lazy_static! { + static ref TECHNICIAN:std::sync::Mutex=Default::default(); +} + +pub async fn technician_request(request:&str)->String { + match technician_request_inner(request).await { + Ok(value)=>serde_json::json!({"ok":true,"data":value}).to_string(), + // Avoid including HTTP request or credential details in UI errors/logs. + Err(_)=>serde_json::json!({"ok":false,"error":"Company request failed. Check login, authenticator code, permissions and server availability."}).to_string(), + } +} + +async fn technician_request_inner(request:&str)->ResultType { + use serde_json::json; + use hbb_common::bail; + use hbb_common::anyhow::anyhow; + if request.len()>8192 {bail!("Request too large");} + let input:serde_json::Value=serde_json::from_str(request)?; + let action=input["action"].as_str().ok_or_else(||anyhow!("Missing action"))?; + let directory=swan_agent::state_directory(); + let state=AgentState::load_for_refresh(&directory)?; + if state.bootstrap.edition!=Edition::Technician {bail!("Technician edition required");} + if action=="sync" { + let mut state=state;state.sync().await?;state.save(&directory)?; + return Ok(json!({"revision":state.accepted_revision})); + } + if action=="status" { + let memory=TECHNICIAN.lock().unwrap(); + return Ok(json!({"logged_in":memory.login.as_ref().map(|l|l.company==state.bootstrap.company_id).unwrap_or(false),"username":memory.login.as_ref().map(|l|l.username.as_str())})); + } + if action=="login" { + let username=input["username"].as_str().ok_or_else(||anyhow!("Missing username"))?; + let password=input["password"].as_str().ok_or_else(||anyhow!("Missing password"))?; + let code=input["code"].as_str().ok_or_else(||anyhow!("Missing code"))?; + let generation={let mut memory=TECHNICIAN.lock().unwrap();memory.generation=memory.generation.wrapping_add(1);memory.login=None;memory.tickets.clear();memory.generation}; + let token=state.technician_login(username,password,code).await?; + let accepted={let mut memory=TECHNICIAN.lock().unwrap(); + if memory.generation==generation {memory.login=Some(TechnicianLogin{token:token.clone(),company:state.bootstrap.company_id.clone(),username:username.into()});true}else{false} + }; + if !accepted {state.technician_logout(&token).await?;bail!("Login superseded");} + return Ok(json!({"username":username})); + } + let (token,generation)={let memory=TECHNICIAN.lock().unwrap(); + let login=memory.login.as_ref().ok_or_else(||anyhow!("Login required"))?; + if login.company!=state.bootstrap.company_id {bail!("Wrong company login");} + (login.token.clone(),memory.generation) + }; + if action=="logout" { + {let mut memory=TECHNICIAN.lock().unwrap();memory.generation=memory.generation.wrapping_add(1);memory.login=None;memory.tickets.clear();} + // Local logout is immediate even if the server cannot receive revocation. + state.technician_logout(&token).await?; + return Ok(json!({"logged_in":false})); + } + if action=="devices" {return Ok(state.technician_inventory(&token).await?);} + if action=="history" {return Ok(state.technician_history(&token).await?);} + if action=="connect" { + let device=input["device_id"].as_str().ok_or_else(||anyhow!("Missing device"))?; + let key=protocol::signing_key_from_hex(&protocol::random_token())?; + let (envelope,grant)=state.technician_ticket(&token,device,input["unattended"].as_bool().unwrap_or(false),&key).await?; + let mut memory=TECHNICIAN.lock().unwrap(); + if memory.generation!=generation || memory.login.is_none(){bail!("Login changed during authorization");} + memory.tickets.retain(|_,ticket|ticket.expires_at>protocol::now()); + if memory.tickets.len()>=32 {bail!("Too many pending connections");} + // The proof is consumed inside native handle_hash, never sent to Dart, + // command-line arguments, another app instance or IPC configuration. + memory.tickets.insert(grant.rustdesk_id.clone(),PendingTicket{envelope,key_hex:hex::encode(key.to_bytes()),expires_at:grant.expires_at,company:grant.company_id}); + return Ok(json!({"rustdesk_id":grant.rustdesk_id})); + } + bail!("Unknown technician action") +} + pub fn apply_defaults() { - *config::APP_NAME.write().unwrap() = "Swan Remote Support".into(); - let state = AgentState::load(&swan_agent::state_directory()); + let state = AgentState::load_for_refresh(&swan_agent::state_directory()); let profile = state.as_ref().ok().and_then(|s| s.company_profile().ok()); let technician = state.as_ref().map(|s| s.bootstrap.edition == Edition::Technician).unwrap_or(false); + // Stable edition identifiers keep customer service/configuration separate + // from a technician app running on the same Windows machine. + *config::APP_NAME.write().unwrap() = if technician{"Swan Remote Support Technician"}else{"Swan Remote Support"}.into(); let mut overrides = config::OVERWRITE_SETTINGS.write().unwrap(); // Fail closed before setup; never use an upstream public server or legacy tailnet. overrides.insert(keys::OPTION_CUSTOM_RENDEZVOUS_SERVER.into(), profile.as_ref().map(|p|p.rendezvous.clone()).unwrap_or_else(||"unconfigured.invalid".into())); @@ -30,6 +111,18 @@ pub fn apply_defaults() { builtin.insert(keys::OPTION_ALLOW_LOGON_SCREEN_PASSWORD.into(),"N".into()); } +pub fn refresh_defaults()->bool { + static MODIFIED:std::sync::Mutex>=std::sync::Mutex::new(None); + let modified=match std::fs::metadata(swan_agent::state_directory().join("managed-state.json")).and_then(|m|m.modified()) { + Ok(modified)=>modified, + Err(_)=>return false, + }; + let mut last=MODIFIED.lock().unwrap();if *last==Some(modified){return false;}*last=Some(modified);drop(last); + let before=(config::Config::get_option(keys::OPTION_CUSTOM_RENDEZVOUS_SERVER),config::Config::get_option(keys::OPTION_RELAY_SERVER),config::Config::get_option(keys::OPTION_KEY)); + apply_defaults(); + before!=(config::Config::get_option(keys::OPTION_CUSTOM_RENDEZVOUS_SERVER),config::Config::get_option(keys::OPTION_RELAY_SERVER),config::Config::get_option(keys::OPTION_KEY)) +} + pub async fn claim(password:&[u8],challenge:&str,peer:&str)->ResultType { let login=ManagedLogin::from_wire(password)?; let state=AgentState::load(&swan_agent::state_directory())?; @@ -50,21 +143,26 @@ pub fn login(challenge:&str,target:&str)->ResultType> { drop(activity); let state=AgentState::load(&swan_agent::state_directory())?; if state.bootstrap.edition!=Edition::Technician {hbb_common::bail!("Technician edition required");} - let envelope:SignedEnvelope=serde_json::from_str(&std::env::var("SWAN_SESSION_GRANT")?)?; + let pending=TECHNICIAN.lock().unwrap().tickets.remove(target); + let (envelope,key)=if let Some(ticket)=pending { + if ticket.company!=state.bootstrap.company_id || ticket.expires_at<=protocol::now(){hbb_common::bail!("Expired or wrong-company ticket");} + (ticket.envelope,protocol::signing_key_from_hex(&ticket.key_hex)?) + }else{ + (serde_json::from_str(&std::env::var("SWAN_SESSION_GRANT")?)?,protocol::signing_key_from_hex(&std::env::var("SWAN_SESSION_PROOF_KEY")?)?) + }; let grant:protocol::SessionGrant=envelope.verify(&protocol::public_key(&state.bootstrap.profile_public_key)?)?; grant.validate(&state.bootstrap.company_id,&grant.device_id,target,protocol::now())?; // A ticket stolen from a connection cannot be used without this ephemeral proof key. - let key=protocol::signing_key_from_hex(&std::env::var("SWAN_SESSION_PROOF_KEY")?)?; Ok(ManagedLogin::prove(envelope,&key,challenge).to_wire()?) } pub fn overview()->String { - let state=AgentState::load(&swan_agent::state_directory()); + let state=AgentState::load_for_refresh(&swan_agent::state_directory()); match state { - Ok(state)=>match state.company_profile(){ + Ok(state)=>match state.display_profile(){ Ok(profile)=>{ let brand=if state.bootstrap.edition==Edition::Technician{&profile.technician}else{&profile.customer}; - serde_json::json!({"configured":true,"enrolled":state.device_id.is_some(),"unattended":state.unattended_consent,"display_name":brand.display_name,"primary_color":brand.primary_color,"logo_svg":brand.logo_svg,"support_url":brand.support_url,"consent_text":brand.consent_text,"domain":profile.management_url}).to_string() + serde_json::json!({"configured":true,"profile_valid":state.company_profile().is_ok(),"edition":state.bootstrap.edition,"enrolled":state.device_id.is_some(),"unattended":state.unattended_consent,"display_name":brand.display_name,"primary_color":brand.primary_color,"logo_svg":brand.logo_svg,"support_url":brand.support_url,"consent_text":brand.consent_text,"domain":profile.management_url}).to_string() } Err(_)=>"{\"configured\":false}".into() }, diff --git a/src/rendezvous_mediator.rs b/src/rendezvous_mediator.rs index 4f5c8fee82d..11624ec4729 100644 --- a/src/rendezvous_mediator.rs +++ b/src/rendezvous_mediator.rs @@ -280,6 +280,8 @@ impl RendezvousMediator { } }, _ = timer.tick() => { + #[cfg(feature = "swan_custom")] + if crate::managed::refresh_defaults() {Self::restart();} if SHOULD_EXIT.load(Ordering::SeqCst) { break; } @@ -459,6 +461,8 @@ impl RendezvousMediator { rz.handle_resp(msg.union, Sink::Stream(&mut conn), &server, &mut update_latency).await? } _ = timer.tick() => { + #[cfg(feature = "swan_custom")] + if crate::managed::refresh_defaults() {Self::restart();} if SHOULD_EXIT.load(Ordering::SeqCst) { break; } From 3b8ef1a1101eceec0fa36c1797e3953c46ae786e Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Thu, 1 Oct 2026 13:42:48 +0200 Subject: [PATCH 004/652] Export native dependency resolution for locked build review --- .github/workflows/bridge.yml | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/.github/workflows/bridge.yml b/.github/workflows/bridge.yml index 163f9f19cc9..7f09dcd1ff3 100644 --- a/.github/workflows/bridge.yml +++ b/.github/workflows/bridge.yml @@ -114,3 +114,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 From 75632a21ce5a4a9c35172d4229bb6897ce69bd34 Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Thu, 1 Oct 2026 13:49:06 +0200 Subject: [PATCH 005/652] Pin native managed application dependencies without changing baseline versions --- Cargo.lock | 181 +++++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 168 insertions(+), 13 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 57177174db5..2a93ffb29be 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", @@ -7334,6 +7441,7 @@ dependencies = [ "shutdown_hooks", "softbuffer", "stunclient", + "swan-agent", "sys-locale", "system_shutdown", "tao", @@ -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", ] From c78a47196c398b25d23cc3a22d8f22272f7dd991 Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Thu, 1 Oct 2026 16:06:25 +0200 Subject: [PATCH 006/652] Preserve enrollment trust and secure technician session tickets --- .github/workflows/flutter-build.yml | 6 +- README.md | 4 +- deployment/windows/LOCAL-TESTING.md | 2 +- deployment/windows/VM-TESTING.md | 5 +- deployment/windows/test-company-lifecycle.js | 21 +++++- docs/ACCEPTANCE.md | 73 +++++++++++++++++++ docs/CODE_SIGNING_POLICY.md | 56 +++----------- docs/PRIVACY.md | 37 ++-------- docs/SOURCE_AND_LICENSE.md | 16 ++-- docs/SWAN_CUSTOM_BUILD.md | 2 + docs/TAILSCALE_DEPLOYMENT.md | 2 + .../pages/company_technician_page.dart | 6 +- .../lib/desktop/pages/desktop_home_page.dart | 16 +++- product/agent/src/lib.rs | 43 ++++++++++- product/agent/src/main.rs | 12 ++- src/client.rs | 4 +- src/flutter_ffi.rs | 4 +- src/managed.rs | 18 +++-- 18 files changed, 215 insertions(+), 112 deletions(-) create mode 100644 docs/ACCEPTANCE.md diff --git a/.github/workflows/flutter-build.yml b/.github/workflows/flutter-build.yml index 2491789e6a4..0f6d07d5f11 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" diff --git a/README.md b/README.md index 93062e5e1de..9e69b266eca 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). -**[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/deployment/windows/LOCAL-TESTING.md b/deployment/windows/LOCAL-TESTING.md index 836fdb1f5d4..9cdac0e180e 100644 --- a/deployment/windows/LOCAL-TESTING.md +++ b/deployment/windows/LOCAL-TESTING.md @@ -8,4 +8,4 @@ The default local administration address is http://127.0.0.1:18080. Use the one- This loopback HTTP listener is for administration smoke tests. Endpoint enrollment and profile synchronization require HTTPS with a trusted certificate. `local-https.js` provides an isolated loopback proxy using PFX/password settings from the ignored environment file. Debug agents accept a DER test certificate through `SWAN_TEST_CA_FILE`; release builds ignore this variable and use operating-system trust roots. Certificate validation remains enabled. Public direct and relay testing requires a company-owned reachable endpoint and a separate external test machine. -Verified on this development machine: management starts, administration responds with HTTP 200, and eleven product-workspace tests pass. `test-company-lifecycle.js` also passed against the running HTTPS proxy and built agent: fresh company setup, device enrollment, approval denial, technician MFA/group inventory, consent denial, grant replay denial, signed branding sync and revocation. It refuses to alter an already configured test company. This API integration uses a test device ID and does not demonstrate native remote desktop. The native Flutter/RustDesk integration, production signatures, complete installers, update installation/recovery and clean-machine Windows compatibility remain release requirements; this development build is not a production release. +Verified on this development machine: management starts, administration responds with HTTP 200, and thirteen product-workspace tests pass. `node deployment/windows/test-company-lifecycle.js PATH_TO_PRIVATE_ENV_FILE` also passed against a fresh HTTPS proxy and newly built agent/server: company setup, device enrollment, approval denial, technician MFA/group inventory and session history, logout credential revocation, consent denial, grant replay denial, signed branding sync and device revocation. The script records executable hashes in the protected data directory and refuses an already configured company. For another run, create a separate empty data directory and environment file, choose unused loopback ports, and copy the public DER test certificate into that directory as `localhost.cer`; preserve earlier databases and results. This API integration uses a test device ID and does not demonstrate native remote desktop. The native Flutter/RustDesk integration, production signatures, complete installers, update installation/recovery and clean-machine Windows compatibility remain release requirements; this development build is not a production release. diff --git a/deployment/windows/VM-TESTING.md b/deployment/windows/VM-TESTING.md index c0277340cce..174033501b4 100644 --- a/deployment/windows/VM-TESTING.md +++ b/deployment/windows/VM-TESTING.md @@ -4,8 +4,9 @@ Use official Microsoft evaluation media, within its evaluation period. Never disable activation checks or redistribute evaluation images with project releases. Media, VM disks, local credentials, screenshots and raw test logs stay outside Git. -The initial local lab is `D:\SwanRemoteSupport-TestLab`. It uses QEMU with one -guest at a time, 4 GiB RAM, two virtual CPUs and a sparse 64 GiB disk. User-mode +Choose the local lab, QEMU and ISO paths in a private local environment file. +The initial resource budget is one guest at a time, 4 GiB RAM, two virtual CPUs +and a sparse 64 GiB disk. User-mode NAT does not change the host bridge, firewall or existing RustDesk services. VNC (port 5920) and the QEMU monitor (4444) listen on loopback only. These sockets provide full guest access; close QEMU when the guest is not under test. diff --git a/deployment/windows/test-company-lifecycle.js b/deployment/windows/test-company-lifecycle.js index 8dfd0d1fa10..c52cfaff871 100644 --- a/deployment/windows/test-company-lifecycle.js +++ b/deployment/windows/test-company-lifecycle.js @@ -4,10 +4,21 @@ const fs=require('node:fs'), path=require('node:path'), https=require('node:http const crypto=require('node:crypto'), assert=require('node:assert/strict'); const {spawnSync}=require('node:child_process'); const root=path.resolve(__dirname,'../..'); -const data=path.join(root,'swan-data/local-test'); +const envFile=process.argv[2]; +if(!envFile)throw new Error('Pass a private local-test.env file for a fresh isolated server'); +const settings={}; +for(const line of fs.readFileSync(envFile,'utf8').split(/\r?\n/)){ + if(!line.trim()||line.trimStart().startsWith('#'))continue; + const match=/^([A-Z_]+)=(.*)$/.exec(line);if(!match)throw new Error('Invalid test environment data'); + settings[match[1]]=match[2]; +} +assert.match(settings.SWAN_LISTEN??'',/^127\.0\.0\.1:\d+$/); +assert(settings.SWAN_DATA_DIR,'A separate test data directory is required'); +const data=path.resolve(settings.SWAN_DATA_DIR); const agent=path.join(root,'product/target/debug/swan-agent.exe'); const ca=new crypto.X509Certificate(fs.readFileSync(path.join(data,'localhost.cer'))).toString(); -const base='https://localhost:18443'; +const port=Number(settings.SWAN_TEST_TLS_PORT);assert(Number.isInteger(port)&&port>=1024&&port<=65535); +const base=`https://localhost:${port}`; const alphabet='ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'; function base32(bytes){let bits=0,value=0,result='';for(const byte of bytes){value=(value<<8)|byte;bits+=8;while(bits>=5){result+=alphabet[(value>>>(bits-5))&31];bits-=5;}}if(bits)result+=alphabet[(value<<(5-bits))&31];return result;} function decode32(text){let bits=0,value=0,result=[];for(const c of text.replace(/=/g,'')){value=(value<<5)|alphabet.indexOf(c);bits+=5;if(bits>=8){result.push((value>>>(bits-8))&255);bits-=8;}}return Buffer.from(result);} @@ -50,11 +61,15 @@ async function main(){ const grant=await api('grants','POST',technicianLogin.token,ticket);assert.equal(grant.status,200); assert.equal((await api('grants/claim','POST',customer.device_token,{grant:grant.body})).status,200); assert.equal((await api('grants/claim','POST',customer.device_token,{grant:grant.body})).status,403,'Grant replay accepted'); + const history=JSON.parse(runAgent(techDir,['history'],auth));assert.equal(history.length,1);assert.equal(history[0].claimed,true);assert.equal(history[0].device_id,customer.device_id); current.customer.display_name='Changed Local Test Branding';assert.equal((await api('profile','PUT',admin,current)).status,200); runAgent(customerDir,['sync']);const updated=JSON.parse(Buffer.from(state(customerDir).profile.payload,'base64'));assert.equal(updated.customer.display_name,'Changed Local Test Branding');assert.equal(updated.revision,2); assert.equal((await api(`devices/${customer.device_id}/state`,'PUT',admin,{state:'revoked',group:'test-customers'})).status,200); assert.equal((await api('grants','POST',technicianLogin.token,ticket)).status,403,'Revoked device accessible'); - fs.writeFileSync(path.join(data,'lifecycle-result.json'),JSON.stringify({passed:true,at:new Date().toISOString(),checks:['HTTPS certificate validation','fresh company setup','built agent bootstrap and enrollment','pending approval denial','technician MFA login and group inventory','unattended consent denial','grant replay denial','signed branding sync','device revocation']},null,2)); + runAgent(techDir,['logout'],auth);assert.equal((await api('devices','GET',technicianLogin.token)).status,401,'Logged-out credential still authorized'); + const source=spawnSync('git',['rev-parse','HEAD'],{cwd:root,encoding:'utf8'}); + const dirty=spawnSync('git',['status','--porcelain'],{cwd:root,encoding:'utf8'}); + fs.writeFileSync(path.join(data,'lifecycle-result.json'),JSON.stringify({passed:true,at:new Date().toISOString(),source_commit:source.status===0?source.stdout.trim():'unknown',source_dirty:dirty.status===0?dirty.stdout.trim().length>0:null,agent_sha256:crypto.createHash('sha256').update(fs.readFileSync(agent)).digest('hex'),management_sha256:crypto.createHash('sha256').update(fs.readFileSync(path.join(root,'product/target/debug/swan-management.exe'))).digest('hex'),checks:['HTTPS certificate validation','fresh company setup','built agent bootstrap and enrollment','pending approval denial','technician MFA login and group inventory','technician session history','logout revokes credentials','unattended consent denial','grant replay denial','signed branding sync','device revocation']},null,2)); console.log('PASS: real local HTTPS company lifecycle and built-agent checks. Native sessions and installers remain separate tests.'); } main().catch(error=>{console.error(error.message);process.exitCode=1;}); diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md new file mode 100644 index 00000000000..5f30d4ab4bf --- /dev/null +++ b/docs/ACCEPTANCE.md @@ -0,0 +1,73 @@ +# Configurable product acceptance report + +Development status, 1 October 2026. **The product is not complete or approved for +production publication.** A passing component test is not evidence of a working +native support session or a clean-machine installation. + +## Evidence obtained + +- Public baseline tag `swan-single-company-baseline-1.4.9` preserves the original + Swan deployment and build instructions. Its source was not replaced. +- Thirteen product tests pass on the local Windows development host, including + enrollment/trust preservation across stale writes and trusted key rotation. + The preceding eleven-test component source passed tests and release compilation on + [Windows 2022 and Ubuntu 24.04 CI](https://github.com/rijff24/SwanRemoteSupport/actions/runs/36856716860) + at commit `411269b`. +- A fresh, isolated company passed real HTTPS tests against newly built agent + and management executables: setup/TOTP, enrollment, pending-device denial, + technician login and group inventory, own-session history, logout token + revocation, unattended-consent denial, single-use grants, signed branding + revision synchronization and device revocation. Exact executable hashes and + private results stay under the Git-ignored local test data directory. This + test uses a test RustDesk ID and proves **no native remote desktop behavior**. +- Flutter bridge generation passed for the technician request stream. Native + Rust parsing and changed PowerShell parsing passed. The graphical technician + interface and in-memory ticket broker still need native compilation and use. +- Native lockfile resolution at `75632a2` adds managed-agent dependencies while + preserving every pre-existing package version, checksum and Git commit. A + pinned Windows x64 build passed native Rust compilation but failed Flutter + compilation because the technician page lacked its platform bridge import. + That import is now corrected; a successful endpoint build is still required. +- Existing RustDesk, hbbs and hbbr services remained running. Only the isolated + development management process was rebuilt and restarted. +- Windows hypervisor API probing reports an available hypervisor. QEMU 11.1.0 + passed its published SHA-512 checksum and runs from the separate local lab. + Official Server 2025 evaluation media is still downloading. No evaluation + guest installation has passed yet. + +## Requirements still to demonstrate + +| Requirement | Current evidence and remaining work | +| --- | --- | +| Reproducible server, customer, technician and worker builds | Product components pass pinned builds. Native x64 build/package remains pending; baseline rebuild is not newly proved. | +| Fresh-company Windows/Linux setup | HTTPS/API setup works. Equivalent full setup wizards, transport provisioning, certificate management and reachability checks remain incomplete. | +| Roles, groups, permissions and revocation | MFA/group/device denial tests pass. Granular signed session permission policy and receiver enforcement still require implementation and verification. | +| Configurable customer and technician apps | Signed profile sync passes. Graphical login/inventory/history, logo/color rendering, contacts, company shortcuts, offline operation and native restart/upgrade preservation remain to verify or complete. | +| Signed profiles and rotation | Component tests reject tampering, wrong-company, expired and older profiles and untrusted key changes. Trusted cached key/endpoint rotation preserves enrollment in tests. Real HTTPS rotation and installed-app behavior still need verification. | +| Approved managed sessions without password/direct bypass | Grant signature, target, challenge and replay checks pass component/API tests. Actual receiving-device enforcement, direct/relay paths, lease expiry and live revocation are unverified. | +| Customer approval and unattended consent | API consent denial and durable local revocation tests pass. Real attended prompts, stop/uninstall, unattended opt-in, restart, sign-in screen and UAC are unverified. | +| Transfer, clipboard, multiple monitors | Native end-to-end tests pending. | +| Public company transport without Tailscale | Source uses company transport and fails closed before setup. External NAT traversal, relay fallback and negative-access tests pending; no public endpoint has been changed. | +| Windows worker and public/protected downloads | Queue, worker, fixed bundles and access checks are implemented. A real signed worker job, both EXE/MSI installation products, protected technician download and public customer website installation remain unverified or incomplete. | +| Signing and exact corresponding source | Certificate/hash/publisher pins and source metadata are implemented. Provider approval, production signatures, company signing integration and production release checks remain gates. | +| Approved automatic updates | Metadata replay/expiry and session exclusion tests pass. Real rollout, pause/windows, full payload/agent identity preservation, technician executable handoff, interruption repair/retry/rollback and recovery remain incomplete or unverified. | +| Backup, restore and migrations | Encrypted management round-trip/tamper checks pass. Full deployment/transport key coverage, server restore rehearsal and migration rollback remain incomplete or unverified. | + +## Clean Windows matrix + +All rows are intended targets, not verified compatibility claims. Record exact +edition/build, media hash, product commit, package signature/hash and results. + +| Operating system | Clean installation | Repair/upgrade/recovery | Native session behavior | +| --- | --- | --- | --- | +| Windows 10 x64 | Unverified | Unverified | Unverified | +| Windows 11 x64 | Unverified | Unverified | Unverified | +| Server 2016 Desktop Experience | Unverified | Unverified | Unverified | +| Server 2019 Desktop Experience | Unverified | Unverified | Unverified | +| Server 2022 Desktop Experience | Unverified | Unverified | Unverified | +| Server 2025 Desktop Experience | Unverified | Unverified | Unverified | + +See [VM testing](../deployment/windows/VM-TESTING.md) and +[local HTTPS testing](../deployment/windows/LOCAL-TESTING.md). Test credentials, +signing keys, certificates with private keys, VM images and raw operational logs +must remain outside public source. The project provides no shared company VPS. diff --git a/docs/CODE_SIGNING_POLICY.md b/docs/CODE_SIGNING_POLICY.md index 914fd819045..b3bc92c3a70 100644 --- a/docs/CODE_SIGNING_POLICY.md +++ b/docs/CODE_SIGNING_POLICY.md @@ -1,57 +1,19 @@ # Swan Remote Support code-signing policy -## Scope +Production publication requires provider approval, valid signatures and completed release checks. Project signing through an open-source program is an intended integration, not an approved service or a guarantee of free signing. Company signing is optional and requires its own publisher policy and keys. -This policy covers Swan-authored Windows executables, DLLs, PowerShell deployment helpers, and the final Swan Remote Support installer distributed from this repository. The official Tailscale MSI is not part of the Swan build or release payload; the bootstrap downloads it from Tailscale and independently verifies its checksum and publisher. +## Project releases -Free code signing provided by SignPath.io, certificate by SignPath Foundation. +Use immutable public source tags, reviewed pinned build workflows/toolchains/dependencies and exact artifact/source hashes. Sign the project executables and DLLs before packaging, then sign and verify the final installer. Publish signed release metadata identifying edition, x64 architecture, supported Windows families, package/installed/agent identities, certificate fingerprint, publisher and corresponding source. -SignPath Foundation eligibility and project approval are external gates. Until the project is accepted and the complete release passes this policy, all CI artifacts must remain clearly marked **unsigned** and must not be distributed as customer-ready software. +Keep project signing private keys with the approved signing provider. Never distribute them to company servers or workers. Company workers consume pinned unchanged project release binaries plus validated company profiles and assets. Profile text must never become a build script or shell command. Customized embedded EXE resources or publisher identity require newly signed company artifacts and corresponding public source. -## Source and responsibilities +Verify Authenticode trust, publisher, signing-certificate SHA-256 and signed artifact hashes before executing installation or updates. Company administrators approve imported release metadata before rollout. Signing does not substitute for enrollment, receiver authorization or acceptance testing. -- Canonical source: the public Swan-controlled GitHub fork and the immutable Git tag for the release. -- Author/maintainer: Rijff Swanepoel (`@rijff24`). -- Reviewer: the maintainer records a self-review while Swan is a one-person project. Security-sensitive or dependency changes should receive an independent review before signing whenever another qualified reviewer is available. -- Signing approver: Rijff Swanepoel, using a separate MFA-protected SignPath approval step. -- Build system: the pinned GitHub Actions workflow in `.github/workflows/swan-windows-build.yml`. +## Release gate -The author and signing-approval actions must be separate auditable events even when performed by the same person. GitHub and SignPath accounts must use MFA. Shared accounts are prohibited. +The maintainer must match source commit, workflow run, artifact hashes, publisher policy and recorded test evidence. Complete clean-machine installation, repair/upgrade/recovery/uninstall and native authorization/session tests for every advertised Windows family. Preserve identity, enrollment, consent, branding and permissions. Publish source and known limitations with each release. -## Approval requirements +Until these gates pass, mark build artifacts as development/unsigned and do not publish them as customer-ready production software. Local test certificates and development release keys stay in isolated protected test environments. A test-signed package is not a trusted production release. -A signing request may be approved only when all of the following are true: - -1. The source commit is in the canonical public repository and the release tag is immutable. -2. The release contains no private server key, Tailscale auth key, API token, customer credential, signing key, PFX, password, or customer data. -3. The build uses the reviewed workflow and pinned action commits, toolchains, and dependency lockfiles. -4. The server address and Ed25519 **public** key match the documented public configuration. -5. Automated format, parse, compile, and applicable test checks pass from a clean checkout. -6. The inner Swan executables and DLLs are scanned, signed, timestamped, and signature-verified before packaging. -7. The final installer is built from those signed inner files, then signed, timestamped, scanned, and signature-verified. -8. A clean Windows 10/11 test confirms installation, Tailscale tag enforcement, numeric Swan ID registration, unattended connection, customer-visible stop/uninstall controls, reboot/logon-screen access, rollback, and uninstall. -9. The release notes publish the final SHA-256, corresponding-source link, privacy link, known limitations, and rollback instructions. -10. No unexplained files or unreviewed changes remain in the release tree. - -An approver must reject a request if the source commit, workflow run, artifact hash, expected publisher, or test evidence cannot be matched exactly. - -## Tailscale boundary - -Swan does not sign, modify, or represent the Tailscale Windows client as a Swan product. The deployment bootstrap accepts the official Tailscale package only if both the pinned hash and valid `Tailscale Inc.` Authenticode organization match. A mismatch is a hard failure. The temporary MSI is removed after the attempt. - -This separation is intentional for licensing, provenance, update safety, and SignPath review. A future monolithic bundle must not be released unless Tailscale redistribution terms and SignPath's proprietary-component rules have been reviewed in writing. - -## Key protection and incident response - -No signing private key is stored in this repository or on a general-purpose developer PC. Signing is performed by the approved hosted service with MFA and auditable approval. Signing credentials may not be printed, copied to issue comments, or exposed to pull requests from forks. - -If a signing account, workflow, release artifact, maintainer device, or customer credential may be compromised: - -1. Stop publication and customer installation immediately. -2. Revoke affected repository/session credentials and notify SignPath where signing trust may be affected. -3. Remove or mark the release unsafe without rewriting the source history needed for investigation. -4. Preserve hashes, workflow logs, and relevant security evidence without publishing customer secrets. -5. Rotate affected Swan passwords, Tailscale node identities/tags, and server secrets as applicable. -6. Publish a clear security advisory and a clean replacement release only after root-cause review and full revalidation. - -See [Security Policy](../SECURITY.md), [Privacy](PRIVACY.md), [Windows signing process](WINDOWS_CODE_SIGNING.md), and [Tailscale deployment](TAILSCALE_DEPLOYMENT.md). +The original single-company signing and Tailscale deployment instructions remain in the tagged baseline. Tailscale is not a required component of new company packages. See [the acceptance report](ACCEPTANCE.md) for remaining signing, packaging and native test work. diff --git a/docs/PRIVACY.md b/docs/PRIVACY.md index 9e8ed12d1d8..47dfe895610 100644 --- a/docs/PRIVACY.md +++ b/docs/PRIVACY.md @@ -1,36 +1,15 @@ -# Swan Remote Support privacy statement +# Company deployment privacy information -## Purpose +This is the configurable product's technical privacy description. Each hosting company publishes its own support contact, consent wording, retention policy and customer request process before distributing its packages. The current development build is not a production release. -Swan Remote Support is used only to provide support, maintenance, updates, and separately agreed backup work for Swan Computing customers. Installation and unattended access require the computer owner's informed authorization. The customer can see the tray/service presence and can stop or uninstall the software. +The company server stores administrator/technician accounts, password hashes, TOTP authentication secrets, device enrollment records, group permissions, authorization/session history, audit events and update/build metadata. Devices keep their unique enrollment credentials and signed cached company configuration. Installer bootstrap files contain public company endpoint and trust information only. -## Data handled +Customer approval is the default session mode. Unattended support requires separate explicit consent and company policy permission. Revocation is saved locally before server synchronization; the receiver must deny subsequent unattended access when consent is absent. Customer-visible support status, stop controls and uninstall remain acceptance requirements. Session recording is disabled by default. -Depending on the support task, the service may process: +An authorized remote session may expose screen content, input, clipboard contents, transferred files, audio and system information according to its permissions. Companies must explain the support scope to their customers. Technician authorization is verified by signed grants and receiving devices; a company download is not itself permission to access a device. -- customer and device identification needed to associate a computer with its support agreement; -- the Swan Remote Support device ID and its unique unattended password; -- connection metadata and diagnostic logs from the self-hosted RustDesk server and client; -- device and account metadata processed by Tailscale under Tailscale's own service terms and privacy documentation; -- screen content, clipboard content, files, commands, or system information visible during an authorized support session; and -- backup data only when a separate backup scope, destination, encryption, retention, and restore procedure has been agreed. +Configuration and trust changes use signed profiles over HTTPS. Cached company branding may be shown offline, but expired or unverifiable policy cannot authorize a new managed session. No project-hosted shared company service is supplied, and the new package flow does not install Tailscale or collect legacy device passwords. -The public source repository contains the RustDesk/Tailscale server address and the server's Ed25519 **public** key. These are routing and trust values, not customer records or private credentials. The repository and release artifacts must never contain the server private key, customer passwords, Tailscale auth keys, API tokens, signing keys, or customer data. +Companies protect database/configuration/transport keys, enrollment credentials, TOTP seeds, signing material and backups. Private keys and customer records never belong in public source or installation bundles. Support contacts and deletion/retention procedures are the hosting company's responsibility. Complete backup, restore and deletion behavior must be verified before production use. -## Storage and access - -Each customer computer receives a different cryptographically random Swan password. The one-time device ID/password receipt is copied during installation, transferred into Swan's organization password manager, and cleared from the clipboard automatically after 90 seconds when the GUI flow is used. The installer does not print the password unless an administrator explicitly chooses the higher-risk `-ShowCredentials` option. - -Access to customer device records is limited to authorized Swan support staff. Technician devices and service accounts must use MFA where supported. Passwords must not be reused or sent through ordinary email or unencrypted chat. - -## Retention and deletion - -Support credentials and operational records are retained only for the active support relationship and any legally or contractually required period. When support ends, Swan disables unattended access, removes the Tailscale node from the tailnet, deletes or archives the password-manager record according to the agreed retention policy, and helps the customer uninstall the software if requested. - -Backup retention is governed by the separate customer backup agreement. Ending remote support does not itself prove that every backup copy has been deleted; backup destinations and retention schedules must be reviewed separately. - -## Customer choices - -Customers may request attended support instead of unattended access, pause the Swan service, revoke authorization, uninstall Swan Remote Support, or ask what support records are held about their device. A customer-request process and business contact channel must be published on Swan Computing's customer download page before the first public release. - -This repository documents the technical privacy controls. It is not a substitute for a jurisdiction-specific privacy notice or customer contract. Swan Computing should obtain appropriate South African legal/privacy review before production use, especially where customer backups may contain personal information. +The historical Swan-only privacy and password-manager flow is preserved in the tagged baseline. See [the acceptance report](ACCEPTANCE.md) for controls that still need native or clean-machine verification. diff --git a/docs/SOURCE_AND_LICENSE.md b/docs/SOURCE_AND_LICENSE.md index 8d02df86173..45da867217f 100644 --- a/docs/SOURCE_AND_LICENSE.md +++ b/docs/SOURCE_AND_LICENSE.md @@ -1,17 +1,13 @@ # Source availability and licensing -Swan Remote Support is a modified RustDesk client distributed under the GNU Affero General Public License v3.0. Anyone receiving a Swan binary must be able to obtain the complete corresponding source for that exact version, including Swan modifications, build scripts, pinned build configuration, and licence notices. +Swan Remote Support retains RustDesk attribution and AGPL-3.0 licensing. Publish complete corresponding source for every distributed release, including endpoint, management, protocol and worker changes, build scripts, pinned toolchains/dependencies and license notices. Release metadata must identify the exact immutable public source tag. Keep the source revisions for packaged rendezvous/relay components public in the transport manifest. -The corresponding-source link in every release must point to the immutable public Git tag that produced that binary. A private source copy offered only on request is not the selected release model for this project. The public server address and Ed25519 public key may appear in source; they do not grant access without Tailscale authorization and a device-specific Swan password. +Each company hosts its own deployment. Company names, logos, colors and public bootstrap/profile trust information customize the product; they do not grant technician access. The configurable product uses enrollment, group authorization, signed session grants and receiving-device enforcement. New packages do not require Tailscale or collect reusable legacy support passwords. -Never publish: +Never publish company private transport/configuration keys, device credentials, technician/admin tokens, TOTP seeds, signing private keys/PFX passwords, backup decryption material, customer data or private operational logs. Installers carry public bootstrap information only. Machine-specific test/deployment settings and all test credentials belong in ignored local environment/data files. -- the RustDesk server's private `id_ed25519` key; -- customer device IDs/passwords or customer data; -- Tailscale auth keys, API/OAuth secrets, or exported session credentials; -- code-signing keys, PFX files, passwords, or recovery material; or -- production logs, backup contents, or support-session recordings. +Project-signed binaries retain the certificate publisher's embedded identity. Company branding assets do not change that identity. Changing embedded resources requires new signed binaries and the corresponding public source/build description. Never supply a project signing private key to company servers or workers. -The official Tailscale Windows MSI is downloaded separately from Tailscale, is not modified or rebranded, and is not part of Swan's AGPL corresponding source. Tailscale's open-source client code is BSD 3-Clause licensed, while its official Windows product and hosted service have additional licensing and service terms. See [Tailscale deployment](TAILSCALE_DEPLOYMENT.md). +The original single-company Tailscale deployment remains available at `swan-single-company-baseline-1.4.9`; its historical build/deployment documents apply to that baseline only. -Before publishing a release, run a repository-history and artifact secret scan, build from a clean checkout, verify the release hashes and signatures, and confirm the public tag matches the exact source used. Retain upstream RustDesk attribution and the repository's `LICENCE` file. +Before production publication, build from the reviewed public source, verify artifacts and their signatures/hashes, complete the acceptance checks and publish exact corresponding source. Preserve the repository's `LICENCE` and upstream notices. diff --git a/docs/SWAN_CUSTOM_BUILD.md b/docs/SWAN_CUSTOM_BUILD.md index d57cdec8df2..eb9afced882 100644 --- a/docs/SWAN_CUSTOM_BUILD.md +++ b/docs/SWAN_CUSTOM_BUILD.md @@ -1,5 +1,7 @@ # Swan Remote Support build and deployment guide +> Historical guide for the single-company baseline tagged `swan-single-company-baseline-1.4.9`. Its Tailscale/password flow is preserved for migration; it is not the configurable product's installation flow. Use [product/README.md](../product/README.md) for current development builds and deployments. + ## Purpose and current status This branch turns the RustDesk `1.4.9` client into **Swan Remote Support** for Swan Computing. It provides: diff --git a/docs/TAILSCALE_DEPLOYMENT.md b/docs/TAILSCALE_DEPLOYMENT.md index c5251fd135d..b0b3623aa7e 100644 --- a/docs/TAILSCALE_DEPLOYMENT.md +++ b/docs/TAILSCALE_DEPLOYMENT.md @@ -1,5 +1,7 @@ # Tailscale deployment for Swan Remote Support +> Historical baseline deployment only. The configurable product uses company-hosted rendezvous/relay and signed session grants; new packages must not install or require Tailscale. Keep the existing private deployment available until public direct, relay and denied-access tests pass. The pricing and service information below is historical, not current purchasing guidance. + ## Why Tailscale is installed separately Tailscale is largely open source, but it is not one wholly open-source Windows product. The `tailscaled` daemon and CLI are published under the BSD 3-Clause licence; the official Windows graphical client and Tailscale's hosted coordination service include components that are not part of that open-source repository. See Tailscale's [open-source overview](https://tailscale.com/opensource), the [client source and licence](https://github.com/tailscale/tailscale), and the [service terms](https://tailscale.com/terms). diff --git a/flutter/lib/desktop/pages/company_technician_page.dart b/flutter/lib/desktop/pages/company_technician_page.dart index 3bb1b980c4a..63ceb1616c9 100644 --- a/flutter/lib/desktop/pages/company_technician_page.dart +++ b/flutter/lib/desktop/pages/company_technician_page.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'package:flutter_hbb/common.dart'; +import 'package:flutter_hbb/models/platform_model.dart'; /// Company authorization lives in Rust and on the receiver. This view never /// receives a bearer token, signing key or reusable device credential. @@ -76,7 +77,10 @@ class _CompanyTechnicianPageState extends State { final response = await _request({'action': 'connect', 'device_id': device['id'], 'unattended': unattended}) as Map; if (!mounted) return; - await connect(context, response['rustdesk_id'] as String); + // Only a one-use local handle enters the window API; signed grants and + // challenge proof keys stay in Rust memory and are bound to the target. + await connect(context, response['rustdesk_id'] as String, + password: response['ticket_handle'] as String); } @override diff --git a/flutter/lib/desktop/pages/desktop_home_page.dart b/flutter/lib/desktop/pages/desktop_home_page.dart index 86f13c7fd19..46ffdd7b18a 100644 --- a/flutter/lib/desktop/pages/desktop_home_page.dart +++ b/flutter/lib/desktop/pages/desktop_home_page.dart @@ -271,6 +271,20 @@ class _DesktopHomePageState extends State ), ), const SizedBox(height: 12), + Obx(() => TextButton.icon( + onPressed: () async { await start_service(svcStopped.value); }, + icon: Icon(svcStopped.value ? Icons.play_arrow : Icons.stop_circle_outlined), + label: Text(svcStopped.value ? 'Resume support' : 'Stop support'), + )), + if ((company['support_url'] as String? ?? '').isNotEmpty) + TextButton.icon( + onPressed: () async { + final url = Uri.tryParse(company['support_url'] as String); + if (url != null && url.scheme == 'https' && url.host.isNotEmpty && url.userInfo.isEmpty) await launchUrl(url); + }, + icon: const Icon(Icons.support_agent), + label: const Text('Contact support'), + ), if (company['unattended'] == true) TextButton.icon( onPressed: () async { @@ -281,7 +295,7 @@ class _DesktopHomePageState extends State label: const Text('Revoke unattended access'), ), Text( - 'Use the tray icon to view or stop support. Company branding powered by Swan Remote Support ยท AGPL-3.0.', + 'Use the tray icon to view sessions. Powered by Swan Remote Support and RustDesk ยท AGPL-3.0.', style: Theme.of(context).textTheme.bodySmall, ), ], diff --git a/product/agent/src/lib.rs b/product/agent/src/lib.rs index 84e626a1c00..2e969c5f7e4 100644 --- a/product/agent/src/lib.rs +++ b/product/agent/src/lib.rs @@ -53,6 +53,7 @@ impl AgentState { Ok(state) } pub fn save(&self,directory:&Path)->Result<()> { + self.bootstrap.validate()?;self.cached_profile()?; std::fs::create_dir_all(directory)?; #[cfg(unix)] {use std::os::unix::fs::PermissionsExt; std::fs::set_permissions(directory,std::fs::Permissions::from_mode(0o700))?;} let path=directory.join("managed-state.json"); @@ -62,10 +63,20 @@ impl AgentState { let mut saved=self.clone(); if path.exists(){ let existing:AgentState=serde_json::from_slice(&std::fs::read(&path)?)?; + existing.bootstrap.validate()?;let existing_profile=existing.cached_profile()?; ensure!(existing.bootstrap.company_id==self.bootstrap.company_id,"Cannot change company ownership"); + ensure!(existing.bootstrap.edition==self.bootstrap.edition,"Cannot change installed edition"); + ensure!(existing.bootstrap.release_public_key==self.bootstrap.release_public_key,"Cannot silently replace the release trust key"); + ensure!(existing.bootstrap.profile_public_key==self.bootstrap.profile_public_key || existing_profile.next_profile_public_key.as_ref()==Some(&self.bootstrap.profile_public_key),"Profile key rotation requires the previously trusted signed profile"); ensure!(existing.accepted_revision<=self.accepted_revision,"A newer profile has already been saved"); saved.last_release_sequence=saved.last_release_sequence.max(existing.last_release_sequence); - if existing.device_id.is_some() && !existing.unattended_consent {saved.unattended_consent=false;} + if let Some(id)=existing.device_id.as_ref(){ + ensure!(self.device_id.as_ref().map(|value|value==id).unwrap_or(true),"Cannot replace enrolled device identity"); + ensure!(self.device_token.as_ref().map(|value|Some(value)==existing.device_token.as_ref()).unwrap_or(true),"Cannot replace enrolled device credential"); + if self.device_id.is_none(){saved.unattended_consent=existing.unattended_consent;} + if !existing.unattended_consent {saved.unattended_consent=false;} + saved.device_id=existing.device_id;saved.device_token=existing.device_token; + } } let temporary=directory.join(format!("state-{}.tmp",random_token())); use std::io::Write; @@ -139,6 +150,7 @@ impl AgentState { } pub async fn request_grant(&self,token:&str,device:&str,unattended:bool,key:&SigningKey)->Result { ensure!(self.bootstrap.edition==Edition::Technician,"Technician edition required"); + self.company_profile()?; let grant:SignedEnvelope=self.client()?.post(self.endpoint("grants")?).bearer_auth(token).json(&json!({"device_id":device,"proof_public_key":STANDARD.encode(key.verifying_key().as_bytes()),"unattended":unattended})).send().await?.error_for_status()?.json().await?; let parsed:SessionGrant=grant.verify(&public_key(&self.bootstrap.profile_public_key)?)?; parsed.validate(&self.bootstrap.company_id,device,&parsed.rustdesk_id,now())?; @@ -159,7 +171,8 @@ impl AgentState { Ok(Lease { grant_id:grant.grant_id,unattended:grant.unattended,expires_at:expires,last_renewed:now(),_activity:activity }) } pub async fn renew(&self,lease:&mut Lease)->Result<()> { - ensure!(!lease.unattended || self.unattended_consent,"Unattended consent revoked"); + let profile=self.company_profile()?; + ensure!(!lease.unattended || (self.unattended_consent && profile.allow_unattended),"Unattended consent or company permission revoked"); let result:Value=self.client()?.post(self.endpoint(&format!("grants/{}/renew",lease.grant_id))?).bearer_auth(self.device_token.as_ref().context("Not enrolled")?).send().await?.error_for_status()?.json().await?; let expires=result["lease_until"].as_u64().context("Missing lease expiry")?; ensure!(expires>now() && expires<=now()+310,"Invalid session lease"); @@ -234,6 +247,32 @@ mod tests { std::fs::remove_dir_all(directory).unwrap(); } #[test] + fn stale_pre_enrollment_refresh_preserves_identity_and_trust() { + let directory=std::env::temp_dir().join(format!("swan-enrollment-{}",random_token())); + let enrolled=fixture();let mut before=enrolled.clone();before.device_id=None;before.device_token=None;before.unattended_consent=false; + before.save(&directory).unwrap();enrolled.save(&directory).unwrap();before.save(&directory).unwrap(); + let current=AgentState::load(&directory).unwrap();assert_eq!(current.device_id,enrolled.device_id);assert_eq!(current.device_token,enrolled.device_token);assert!(current.unattended_consent); + let mut replacement=current.clone();replacement.device_id=Some("different-device".into());assert!(replacement.save(&directory).is_err()); + let mut replacement=current.clone();replacement.device_token=Some(random_token());assert!(replacement.save(&directory).is_err()); + let mut replacement=current.clone();replacement.bootstrap.edition=Edition::Technician;assert!(replacement.save(&directory).is_err()); + let mut replacement=current.clone();replacement.bootstrap.release_public_key=STANDARD.encode(SigningKey::from_bytes(&[8;32]).verifying_key().as_bytes());assert!(replacement.save(&directory).is_err()); + let mut replacement=current.clone();let key=SigningKey::from_bytes(&[9;32]);replacement.bootstrap.profile_public_key=STANDARD.encode(key.verifying_key().as_bytes());replacement.profile=SignedEnvelope::sign(¤t.company_profile().unwrap(),&key).unwrap();assert!(replacement.save(&directory).is_err()); + assert_eq!(AgentState::load(&directory).unwrap().device_token,enrolled.device_token); + std::fs::remove_dir_all(directory).unwrap(); + } + #[test] + fn trusted_profile_rotation_preserves_enrollment_and_rejects_rollback() { + let directory=std::env::temp_dir().join(format!("swan-rotation-{}",random_token())); + let mut state=fixture();let next=SigningKey::from_bytes(&[10;32]);let next_public=STANDARD.encode(next.verifying_key().as_bytes()); + let mut profile=state.company_profile().unwrap();profile.next_profile_public_key=Some(next_public.clone()); + state.profile=SignedEnvelope::sign(&profile,&SigningKey::from_bytes(&[7;32])).unwrap();state.save(&directory).unwrap();let old=state.clone(); + profile.revision+=1;profile.next_profile_public_key=None;profile.management_url="https://new-support.example.com".into(); + state.bootstrap.profile_public_key=next_public.clone();state.bootstrap.management_url=profile.management_url.clone();state.accepted_revision=profile.revision;state.profile=SignedEnvelope::sign(&profile,&next).unwrap(); + state.save(&directory).unwrap();let loaded=AgentState::load(&directory).unwrap(); + assert_eq!(loaded.bootstrap.profile_public_key,next_public);assert_eq!(loaded.bootstrap.management_url,profile.management_url);assert_eq!(loaded.device_id,old.device_id);assert_eq!(loaded.device_token,old.device_token);assert_eq!(loaded.unattended_consent,old.unattended_consent); + assert!(old.save(&directory).is_err());std::fs::remove_dir_all(directory).unwrap(); + } + #[test] fn expired_cache_only_allows_refresh_and_never_unsigned_policy() { let directory=std::env::temp_dir().join(format!("swan-expired-{}",random_token()));let mut state=fixture(); let mut profile=state.company_profile().unwrap();profile.expires_at=now()-1; diff --git a/product/agent/src/main.rs b/product/agent/src/main.rs index 89991bdfb8a..9d307b52ef5 100644 --- a/product/agent/src/main.rs +++ b/product/agent/src/main.rs @@ -75,6 +75,16 @@ async fn main()->Result<()> { let devices=state.technician_inventory(&token).await?; println!("{}",serde_json::to_string_pretty(&devices)?); } + "history"=>{ + let state=AgentState::load(&directory)?; + let token=std::env::var("SWAN_TECHNICIAN_TOKEN").context("Set authenticated technician token")?; + println!("{}",serde_json::to_string_pretty(&state.technician_history(&token).await?)?); + } + "logout"=>{ + let state=AgentState::load_for_refresh(&directory)?; + let token=std::env::var("SWAN_TECHNICIAN_TOKEN").context("Set authenticated technician token")?; + state.technician_logout(&token).await?;println!("Technician session signed out."); + } "connect"=>{ let state=AgentState::load(&directory)?;let device=args.get(2).context("Usage: swan-agent connect DEVICE_ID APP_EXE [--unattended]")?; let executable=PathBuf::from(args.get(3).context("Missing app executable")?); @@ -88,7 +98,7 @@ async fn main()->Result<()> { .env("SWAN_SESSION_PROOF_KEY",hex::encode(proof.to_bytes())).spawn()?; ensure!(child.wait()?.success(),"Technician application exited unsuccessfully"); } - _=>{println!("Swan Remote Support configuration agent\nCommands: setup, enroll, sync, watch, update, revoke-unattended, login, devices, connect\nState directory: {}",directory.display());if command!="help"{bail!("Unknown command");}} + _=>{println!("Swan Remote Support configuration agent\nCommands: setup, enroll, sync, watch, update, revoke-unattended, login, devices, history, logout, connect\nState directory: {}",directory.display());if command!="help"{bail!("Unknown command");}} } Ok(()) } diff --git a/src/client.rs b/src/client.rs index d7750e94e01..0d93cf04294 100644 --- a/src/client.rs +++ b/src/client.rs @@ -3483,9 +3483,9 @@ pub async fn handle_hash( #[cfg(feature = "swan_custom")] { let target = lc.read().unwrap().id.clone(); - match crate::managed::login(&hash.challenge, &target) { + match crate::managed::login(&hash.challenge, &target, password_preset) { Ok(proof) => send_login(lc, String::new(), String::new(), proof, peer).await, - Err(_) => interface.msgbox("error", "Company authorization required", "Start this session from the authenticated technician launcher.", ""), + Err(_) => interface.msgbox("error", "Company authorization required", "Sign in and request a new session from the company technician device list.", ""), } return; } diff --git a/src/flutter_ffi.rs b/src/flutter_ffi.rs index 2fb76208ce4..c2f8c336180 100644 --- a/src/flutter_ffi.rs +++ b/src/flutter_ffi.rs @@ -2810,8 +2810,8 @@ pub fn main_get_printer_names() -> SyncReturn { return SyncReturn("".to_owned()); } -/// Responses contain display data only. Login tokens and grant proof keys stay -/// in Rust process memory, shared by this app's Flutter window engines. +/// Login tokens and grant proof keys stay in Rust process memory, shared by +/// this app's Flutter window engines. Connections return one-use local handles. pub fn main_company_request(request:String,sink:StreamSink)->ResultType<()> { #[cfg(feature = "swan_custom")] {return flutter::async_tasks::company_request(request,sink);} diff --git a/src/managed.rs b/src/managed.rs index f0ea5f9086c..1ed75b1ed04 100644 --- a/src/managed.rs +++ b/src/managed.rs @@ -4,7 +4,7 @@ use hbb_common::{config::{self, keys}, ResultType}; use swan_agent::{protocol::{self, Edition, ManagedLogin, SignedEnvelope}, AgentState, Lease}; struct TechnicianLogin {token:String,company:String,username:String} -struct PendingTicket {envelope:SignedEnvelope,key_hex:String,expires_at:u64,company:String} +struct PendingTicket {envelope:SignedEnvelope,key_hex:String,expires_at:u64,company:String,peer:String} #[derive(Default)] struct TechnicianMemory { generation:u64, @@ -76,8 +76,9 @@ async fn technician_request_inner(request:&str)->ResultType { if memory.tickets.len()>=32 {bail!("Too many pending connections");} // The proof is consumed inside native handle_hash, never sent to Dart, // command-line arguments, another app instance or IPC configuration. - memory.tickets.insert(grant.rustdesk_id.clone(),PendingTicket{envelope,key_hex:hex::encode(key.to_bytes()),expires_at:grant.expires_at,company:grant.company_id}); - return Ok(json!({"rustdesk_id":grant.rustdesk_id})); + let handle=format!("SWT1.{}",protocol::random_token()); + memory.tickets.insert(handle.clone(),PendingTicket{envelope,key_hex:hex::encode(key.to_bytes()),expires_at:grant.expires_at,company:grant.company_id,peer:grant.rustdesk_id.clone()}); + return Ok(json!({"rustdesk_id":grant.rustdesk_id,"ticket_handle":handle})); } bail!("Unknown technician action") } @@ -134,7 +135,7 @@ pub async fn renew(lease:&mut Lease)->ResultType<()> { state.renew(lease).await?;Ok(()) } -pub fn login(challenge:&str,target:&str)->ResultType> { +pub fn login(challenge:&str,target:&str,local_ticket:&str)->ResultType> { // Hold the shared activity lock for the outgoing app process. Incoming leases // hold their own locks. The updater acquires the exclusive side before install. static ACTIVITY:std::sync::Mutex>=std::sync::Mutex::new(None); @@ -143,9 +144,14 @@ pub fn login(challenge:&str,target:&str)->ResultType> { drop(activity); let state=AgentState::load(&swan_agent::state_directory())?; if state.bootstrap.edition!=Edition::Technician {hbb_common::bail!("Technician edition required");} - let pending=TECHNICIAN.lock().unwrap().tickets.remove(target); + // The opaque handle identifies one window's pending request. It contains + // no grant or proof key and cannot overwrite another request to this peer. + let pending=if local_ticket.is_empty(){None}else{ + if !local_ticket.starts_with("SWT1."){hbb_common::bail!("Managed ticket required; legacy passwords are disabled");} + Some(TECHNICIAN.lock().unwrap().tickets.remove(local_ticket).ok_or_else(||hbb_common::anyhow::anyhow!("Ticket missing or already consumed"))?) + }; let (envelope,key)=if let Some(ticket)=pending { - if ticket.company!=state.bootstrap.company_id || ticket.expires_at<=protocol::now(){hbb_common::bail!("Expired or wrong-company ticket");} + if ticket.company!=state.bootstrap.company_id || ticket.peer!=target || ticket.expires_at<=protocol::now(){hbb_common::bail!("Expired or wrong-target ticket");} (ticket.envelope,protocol::signing_key_from_hex(&ticket.key_hex)?) }else{ (serde_json::from_str(&std::env::var("SWAN_SESSION_GRANT")?)?,protocol::signing_key_from_hex(&std::env::var("SWAN_SESSION_PROOF_KEY")?)?) From 6480208a86013c814d8541fd3e4291995395fefe Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Thu, 1 Oct 2026 16:09:49 +0200 Subject: [PATCH 007/652] Gate local evaluation guests on verified download completion --- deployment/windows/New-LocalTestVm.ps1 | 37 ++++++++++++++++++++++++++ deployment/windows/VM-TESTING.md | 11 ++++++++ 2 files changed, 48 insertions(+) create mode 100644 deployment/windows/New-LocalTestVm.ps1 diff --git a/deployment/windows/New-LocalTestVm.ps1 b/deployment/windows/New-LocalTestVm.ps1 new file mode 100644 index 00000000000..1e85a489b56 --- /dev/null +++ b/deployment/windows/New-LocalTestVm.ps1 @@ -0,0 +1,37 @@ +param([Parameter(Mandatory=$true)][string]$EnvironmentFile) +$ErrorActionPreference = 'Stop' +# Machine paths belong in an untracked file. Never evaluate its contents as code. +$settings = @{} +$allowed = @('SWAN_VM_LAB_ROOT','SWAN_QEMU_DIRECTORY','SWAN_VM_ISO', + 'SWAN_VM_NAME','SWAN_VM_MEMORY_MIB','SWAN_VM_ACCELERATOR') +foreach ($line in [IO.File]::ReadAllLines([IO.Path]::GetFullPath($EnvironmentFile))) { + if ([string]::IsNullOrWhiteSpace($line) -or $line.TrimStart().StartsWith('#')) { continue } + $parts = $line.Split('=',2) + if ($parts.Length -ne 2 -or $allowed -notcontains $parts[0]) { throw 'Invalid VM environment entry' } + if ($settings.ContainsKey($parts[0])) { throw 'Duplicate VM environment entry' } + $settings[$parts[0]] = $parts[1] +} +foreach ($key in $allowed) { + if ([string]::IsNullOrWhiteSpace($settings[$key])) { throw "Missing VM setting: $key" } +} +$iso = Get-Item -LiteralPath $settings['SWAN_VM_ISO'] +$completionPath = $iso.FullName + '.download-complete.json' +if (-not (Test-Path -LiteralPath $completionPath -PathType Leaf)) { + throw 'Evaluation ISO has no completed-download record; wait for a successful download before creating the guest' +} +$completion = Get-Content -LiteralPath $completionPath -Raw | ConvertFrom-Json +if ($completion.bytes -ne $iso.Length -or $completion.sha256 -notmatch '^[a-fA-F0-9]{64}$') { + throw 'Invalid evaluation ISO completion record' +} +if ((Get-FileHash -LiteralPath $iso.FullName -Algorithm SHA256).Hash -ne $completion.sha256) { + throw 'Evaluation ISO changed after download completion' +} +$parameters = @{ + QemuDirectory = $settings['SWAN_QEMU_DIRECTORY'] + IsoPath = $settings['SWAN_VM_ISO'] + LabDirectory = $settings['SWAN_VM_LAB_ROOT'] + Name = $settings['SWAN_VM_NAME'] + MemoryMiB = [int]$settings['SWAN_VM_MEMORY_MIB'] + Accelerator = $settings['SWAN_VM_ACCELERATOR'] +} +& (Join-Path $PSScriptRoot 'New-TestVm.ps1') @parameters diff --git a/deployment/windows/VM-TESTING.md b/deployment/windows/VM-TESTING.md index 174033501b4..29cb604bf80 100644 --- a/deployment/windows/VM-TESTING.md +++ b/deployment/windows/VM-TESTING.md @@ -14,6 +14,13 @@ provide full guest access; close QEMU when the guest is not under test. `New-TestVm.ps1` requires an already verified QEMU installation and ISO. It refuses existing VM directories. Prefer WHPX after checking it starts on the host; TCG is a slower fallback. The host must retain 1.5 GiB available memory. +`New-LocalTestVm.ps1 -EnvironmentFile PATH_TO_PRIVATE_ENV` reads the six +`SWAN_VM_*` settings from the private environment file without executing them. +It requires `ISO_PATH.download-complete.json` containing `bytes` and `sha256`, +recorded only after the official HTTPS download exits successfully. It verifies +the recorded size and hash before creating a guest. This record proves local +download integrity; it does not replace verification of the Microsoft origin. +Do not write the completion record while a downloader is still running. Windows 11 also requires supported UEFI/Secure Boot and TPM configuration: the generic starter script is for Server guests and does not establish Windows 11 compatibility. Do not bypass Windows 11 hardware requirements for acceptance. @@ -43,3 +50,7 @@ Official media starting points: Evaluation expiration or missing supported media must be reported as an unverified matrix row, never as a passing compatibility test. + +The first local WHPX probe reported `Unexpected VP exit code 4`; the isolated +guest was stopped. Its sparse disk is preserved outside Git. No operating +system installation or accelerator compatibility is established by that probe. From 675148d15ada7429aa0103e9708bce5022d2da46 Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Thu, 1 Oct 2026 16:11:58 +0200 Subject: [PATCH 008/652] Prevent automatic updates from reinstalling removed endpoints --- deployment/windows/LOCAL-TESTING.md | 2 +- docs/ACCEPTANCE.md | 4 ++-- product/agent/src/update.rs | 30 ++++++++++++++++++++++++++--- 3 files changed, 30 insertions(+), 6 deletions(-) diff --git a/deployment/windows/LOCAL-TESTING.md b/deployment/windows/LOCAL-TESTING.md index 9cdac0e180e..ed839cea530 100644 --- a/deployment/windows/LOCAL-TESTING.md +++ b/deployment/windows/LOCAL-TESTING.md @@ -8,4 +8,4 @@ The default local administration address is http://127.0.0.1:18080. Use the one- This loopback HTTP listener is for administration smoke tests. Endpoint enrollment and profile synchronization require HTTPS with a trusted certificate. `local-https.js` provides an isolated loopback proxy using PFX/password settings from the ignored environment file. Debug agents accept a DER test certificate through `SWAN_TEST_CA_FILE`; release builds ignore this variable and use operating-system trust roots. Certificate validation remains enabled. Public direct and relay testing requires a company-owned reachable endpoint and a separate external test machine. -Verified on this development machine: management starts, administration responds with HTTP 200, and thirteen product-workspace tests pass. `node deployment/windows/test-company-lifecycle.js PATH_TO_PRIVATE_ENV_FILE` also passed against a fresh HTTPS proxy and newly built agent/server: company setup, device enrollment, approval denial, technician MFA/group inventory and session history, logout credential revocation, consent denial, grant replay denial, signed branding sync and device revocation. The script records executable hashes in the protected data directory and refuses an already configured company. For another run, create a separate empty data directory and environment file, choose unused loopback ports, and copy the public DER test certificate into that directory as `localhost.cer`; preserve earlier databases and results. This API integration uses a test device ID and does not demonstrate native remote desktop. The native Flutter/RustDesk integration, production signatures, complete installers, update installation/recovery and clean-machine Windows compatibility remain release requirements; this development build is not a production release. +Verified on this development machine: management starts, administration responds with HTTP 200, and fourteen product-workspace tests pass. `node deployment/windows/test-company-lifecycle.js PATH_TO_PRIVATE_ENV_FILE` also passed against a fresh HTTPS proxy and newly built agent/server: company setup, device enrollment, approval denial, technician MFA/group inventory and session history, logout credential revocation, consent denial, grant replay denial, signed branding sync and device revocation. The script records executable hashes in the protected data directory and refuses an already configured company. For another run, create a separate empty data directory and environment file, choose unused loopback ports, and copy the public DER test certificate into that directory as `localhost.cer`; preserve earlier databases and results. This API integration uses a test device ID and does not demonstrate native remote desktop. The native Flutter/RustDesk integration, production signatures, complete installers, update installation/recovery and clean-machine Windows compatibility remain release requirements; this development build is not a production release. diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index 5f30d4ab4bf..06de669dfec 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -8,8 +8,8 @@ native support session or a clean-machine installation. - Public baseline tag `swan-single-company-baseline-1.4.9` preserves the original Swan deployment and build instructions. Its source was not replaced. -- Thirteen product tests pass on the local Windows development host, including - enrollment/trust preservation across stale writes and trusted key rotation. +- Fourteen product tests pass on the local Windows development host, including + enrollment/trust preservation across stale writes, trusted key rotation, and refusal to automatically reinstall a removed endpoint. The preceding eleven-test component source passed tests and release compilation on [Windows 2022 and Ubuntu 24.04 CI](https://github.com/rijff24/SwanRemoteSupport/actions/runs/36856716860) at commit `411269b`. diff --git a/product/agent/src/update.rs b/product/agent/src/update.rs index eb1c81e09d6..97af1ac70b1 100644 --- a/product/agent/src/update.rs +++ b/product/agent/src/update.rs @@ -9,6 +9,18 @@ use std::path::Path; #[serde(deny_unknown_fields)] struct Receipt { release:SignedEnvelope, previous_sequence:u64, phase:String } +fn require_existing_application(target:&Path)->Result<()> { + ensure!(target.is_file(),"Application was removed; automatic updates cannot reinstall it. Run explicit company setup or repair."); + Ok(()) +} + +#[cfg(windows)] +fn installed_target(directory:&Path,edition:&Edition)->Result { + if *edition==Edition::Technician {Ok(directory.join("SwanRemoteSupport-Technician.exe"))}else{ + Ok(std::path::PathBuf::from(std::env::var_os("ProgramFiles").context("ProgramFiles unavailable")?).join("Swan Remote Support/Swan Remote Support.exe")) + } +} + pub fn validate_release(state:&AgentState,envelope:&SignedEnvelope)->Result { let profile=state.company_profile()?; let release:Release=envelope.verify(&public_key(&state.bootstrap.release_public_key)?)?; @@ -97,6 +109,7 @@ impl AgentState { pub async fn update(&mut self,directory:&Path,technician_token:Option<&str>)->Result { #[cfg(not(windows))] {let _=(directory,technician_token);anyhow::bail!("Endpoint updates require Windows");} #[cfg(windows)] { + require_existing_application(&installed_target(directory,&self.bootstrap.edition)?)?; let profile=self.company_profile()?; if profile.updates_paused || !maintenance_open(profile.maintenance_start_utc,profile.maintenance_end_utc,now()){return Ok(false);} let receipt_path=directory.join("pending-update.json"); @@ -118,6 +131,8 @@ impl AgentState { fs2::FileExt::try_lock_exclusive(&activity).context("Update deferred while a session or technician app is active")?; // Re-read consent and enrollment immediately before installation. let mut latest=AgentState::load(directory)?; + // Removal during download must not be turned into a fresh install. + require_existing_application(&installed_target(directory,&latest.bootstrap.edition)?)?; validate_release(&latest,&envelope)?; let receipt=Receipt{release:envelope,previous_sequence:latest.last_release_sequence,phase:"installing".into()}; let mut options=std::fs::OpenOptions::new();options.create_new(true).write(true); @@ -146,9 +161,7 @@ impl AgentState { #[cfg(windows)] fn verify_installed(directory:&Path,release:&Release)->Result<()> { - let target=if release.edition==Edition::Technician {directory.join("SwanRemoteSupport-Technician.exe")}else{ - std::path::PathBuf::from(std::env::var_os("ProgramFiles").context("ProgramFiles unavailable")?).join("Swan Remote Support/Swan Remote Support.exe") - }; + let target=installed_target(directory,&release.edition)?; ensure!(digest(std::fs::read(&target)?).eq_ignore_ascii_case(&release.installed_sha256),"Installed executable differs from signed release; recovery remains pending"); let mut installed=release.clone();installed.sha256=release.installed_sha256.clone();verify_publisher(&target,&installed) } @@ -157,6 +170,17 @@ fn verify_installed(directory:&Path,release:&Release)->Result<()> { mod tests { use super::*; #[test] + fn removed_application_requires_explicit_setup_instead_of_automatic_reinstall() { + let folder=std::env::temp_dir().join(format!("swan-removed-{}",random_token())); + std::fs::create_dir(&folder).unwrap();let target=folder.join("endpoint.exe"); + assert!(require_existing_application(&target).is_err()); + std::fs::write(&target,b"installed test fixture").unwrap(); + assert!(require_existing_application(&target).is_ok()); + std::fs::remove_file(&target).unwrap(); + assert!(require_existing_application(&target).is_err()); + std::fs::remove_dir(folder).unwrap(); + } + #[test] fn update_lock_excludes_sessions_and_releases_on_close() { let folder=std::env::temp_dir().join(format!("swan-lock-{}",random_token())); let session=lock_session(&folder).unwrap();let update=activity_file(&folder).unwrap(); From dccbef473f12a430e47191a42e0ed90a30450233 Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Thu, 1 Oct 2026 16:15:12 +0200 Subject: [PATCH 009/652] Version explicit customer consent to preserve revocation and renewed opt-in --- deployment/windows/LOCAL-TESTING.md | 2 +- docs/ACCEPTANCE.md | 4 +-- product/agent/src/lib.rs | 38 ++++++++++++++++++++++++++--- product/agent/src/main.rs | 10 ++++++-- src/ipc.rs | 5 ++-- 5 files changed, 47 insertions(+), 12 deletions(-) diff --git a/deployment/windows/LOCAL-TESTING.md b/deployment/windows/LOCAL-TESTING.md index ed839cea530..ee38af94358 100644 --- a/deployment/windows/LOCAL-TESTING.md +++ b/deployment/windows/LOCAL-TESTING.md @@ -8,4 +8,4 @@ The default local administration address is http://127.0.0.1:18080. Use the one- This loopback HTTP listener is for administration smoke tests. Endpoint enrollment and profile synchronization require HTTPS with a trusted certificate. `local-https.js` provides an isolated loopback proxy using PFX/password settings from the ignored environment file. Debug agents accept a DER test certificate through `SWAN_TEST_CA_FILE`; release builds ignore this variable and use operating-system trust roots. Certificate validation remains enabled. Public direct and relay testing requires a company-owned reachable endpoint and a separate external test machine. -Verified on this development machine: management starts, administration responds with HTTP 200, and fourteen product-workspace tests pass. `node deployment/windows/test-company-lifecycle.js PATH_TO_PRIVATE_ENV_FILE` also passed against a fresh HTTPS proxy and newly built agent/server: company setup, device enrollment, approval denial, technician MFA/group inventory and session history, logout credential revocation, consent denial, grant replay denial, signed branding sync and device revocation. The script records executable hashes in the protected data directory and refuses an already configured company. For another run, create a separate empty data directory and environment file, choose unused loopback ports, and copy the public DER test certificate into that directory as `localhost.cer`; preserve earlier databases and results. This API integration uses a test device ID and does not demonstrate native remote desktop. The native Flutter/RustDesk integration, production signatures, complete installers, update installation/recovery and clean-machine Windows compatibility remain release requirements; this development build is not a production release. +Verified on this development machine: management starts, administration responds with HTTP 200, and fifteen product-workspace tests pass. `node deployment/windows/test-company-lifecycle.js PATH_TO_PRIVATE_ENV_FILE` also passed against a fresh HTTPS proxy and newly built agent/server: company setup, device enrollment, approval denial, technician MFA/group inventory and session history, logout credential revocation, consent denial, grant replay denial, signed branding sync and device revocation. The script records executable hashes in the protected data directory and refuses an already configured company. For another run, create a separate empty data directory and environment file, choose unused loopback ports, and copy the public DER test certificate into that directory as `localhost.cer`; preserve earlier databases and results. This API integration uses a test device ID and does not demonstrate native remote desktop. The native Flutter/RustDesk integration, production signatures, complete installers, update installation/recovery and clean-machine Windows compatibility remain release requirements; this development build is not a production release. diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index 06de669dfec..9089b62b283 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -8,8 +8,8 @@ native support session or a clean-machine installation. - Public baseline tag `swan-single-company-baseline-1.4.9` preserves the original Swan deployment and build instructions. Its source was not replaced. -- Fourteen product tests pass on the local Windows development host, including - enrollment/trust preservation across stale writes, trusted key rotation, and refusal to automatically reinstall a removed endpoint. +- Fifteen product tests pass on the local Windows development host, including + enrollment/trust preservation across stale writes, trusted key rotation, refusal to automatically reinstall a removed endpoint, and explicit consent changes surviving older refreshes. The preceding eleven-test component source passed tests and release compilation on [Windows 2022 and Ubuntu 24.04 CI](https://github.com/rijff24/SwanRemoteSupport/actions/runs/36856716860) at commit `411269b`. diff --git a/product/agent/src/lib.rs b/product/agent/src/lib.rs index 2e969c5f7e4..8e37c30af0d 100644 --- a/product/agent/src/lib.rs +++ b/product/agent/src/lib.rs @@ -37,6 +37,8 @@ pub struct AgentState { pub device_id:Option, pub device_token:Option, pub unattended_consent:bool, + #[serde(default)] + pub consent_revision:u64, pub last_release_sequence:u64, } impl AgentState { @@ -53,6 +55,16 @@ impl AgentState { Ok(state) } pub fn save(&self,directory:&Path)->Result<()> { + self.save_internal(directory,None) + } + pub fn set_local_consent(&mut self,directory:&Path,enabled:bool)->Result<()> { + ensure!(self.bootstrap.edition==Edition::Customer,"Customer consent is required"); + ensure!(self.device_id.is_some(),"Device enrollment required"); + ensure!(!enabled || self.company_profile()?.allow_unattended,"Company disallows unattended support"); + self.save_internal(directory,Some(enabled))?; + *self=Self::load_for_refresh(directory)?;Ok(()) + } + fn save_internal(&self,directory:&Path,consent_change:Option)->Result<()> { self.bootstrap.validate()?;self.cached_profile()?; std::fs::create_dir_all(directory)?; #[cfg(unix)] {use std::os::unix::fs::PermissionsExt; std::fs::set_permissions(directory,std::fs::Permissions::from_mode(0o700))?;} @@ -71,10 +83,16 @@ impl AgentState { ensure!(existing.accepted_revision<=self.accepted_revision,"A newer profile has already been saved"); saved.last_release_sequence=saved.last_release_sequence.max(existing.last_release_sequence); if let Some(id)=existing.device_id.as_ref(){ + ensure!(consent_change.is_some() || self.consent_revision<=existing.consent_revision,"Consent revision changes require explicit customer consent"); ensure!(self.device_id.as_ref().map(|value|value==id).unwrap_or(true),"Cannot replace enrolled device identity"); ensure!(self.device_token.as_ref().map(|value|Some(value)==existing.device_token.as_ref()).unwrap_or(true),"Cannot replace enrolled device credential"); - if self.device_id.is_none(){saved.unattended_consent=existing.unattended_consent;} - if !existing.unattended_consent {saved.unattended_consent=false;} + if self.device_id.is_none() || self.consent_revisionResult { let profile:SignedEnvelope=client.get(format!("{}/api/v1/profile",input.management_url.trim_end_matches('/'))).send().await?.error_for_status()?.json().await?; let parsed:CompanyProfile=profile.verify(&public_key(&input.profile_public_key)?)?; parsed.validate(&input.company_id,0,now())?; - Ok(AgentState {bootstrap:input,profile,accepted_revision:parsed.revision,device_id:None,device_token:None,unattended_consent:false,last_release_sequence:0}) + Ok(AgentState {bootstrap:input,profile,accepted_revision:parsed.revision,device_id:None,device_token:None,unattended_consent:false,consent_revision:0,last_release_sequence:0}) } pub fn http_client(timeout_seconds:u64,artifact_redirects:bool)->Result { @@ -234,7 +252,19 @@ mod tests { fn fixture()->AgentState { let key=SigningKey::from_bytes(&[7;32]);let public=STANDARD.encode(key.verifying_key().as_bytes()); let profile=CompanyProfile{schema:1,company_id:"test-company".into(),revision:1,issued_at:now()-1000,expires_at:now()+3600,management_url:"https://support.example.com".into(),rendezvous:"support.example.com".into(),relay:"support.example.com".into(),transport_public_key:public.clone(),customer:Branding::default(),technician:Branding::default(),allow_unattended:true,updates_paused:true,rollout_percent:100,maintenance_start_utc:0,maintenance_end_utc:0,update_channel:"test".into(),next_profile_public_key:None}; - AgentState{bootstrap:Bootstrap{schema:1,edition:Edition::Customer,company_id:profile.company_id.clone(),management_url:profile.management_url.clone(),profile_public_key:public.clone(),release_public_key:public},profile:SignedEnvelope::sign(&profile,&key).unwrap(),accepted_revision:1,device_id:Some("device".into()),device_token:Some(random_token()),unattended_consent:true,last_release_sequence:0} + AgentState{bootstrap:Bootstrap{schema:1,edition:Edition::Customer,company_id:profile.company_id.clone(),management_url:profile.management_url.clone(),profile_public_key:public.clone(),release_public_key:public},profile:SignedEnvelope::sign(&profile,&key).unwrap(),accepted_revision:1,device_id:Some("device".into()),device_token:Some(random_token()),unattended_consent:true,consent_revision:0,last_release_sequence:0} + } + #[test] + fn explicit_consent_changes_survive_older_refreshes() { + let directory=std::env::temp_dir().join(format!("swan-consent-{}",random_token())); + let mut state=fixture();state.save(&directory).unwrap();let old_allowed=state.clone(); + state.set_local_consent(&directory,false).unwrap();let old_revoked=state.clone(); + old_allowed.save(&directory).unwrap();assert!(!AgentState::load(&directory).unwrap().unattended_consent); + state.set_local_consent(&directory,true).unwrap();old_revoked.save(&directory).unwrap(); + let current=AgentState::load(&directory).unwrap();assert!(current.unattended_consent);assert_eq!(current.consent_revision,2); + state.set_local_consent(&directory,false).unwrap();current.save(&directory).unwrap(); + assert!(!AgentState::load(&directory).unwrap().unattended_consent); + std::fs::remove_dir_all(directory).unwrap(); } #[test] fn stale_refresh_cannot_restore_consent_or_replay_update_state() { diff --git a/product/agent/src/main.rs b/product/agent/src/main.rs index 9d307b52ef5..8bed0a88a42 100644 --- a/product/agent/src/main.rs +++ b/product/agent/src/main.rs @@ -56,9 +56,15 @@ async fn main()->Result<()> { println!("Installer metadata, bytes and publisher verified."); } "revoke-unattended"=>{ - let mut state=AgentState::load(&directory)?;state.unattended_consent=false;state.save(&directory)?; + let mut state=AgentState::load_for_refresh(&directory)?;state.set_local_consent(&directory,false)?; match state.consent(false).await {Ok(())=>println!("Unattended access revoked locally and on the server."),Err(_)=>println!("Unattended access revoked locally. Server synchronization will retry when available.")} } + "allow-unattended"=>{ + ensure!(args.iter().any(|value|value=="--confirm-unattended"),"Explicit --confirm-unattended consent is required"); + let mut state=AgentState::load(&directory)?; + state.consent(true).await?;state.set_local_consent(&directory,true)?; + println!("Unattended support enabled by explicit customer consent."); + } "login"=>{ let state=AgentState::load(&directory)?; ensure!(state.bootstrap.edition==Edition::Technician,"Technician edition required"); @@ -98,7 +104,7 @@ async fn main()->Result<()> { .env("SWAN_SESSION_PROOF_KEY",hex::encode(proof.to_bytes())).spawn()?; ensure!(child.wait()?.success(),"Technician application exited unsuccessfully"); } - _=>{println!("Swan Remote Support configuration agent\nCommands: setup, enroll, sync, watch, update, revoke-unattended, login, devices, history, logout, connect\nState directory: {}",directory.display());if command!="help"{bail!("Unknown command");}} + _=>{println!("Swan Remote Support configuration agent\nCommands: setup, enroll, sync, watch, update, revoke-unattended, allow-unattended --confirm-unattended, login, devices, history, logout, connect\nState directory: {}",directory.display());if command!="help"{bail!("Unknown command");}} } Ok(()) } diff --git a/src/ipc.rs b/src/ipc.rs index 3d3064ad96b..d5a31a12650 100644 --- a/src/ipc.rs +++ b/src/ipc.rs @@ -886,10 +886,9 @@ async fn handle(data: Data, stream: &mut Connection) { Some(value) => { #[cfg(feature = "swan_custom")] if name == "company-revoke-unattended" && value == "N" { - match swan_agent::AgentState::load(&swan_agent::state_directory()) { + match swan_agent::AgentState::load_for_refresh(&swan_agent::state_directory()) { Ok(mut state) => { - state.unattended_consent = false; - match state.save(&swan_agent::state_directory()) { + match state.set_local_consent(&swan_agent::state_directory(),false) { Ok(()) => { // Revocation is already durable locally even if the API is offline. if let Err(error) = state.consent(false).await { From 3016c1903b0590baf9f4332ff02c35cf8bd5d565 Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Thu, 1 Oct 2026 16:17:48 +0200 Subject: [PATCH 010/652] Expose explicit unattended opt-in through customer confirmation --- .../lib/desktop/pages/desktop_home_page.dart | 24 +++++++++++++++++++ src/flutter_ffi.rs | 6 ++--- src/ipc.rs | 14 +++++++++++ src/managed.rs | 2 +- 4 files changed, 42 insertions(+), 4 deletions(-) diff --git a/flutter/lib/desktop/pages/desktop_home_page.dart b/flutter/lib/desktop/pages/desktop_home_page.dart index 46ffdd7b18a..5d56cc384d0 100644 --- a/flutter/lib/desktop/pages/desktop_home_page.dart +++ b/flutter/lib/desktop/pages/desktop_home_page.dart @@ -294,6 +294,30 @@ class _DesktopHomePageState extends State icon: const Icon(Icons.shield_outlined), label: const Text('Revoke unattended access'), ), + if (isReady && company['unattended'] != true && company['allow_unattended'] == true) + TextButton.icon( + onPressed: () async { + final accepted = await showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: const Text('Allow unattended support?'), + content: Text('Approved technicians from ${company['display_name']} can connect while you are absent, including at the Windows sign-in screen. You can revoke this permission here at any time.\n\nCompany server: ${company['domain']}\n\n${company['consent_text'] ?? ''}'), + actions: [ + TextButton(onPressed: () => Navigator.of(dialogContext).pop(false), child: const Text('Cancel')), + TextButton(onPressed: () => Navigator.of(dialogContext).pop(true), child: const Text('Allow ongoing support')), + ], + ), + ); + if (accepted != true || !mounted) return; + await bind.mainSetCommon(key: 'company-allow-unattended', value: 'Y'); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Consent change requested. Unattended access stays disabled until the company accepts it.'))); + setState(() {}); + } + }, + icon: const Icon(Icons.shield_outlined), + label: const Text('Allow unattended support'), + ), Text( 'Use the tray icon to view sessions. Powered by Swan Remote Support and RustDesk ยท AGPL-3.0.', style: Theme.of(context).textTheme.bodySmall, diff --git a/src/flutter_ffi.rs b/src/flutter_ffi.rs index c2f8c336180..775e7e0c24e 100644 --- a/src/flutter_ffi.rs +++ b/src/flutter_ffi.rs @@ -2914,9 +2914,9 @@ pub fn main_get_common_sync(key: String) -> SyncReturn { pub fn main_set_common(_key: String, _value: String) { #[cfg(feature = "swan_custom")] - if _key == "company-revoke-unattended" && _value == "N" { - if let Err(error) = crate::ipc::set_config("company-revoke-unattended", "N".into()) { - log::error!("Cannot revoke company unattended support: {}", error); + if (_key == "company-revoke-unattended" && _value == "N") || (_key == "company-allow-unattended" && _value == "Y") { + if let Err(error) = crate::ipc::set_config(&_key, _value) { + log::error!("Cannot change company unattended support: {}", error); } return; } diff --git a/src/ipc.rs b/src/ipc.rs index d5a31a12650..413cd0c9f44 100644 --- a/src/ipc.rs +++ b/src/ipc.rs @@ -884,6 +884,20 @@ async fn handle(data: Data, stream: &mut Connection) { allow_err!(stream.send(&Data::Config((name, value))).await); } Some(value) => { + #[cfg(feature = "swan_custom")] + if name == "company-allow-unattended" && value == "Y" { + let directory=swan_agent::state_directory(); + match swan_agent::AgentState::load(&directory) { + Ok(mut state)=>{ + match state.consent(true).await { + Ok(())=>if let Err(error)=state.set_local_consent(&directory,true){log::error!("Cannot persist explicit unattended consent: {}",error);}, + Err(error)=>log::warn!("Company did not accept unattended consent: {}",error), + } + } + Err(error)=>log::error!("Cannot load current company policy: {}",error), + } + return; + } #[cfg(feature = "swan_custom")] if name == "company-revoke-unattended" && value == "N" { match swan_agent::AgentState::load_for_refresh(&swan_agent::state_directory()) { diff --git a/src/managed.rs b/src/managed.rs index 1ed75b1ed04..b72d4dbf43b 100644 --- a/src/managed.rs +++ b/src/managed.rs @@ -168,7 +168,7 @@ pub fn overview()->String { Ok(state)=>match state.display_profile(){ Ok(profile)=>{ let brand=if state.bootstrap.edition==Edition::Technician{&profile.technician}else{&profile.customer}; - serde_json::json!({"configured":true,"profile_valid":state.company_profile().is_ok(),"edition":state.bootstrap.edition,"enrolled":state.device_id.is_some(),"unattended":state.unattended_consent,"display_name":brand.display_name,"primary_color":brand.primary_color,"logo_svg":brand.logo_svg,"support_url":brand.support_url,"consent_text":brand.consent_text,"domain":profile.management_url}).to_string() + serde_json::json!({"configured":true,"profile_valid":state.company_profile().is_ok(),"edition":state.bootstrap.edition,"enrolled":state.device_id.is_some(),"unattended":state.unattended_consent,"allow_unattended":profile.allow_unattended,"display_name":brand.display_name,"primary_color":brand.primary_color,"logo_svg":brand.logo_svg,"support_url":brand.support_url,"consent_text":brand.consent_text,"domain":profile.management_url}).to_string() } Err(_)=>"{\"configured\":false}".into() }, From 95e50c5a22a8c797fd1093504853b6cc7b47a252 Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Thu, 1 Oct 2026 16:19:51 +0200 Subject: [PATCH 011/652] Resume preserved acceptance guests with verified media and fixed options --- deployment/windows/New-LocalTestVm.ps1 | 5 ++-- deployment/windows/Start-TestVm.ps1 | 37 ++++++++++++++++++++++++++ deployment/windows/VM-TESTING.md | 5 ++++ 3 files changed, 45 insertions(+), 2 deletions(-) create mode 100644 deployment/windows/Start-TestVm.ps1 diff --git a/deployment/windows/New-LocalTestVm.ps1 b/deployment/windows/New-LocalTestVm.ps1 index 1e85a489b56..874924bc19d 100644 --- a/deployment/windows/New-LocalTestVm.ps1 +++ b/deployment/windows/New-LocalTestVm.ps1 @@ -1,4 +1,4 @@ -param([Parameter(Mandatory=$true)][string]$EnvironmentFile) +param([Parameter(Mandatory=$true)][string]$EnvironmentFile,[switch]$Resume) $ErrorActionPreference = 'Stop' # Machine paths belong in an untracked file. Never evaluate its contents as code. $settings = @{} @@ -34,4 +34,5 @@ $parameters = @{ MemoryMiB = [int]$settings['SWAN_VM_MEMORY_MIB'] Accelerator = $settings['SWAN_VM_ACCELERATOR'] } -& (Join-Path $PSScriptRoot 'New-TestVm.ps1') @parameters +$script = if ($Resume) { 'Start-TestVm.ps1' } else { 'New-TestVm.ps1' } +& (Join-Path $PSScriptRoot $script) @parameters diff --git a/deployment/windows/Start-TestVm.ps1 b/deployment/windows/Start-TestVm.ps1 new file mode 100644 index 00000000000..8ec7517be8b --- /dev/null +++ b/deployment/windows/Start-TestVm.ps1 @@ -0,0 +1,37 @@ +param( + [Parameter(Mandatory=$true)][string]$QemuDirectory, + [Parameter(Mandatory=$true)][string]$IsoPath, + [Parameter(Mandatory=$true)][string]$LabDirectory, + [ValidatePattern('^[a-z0-9-]+$')][string]$Name = 'server-2025', + [ValidateSet('whpx','tcg')][string]$Accelerator = 'tcg', + [ValidateRange(2048,8192)][int]$MemoryMiB = 4096 +) +$ErrorActionPreference = 'Stop' +$lab = [IO.Path]::GetFullPath($LabDirectory) +$vm = [IO.Path]::GetFullPath((Join-Path $lab $Name)) +if (-not $vm.StartsWith($lab.TrimEnd('\')+'\',[StringComparison]::OrdinalIgnoreCase)) { + throw 'VM directory must stay within the test lab' +} +$disk = Join-Path $vm 'system.qcow2' +$qemu = Join-Path $QemuDirectory 'qemu-system-x86_64.exe' +foreach ($required in @($disk,$qemu,$IsoPath)) { + if (-not (Test-Path -LiteralPath $required -PathType Leaf)) { throw "Missing file: $required" } +} +$iso = Get-Item -LiteralPath $IsoPath +$completionPath = $iso.FullName + '.download-complete.json' +if (-not (Test-Path -LiteralPath $completionPath -PathType Leaf)) { throw 'Missing ISO completion record' } +$completion = Get-Content -LiteralPath $completionPath -Raw | ConvertFrom-Json +if ($completion.bytes -ne $iso.Length -or $completion.sha256 -notmatch '^[a-fA-F0-9]{64}$') { throw 'Invalid ISO completion record' } +if ((Get-FileHash -LiteralPath $iso.FullName -Algorithm SHA256).Hash -ne $completion.sha256) { throw 'ISO changed after download completion' } +if ((Get-CimInstance Win32_OperatingSystem).FreePhysicalMemory / 1024 -lt ($MemoryMiB + 1536)) { throw 'Insufficient memory for the guest and host reserve' } +$running = Get-CimInstance Win32_Process -Filter "Name='qemu-system-x86_64.exe'" +if ($running | Where-Object { $_.CommandLine -and $_.CommandLine.Contains($disk) }) { throw 'Guest disk is already in use' } +# Reconstruct fixed arguments; saved metadata must never supply arbitrary QEMU options. +$arguments = @('-name',$Name,'-machine','q35','-accel',$Accelerator, + '-m',$MemoryMiB.ToString(),'-smp','2','-drive',"file=$disk,format=qcow2", + '-cdrom',$iso.FullName,'-boot','order=d','-nic','user,model=e1000', + '-display','none','-vnc','127.0.0.1:20','-monitor','tcp:127.0.0.1:4444,server=on,wait=off') +[ordered]@{ accelerator=$Accelerator;started_utc=[DateTime]::UtcNow.ToString('o') } | + ConvertTo-Json | Set-Content -LiteralPath (Join-Path $vm 'last-start.json') -Encoding UTF8 +& $qemu @arguments +if ($LASTEXITCODE -ne 0) { throw "QEMU exited with code $LASTEXITCODE" } diff --git a/deployment/windows/VM-TESTING.md b/deployment/windows/VM-TESTING.md index 29cb604bf80..1f68713b8c2 100644 --- a/deployment/windows/VM-TESTING.md +++ b/deployment/windows/VM-TESTING.md @@ -21,6 +21,11 @@ recorded only after the official HTTPS download exits successfully. It verifies the recorded size and hash before creating a guest. This record proves local download integrity; it does not replace verification of the Microsoft origin. Do not write the completion record while a downloader is still running. +For a preserved guest, add `-Resume` and set `SWAN_VM_ACCELERATOR=tcg` in the +private environment file to try the software-emulation fallback. The resume +helper verifies the ISO again, retains the existing disk, reconstructs fixed +loopback-only QEMU options, and refuses a disk already in use. TCG performance +and successful Windows installation still require actual verification. Windows 11 also requires supported UEFI/Secure Boot and TPM configuration: the generic starter script is for Server guests and does not establish Windows 11 compatibility. Do not bypass Windows 11 hardware requirements for acceptance. From 6ec7e72ce7183d21cb8fe66028564e68b05f0d64 Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Thu, 1 Oct 2026 16:22:52 +0200 Subject: [PATCH 012/652] Validate existing enrollment during company installer repair --- product/agent/src/lib.rs | 9 ++++++++- product/management/src/service.rs | 8 ++++++++ product/management/src/tests.rs | 5 +++++ 3 files changed, 21 insertions(+), 1 deletion(-) diff --git a/product/agent/src/lib.rs b/product/agent/src/lib.rs index 8e37c30af0d..b396437619b 100644 --- a/product/agent/src/lib.rs +++ b/product/agent/src/lib.rs @@ -150,8 +150,15 @@ impl AgentState { } pub async fn enroll(&mut self,name:&str,rustdesk_id:&str,consent:bool)->Result<()> { ensure!(self.bootstrap.edition==Edition::Customer,"Only customer edition enrolls devices"); - ensure!(self.device_id.is_none(),"Already enrolled"); let profile=self.company_profile()?; + if let Some(device)=self.device_id.as_ref() { + // Repair validates existing ownership instead of issuing new identity + // or changing consent merely because setup was run again. + let status:Value=self.client()?.get(self.endpoint("device/status")?).bearer_auth(self.device_token.as_ref().context("Missing enrolled credential")?).send().await?.error_for_status()?.json().await?; + ensure!(status["company_id"].as_str()==Some(self.bootstrap.company_id.as_str()) && status["device_id"].as_str()==Some(device.as_str()) && status["rustdesk_id"].as_str()==Some(rustdesk_id),"Repair cannot replace company enrollment or transport identity"); + ensure!(matches!(status["state"].as_str(),Some("pending"|"approved")),"Device requires company administrator intervention"); + return Ok(()); + } ensure!(!consent || profile.allow_unattended,"Company has disabled unattended access"); let result:Value=self.client()?.post(self.endpoint("enroll")?).json(&json!({"name":name,"rustdesk_id":rustdesk_id,"unattended_consent":consent})).send().await?.error_for_status()?.json().await?; ensure!(result["company_id"].as_str()==Some(self.bootstrap.company_id.as_str()),"Wrong company enrollment response"); diff --git a/product/management/src/service.rs b/product/management/src/service.rs index 087d4cdbdd4..d76034b9ac4 100644 --- a/product/management/src/service.rs +++ b/product/management/src/service.rs @@ -130,6 +130,7 @@ pub fn router(store:Shared)->Router { .route("/api/v1/devices",get(devices)) .route("/api/v1/devices/{id}/state",put(device_state)) .route("/api/v1/device/consent",put(consent)) + .route("/api/v1/device/status",get(device_status)) .route("/api/v1/groups/{group}/users/{user}",put(group_access)) .route("/api/v1/grants",post(grant)) .route("/api/v1/grants/claim",post(claim)) @@ -273,6 +274,13 @@ async fn device_state(State(s):State,headers:HeaderMap,Path(id):Path,headers:HeaderMap)->ApiResult { + // Pending devices may inspect only their own identity, never session APIs. + let device:String=s.db.lock().unwrap().query_row("SELECT id FROM devices WHERE token_hash=?1 AND state IN ('pending','approved')",[digest(token(&headers)?)],|row|row.get(0)).optional()?.ok_or_else(unauthorized)?; + let company=s.profile()?.company_id; + let value=s.db.lock().unwrap().query_row("SELECT rustdesk_id,state FROM devices WHERE id=?1",[&device],|row|Ok(json!({"company_id":company,"device_id":device,"rustdesk_id":row.get::<_,String>(0)?,"state":row.get::<_,String>(1)?})))?; + Ok(Json(value)) +} async fn consent(State(s):State,headers:HeaderMap,Json(input):Json)->ApiResult { let device=s.device(&headers)?; if input.unattended && !s.profile()?.allow_unattended {return Err(denied());} diff --git a/product/management/src/tests.rs b/product/management/src/tests.rs index d79b9792ef5..3f6e9905cf8 100644 --- a/product/management/src/tests.rs +++ b/product/management/src/tests.rs @@ -42,6 +42,10 @@ async fn enrollment_mfa_and_grants_fail_closed() { assert_eq!(request(&app,"/api/v1/login","POST",None,login).await.0,StatusCode::UNAUTHORIZED,"MFA codes are single-use"); let (_,device)=request(&app,"/api/v1/enroll","POST",None,json!({"name":"Customer test PC","rustdesk_id":"123456789","unattended_consent":false})).await; let device_id=device["device_id"].as_str().unwrap();let credential=device["device_token"].as_str().unwrap(); + let (status,identity)=request(&app,"/api/v1/device/status","GET",Some(credential),Value::Null).await; + assert_eq!(status,StatusCode::OK);assert_eq!(identity["device_id"],device_id);assert_eq!(identity["rustdesk_id"],"123456789");assert_eq!(identity["state"],"pending"); + let (status,_)=request(&app,"/api/v1/device/status","GET",Some("wrong-device-token"),Value::Null).await; + assert_eq!(status,StatusCode::UNAUTHORIZED); let proof=SigningKey::from_bytes(&[9;32]);let grant_request=json!({"device_id":device_id,"proof_public_key":STANDARD.encode(proof.verifying_key().as_bytes()),"unattended":false}); assert_eq!(request(&app,"/api/v1/grants","POST",Some(admin),grant_request.clone()).await.0,StatusCode::FORBIDDEN,"Pending devices cannot be accessed"); assert_eq!(request(&app,&format!("/api/v1/devices/{device_id}/state"),"PUT",Some(admin),json!({"state":"approved","group":"customers"})).await.0,StatusCode::OK); @@ -64,6 +68,7 @@ async fn enrollment_mfa_and_grants_fail_closed() { assert_eq!(request(&app,&format!("/api/v1/groups/customers/users/{}",new_user["id"].as_str().unwrap()),"PUT",Some(admin),Value::Null).await.0,StatusCode::OK); let (_,devices)=request(&app,"/api/v1/devices","GET",Some(technician),Value::Null).await;assert_eq!(devices.as_array().unwrap().len(),1); assert_eq!(request(&app,&format!("/api/v1/devices/{device_id}/state"),"PUT",Some(admin),json!({"state":"revoked","group":"customers"})).await.0,StatusCode::OK); + assert_eq!(request(&app,"/api/v1/device/status","GET",Some(credential),Value::Null).await.0,StatusCode::UNAUTHORIZED,"Repair cannot restore a revoked device"); assert_eq!(request(&app,"/api/v1/grants","POST",Some(admin),json!({"device_id":device_id,"proof_public_key":STANDARD.encode(proof.verifying_key().as_bytes()),"unattended":false})).await.0,StatusCode::FORBIDDEN); drop(app);drop(store);std::fs::remove_dir_all(directory).unwrap(); } From bb60c988e0ad2f59d8a35f306871506ede841a3d Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Thu, 1 Oct 2026 16:25:21 +0200 Subject: [PATCH 013/652] Verify company bootstrap before installer execution and repair --- deployment/windows/Install-Company.ps1 | 2 ++ deployment/windows/LOCAL-TESTING.md | 2 +- docs/ACCEPTANCE.md | 2 +- product/agent/src/lib.rs | 16 ++++++++++++++++ product/agent/src/main.rs | 8 +++++++- 5 files changed, 27 insertions(+), 3 deletions(-) diff --git a/deployment/windows/Install-Company.ps1 b/deployment/windows/Install-Company.ps1 index ce69e079707..256aff6a858 100644 --- a/deployment/windows/Install-Company.ps1 +++ b/deployment/windows/Install-Company.ps1 @@ -32,6 +32,8 @@ if (-not (Test-Path -LiteralPath (Join-Path $editionDirectory 'managed-state.jso & $agent setup (Join-Path $root 'bootstrap.json') --accept-company if ($LASTEXITCODE -ne 0) { throw 'Company configuration verification failed.' } } +& $agent verify-bootstrap (Join-Path $root 'bootstrap.json') +if ($LASTEXITCODE -ne 0) { throw 'Installer does not match the configured company, edition or trust keys.' } $installers = @(Get-ChildItem -LiteralPath $root -File | Where-Object { $_.Name -match '^SwanRemoteSupport-install\.(exe|msi)$' }) if ($installers.Count -ne 1) { throw 'Expected one company installer.' } & $agent verify-package (Join-Path $root 'release.json') $installers[0].FullName diff --git a/deployment/windows/LOCAL-TESTING.md b/deployment/windows/LOCAL-TESTING.md index ee38af94358..70b190c037a 100644 --- a/deployment/windows/LOCAL-TESTING.md +++ b/deployment/windows/LOCAL-TESTING.md @@ -8,4 +8,4 @@ The default local administration address is http://127.0.0.1:18080. Use the one- This loopback HTTP listener is for administration smoke tests. Endpoint enrollment and profile synchronization require HTTPS with a trusted certificate. `local-https.js` provides an isolated loopback proxy using PFX/password settings from the ignored environment file. Debug agents accept a DER test certificate through `SWAN_TEST_CA_FILE`; release builds ignore this variable and use operating-system trust roots. Certificate validation remains enabled. Public direct and relay testing requires a company-owned reachable endpoint and a separate external test machine. -Verified on this development machine: management starts, administration responds with HTTP 200, and fifteen product-workspace tests pass. `node deployment/windows/test-company-lifecycle.js PATH_TO_PRIVATE_ENV_FILE` also passed against a fresh HTTPS proxy and newly built agent/server: company setup, device enrollment, approval denial, technician MFA/group inventory and session history, logout credential revocation, consent denial, grant replay denial, signed branding sync and device revocation. The script records executable hashes in the protected data directory and refuses an already configured company. For another run, create a separate empty data directory and environment file, choose unused loopback ports, and copy the public DER test certificate into that directory as `localhost.cer`; preserve earlier databases and results. This API integration uses a test device ID and does not demonstrate native remote desktop. The native Flutter/RustDesk integration, production signatures, complete installers, update installation/recovery and clean-machine Windows compatibility remain release requirements; this development build is not a production release. +Verified on this development machine: management starts, administration responds with HTTP 200, and sixteen product-workspace tests pass. `node deployment/windows/test-company-lifecycle.js PATH_TO_PRIVATE_ENV_FILE` also passed against a fresh HTTPS proxy and newly built agent/server: company setup, device enrollment, approval denial, technician MFA/group inventory and session history, logout credential revocation, consent denial, grant replay denial, signed branding sync and device revocation. The script records executable hashes in the protected data directory and refuses an already configured company. For another run, create a separate empty data directory and environment file, choose unused loopback ports, and copy the public DER test certificate into that directory as `localhost.cer`; preserve earlier databases and results. This API integration uses a test device ID and does not demonstrate native remote desktop. The native Flutter/RustDesk integration, production signatures, complete installers, update installation/recovery and clean-machine Windows compatibility remain release requirements; this development build is not a production release. diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index 9089b62b283..060c204c39f 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -8,7 +8,7 @@ native support session or a clean-machine installation. - Public baseline tag `swan-single-company-baseline-1.4.9` preserves the original Swan deployment and build instructions. Its source was not replaced. -- Fifteen product tests pass on the local Windows development host, including +- Sixteen product tests pass on the local Windows development host, including enrollment/trust preservation across stale writes, trusted key rotation, refusal to automatically reinstall a removed endpoint, and explicit consent changes surviving older refreshes. The preceding eleven-test component source passed tests and release compilation on [Windows 2022 and Ubuntu 24.04 CI](https://github.com/rijff24/SwanRemoteSupport/actions/runs/36856716860) diff --git a/product/agent/src/lib.rs b/product/agent/src/lib.rs index b396437619b..e70099c0557 100644 --- a/product/agent/src/lib.rs +++ b/product/agent/src/lib.rs @@ -42,6 +42,13 @@ pub struct AgentState { pub last_release_sequence:u64, } impl AgentState { + pub fn validate_installer_bootstrap(&self,input:&Bootstrap)->Result<()> { + input.validate()?;let profile=self.company_profile()?; + ensure!(input.company_id==self.bootstrap.company_id && input.edition==self.bootstrap.edition,"Installer belongs to another company or edition"); + ensure!(input.profile_public_key==self.bootstrap.profile_public_key && input.release_public_key==self.bootstrap.release_public_key,"Installer cannot replace configured trust keys"); + ensure!(input.management_url.trim_end_matches('/')==profile.management_url.trim_end_matches('/'),"Installer endpoint differs from trusted company configuration"); + Ok(()) + } pub fn load(directory:&Path)->Result { let state=Self::load_for_refresh(directory)?; state.company_profile()?; @@ -262,6 +269,15 @@ mod tests { AgentState{bootstrap:Bootstrap{schema:1,edition:Edition::Customer,company_id:profile.company_id.clone(),management_url:profile.management_url.clone(),profile_public_key:public.clone(),release_public_key:public},profile:SignedEnvelope::sign(&profile,&key).unwrap(),accepted_revision:1,device_id:Some("device".into()),device_token:Some(random_token()),unattended_consent:true,consent_revision:0,last_release_sequence:0} } #[test] + fn repair_bootstrap_cannot_change_company_edition_or_trust() { + let state=fixture();let original=state.bootstrap.clone(); + assert!(state.validate_installer_bootstrap(&original).is_ok()); + let mut changed=original.clone();changed.company_id="other-company".into();assert!(state.validate_installer_bootstrap(&changed).is_err()); + let mut changed=original.clone();changed.edition=Edition::Technician;assert!(state.validate_installer_bootstrap(&changed).is_err()); + let mut changed=original.clone();changed.management_url="https://other.example.com".into();assert!(state.validate_installer_bootstrap(&changed).is_err()); + let mut changed=original;changed.profile_public_key=STANDARD.encode(SigningKey::from_bytes(&[12;32]).verifying_key().as_bytes());assert!(state.validate_installer_bootstrap(&changed).is_err()); + } + #[test] fn explicit_consent_changes_survive_older_refreshes() { let directory=std::env::temp_dir().join(format!("swan-consent-{}",random_token())); let mut state=fixture();state.save(&directory).unwrap();let old_allowed=state.clone(); diff --git a/product/agent/src/main.rs b/product/agent/src/main.rs index 8bed0a88a42..93dc99c1853 100644 --- a/product/agent/src/main.rs +++ b/product/agent/src/main.rs @@ -28,6 +28,12 @@ async fn main()->Result<()> { state.enroll(name,peer,args.iter().any(|a|a=="--unattended-consent")).await?;state.save(&directory)?; println!("Device enrolled. Company approval is required before remote access."); } + "verify-bootstrap"=>{ + let file=args.get(2).context("Usage: swan-agent verify-bootstrap bootstrap.json")?; + let input:Bootstrap=serde_json::from_slice(&std::fs::read(file)?)?; + AgentState::load(&directory)?.validate_installer_bootstrap(&input)?; + println!("Installer company and trust match existing configuration."); + } "sync"=>{let mut state=AgentState::load_for_refresh(&directory)?;state.sync().await?;state.save(&directory)?;println!("Profile synchronized.");} "watch"=>{loop {match AgentState::load_for_refresh(&directory){Ok(mut state)=>{ match state.sync().await{Ok(())=>state.save(&directory)?,Err(error)=>eprintln!("Profile refresh failed: {error}")}; @@ -104,7 +110,7 @@ async fn main()->Result<()> { .env("SWAN_SESSION_PROOF_KEY",hex::encode(proof.to_bytes())).spawn()?; ensure!(child.wait()?.success(),"Technician application exited unsuccessfully"); } - _=>{println!("Swan Remote Support configuration agent\nCommands: setup, enroll, sync, watch, update, revoke-unattended, allow-unattended --confirm-unattended, login, devices, history, logout, connect\nState directory: {}",directory.display());if command!="help"{bail!("Unknown command");}} + _=>{println!("Swan Remote Support configuration agent\nCommands: setup, verify-bootstrap, enroll, sync, watch, update, revoke-unattended, allow-unattended --confirm-unattended, login, devices, history, logout, connect\nState directory: {}",directory.display());if command!="help"{bail!("Unknown command");}} } Ok(()) } From be5612488b3780eb8022952f810854ef8d5cf084 Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Thu, 1 Oct 2026 16:30:39 +0200 Subject: [PATCH 014/652] Supply required Server 2025 instructions in emulated test CPU --- deployment/windows/New-TestVm.ps1 | 2 +- deployment/windows/Start-TestVm.ps1 | 2 +- deployment/windows/VM-TESTING.md | 8 ++++++++ 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/deployment/windows/New-TestVm.ps1 b/deployment/windows/New-TestVm.ps1 index 25a92664fca..70780ed3040 100644 --- a/deployment/windows/New-TestVm.ps1 +++ b/deployment/windows/New-TestVm.ps1 @@ -26,7 +26,7 @@ $disk = Join-Path $vm 'system.qcow2' & $qemuImg create -f qcow2 $disk ($DiskGiB.ToString()+'G') if ($LASTEXITCODE -ne 0) { throw 'QEMU disk creation failed' } $arguments = @('-name',$Name,'-machine','q35','-accel',$Accelerator, - '-m',$MemoryMiB.ToString(),'-smp','2','-drive',"file=$disk,format=qcow2", + '-cpu','max','-m',$MemoryMiB.ToString(),'-smp','2','-drive',"file=$disk,format=qcow2", '-cdrom',[IO.Path]::GetFullPath($IsoPath),'-boot','order=d', '-nic','user,model=e1000','-display','none', '-vnc','127.0.0.1:20','-monitor','tcp:127.0.0.1:4444,server=on,wait=off') diff --git a/deployment/windows/Start-TestVm.ps1 b/deployment/windows/Start-TestVm.ps1 index 8ec7517be8b..7f3ace40bba 100644 --- a/deployment/windows/Start-TestVm.ps1 +++ b/deployment/windows/Start-TestVm.ps1 @@ -28,7 +28,7 @@ $running = Get-CimInstance Win32_Process -Filter "Name='qemu-system-x86_64.exe'" if ($running | Where-Object { $_.CommandLine -and $_.CommandLine.Contains($disk) }) { throw 'Guest disk is already in use' } # Reconstruct fixed arguments; saved metadata must never supply arbitrary QEMU options. $arguments = @('-name',$Name,'-machine','q35','-accel',$Accelerator, - '-m',$MemoryMiB.ToString(),'-smp','2','-drive',"file=$disk,format=qcow2", + '-cpu','max','-m',$MemoryMiB.ToString(),'-smp','2','-drive',"file=$disk,format=qcow2", '-cdrom',$iso.FullName,'-boot','order=d','-nic','user,model=e1000', '-display','none','-vnc','127.0.0.1:20','-monitor','tcp:127.0.0.1:4444,server=on,wait=off') [ordered]@{ accelerator=$Accelerator;started_utc=[DateTime]::UtcNow.ToString('o') } | diff --git a/deployment/windows/VM-TESTING.md b/deployment/windows/VM-TESTING.md index 1f68713b8c2..4b4b010159d 100644 --- a/deployment/windows/VM-TESTING.md +++ b/deployment/windows/VM-TESTING.md @@ -14,6 +14,10 @@ provide full guest access; close QEMU when the guest is not under test. `New-TestVm.ps1` requires an already verified QEMU installation and ISO. It refuses existing VM directories. Prefer WHPX after checking it starts on the host; TCG is a slower fallback. The host must retain 1.5 GiB available memory. +The scripts select QEMU's `max` CPU model. A local QMP capability probe confirmed +SSE4.2 and POPCNT are exposed by that model and absent from the default `qemu64` +model. Server 2025 requires these instructions; this supplies supported emulated +instructions rather than bypassing the operating system's hardware checks. `New-LocalTestVm.ps1 -EnvironmentFile PATH_TO_PRIVATE_ENV` reads the six `SWAN_VM_*` settings from the private environment file without executing them. It requires `ISO_PATH.download-complete.json` containing `bytes` and `sha256`, @@ -59,3 +63,7 @@ unverified matrix row, never as a passing compatibility test. The first local WHPX probe reported `Unexpected VP exit code 4`; the isolated guest was stopped. Its sparse disk is preserved outside Git. No operating system installation or accelerator compatibility is established by that probe. +The official Server 2025 ISO subsequently downloaded successfully and its size +and SHA-256 were recorded privately. A TCG guest reached the Windows boot loader. +The initial TCG attempt used the insufficient default CPU and was restarted +with the corrected model. No completed Windows installation is recorded yet. From 544edcbe58b34575c26b769fdd621ca7fcefd62a Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Thu, 1 Oct 2026 16:34:27 +0200 Subject: [PATCH 015/652] Add documented WHPX interrupt fallback for acceptance lab --- deployment/windows/New-TestVm.ps1 | 8 +++++--- deployment/windows/Start-TestVm.ps1 | 8 +++++--- deployment/windows/VM-TESTING.md | 8 +++++++- 3 files changed, 17 insertions(+), 7 deletions(-) diff --git a/deployment/windows/New-TestVm.ps1 b/deployment/windows/New-TestVm.ps1 index 70780ed3040..50649ef5128 100644 --- a/deployment/windows/New-TestVm.ps1 +++ b/deployment/windows/New-TestVm.ps1 @@ -3,7 +3,7 @@ param( [Parameter(Mandatory=$true)][string]$IsoPath, [Parameter(Mandatory=$true)][string]$LabDirectory, [ValidatePattern('^[a-z0-9-]+$')][string]$Name = 'server-2025', - [ValidateSet('whpx','tcg')][string]$Accelerator = 'whpx', + [ValidateSet('whpx','whpx-userspace','tcg')][string]$Accelerator = 'whpx', [ValidateRange(2048,8192)][int]$MemoryMiB = 4096, [ValidateRange(32,128)][int]$DiskGiB = 64 ) @@ -25,8 +25,10 @@ New-Item -ItemType Directory -Path $vm | Out-Null $disk = Join-Path $vm 'system.qcow2' & $qemuImg create -f qcow2 $disk ($DiskGiB.ToString()+'G') if ($LASTEXITCODE -ne 0) { throw 'QEMU disk creation failed' } -$arguments = @('-name',$Name,'-machine','q35','-accel',$Accelerator, - '-cpu','max','-m',$MemoryMiB.ToString(),'-smp','2','-drive',"file=$disk,format=qcow2", +$acceleratorOption = if ($Accelerator -eq 'whpx-userspace') { 'whpx,hyperv=off,kernel-irqchip=off' } else { $Accelerator } +$cpuModel = if ($Accelerator -eq 'tcg') { 'max' } else { 'host' } +$arguments = @('-name',$Name,'-machine','q35','-accel',$acceleratorOption, + '-cpu',$cpuModel,'-m',$MemoryMiB.ToString(),'-smp','2','-drive',"file=$disk,format=qcow2", '-cdrom',[IO.Path]::GetFullPath($IsoPath),'-boot','order=d', '-nic','user,model=e1000','-display','none', '-vnc','127.0.0.1:20','-monitor','tcp:127.0.0.1:4444,server=on,wait=off') diff --git a/deployment/windows/Start-TestVm.ps1 b/deployment/windows/Start-TestVm.ps1 index 7f3ace40bba..77d62980527 100644 --- a/deployment/windows/Start-TestVm.ps1 +++ b/deployment/windows/Start-TestVm.ps1 @@ -3,7 +3,7 @@ param( [Parameter(Mandatory=$true)][string]$IsoPath, [Parameter(Mandatory=$true)][string]$LabDirectory, [ValidatePattern('^[a-z0-9-]+$')][string]$Name = 'server-2025', - [ValidateSet('whpx','tcg')][string]$Accelerator = 'tcg', + [ValidateSet('whpx','whpx-userspace','tcg')][string]$Accelerator = 'tcg', [ValidateRange(2048,8192)][int]$MemoryMiB = 4096 ) $ErrorActionPreference = 'Stop' @@ -27,8 +27,10 @@ if ((Get-CimInstance Win32_OperatingSystem).FreePhysicalMemory / 1024 -lt ($Memo $running = Get-CimInstance Win32_Process -Filter "Name='qemu-system-x86_64.exe'" if ($running | Where-Object { $_.CommandLine -and $_.CommandLine.Contains($disk) }) { throw 'Guest disk is already in use' } # Reconstruct fixed arguments; saved metadata must never supply arbitrary QEMU options. -$arguments = @('-name',$Name,'-machine','q35','-accel',$Accelerator, - '-cpu','max','-m',$MemoryMiB.ToString(),'-smp','2','-drive',"file=$disk,format=qcow2", +$acceleratorOption = if ($Accelerator -eq 'whpx-userspace') { 'whpx,hyperv=off,kernel-irqchip=off' } else { $Accelerator } +$cpuModel = if ($Accelerator -eq 'tcg') { 'max' } else { 'host' } +$arguments = @('-name',$Name,'-machine','q35','-accel',$acceleratorOption, + '-cpu',$cpuModel,'-m',$MemoryMiB.ToString(),'-smp','2','-drive',"file=$disk,format=qcow2", '-cdrom',$iso.FullName,'-boot','order=d','-nic','user,model=e1000', '-display','none','-vnc','127.0.0.1:20','-monitor','tcp:127.0.0.1:4444,server=on,wait=off') [ordered]@{ accelerator=$Accelerator;started_utc=[DateTime]::UtcNow.ToString('o') } | diff --git a/deployment/windows/VM-TESTING.md b/deployment/windows/VM-TESTING.md index 4b4b010159d..2df4ae3e665 100644 --- a/deployment/windows/VM-TESTING.md +++ b/deployment/windows/VM-TESTING.md @@ -14,10 +14,13 @@ provide full guest access; close QEMU when the guest is not under test. `New-TestVm.ps1` requires an already verified QEMU installation and ISO. It refuses existing VM directories. Prefer WHPX after checking it starts on the host; TCG is a slower fallback. The host must retain 1.5 GiB available memory. -The scripts select QEMU's `max` CPU model. A local QMP capability probe confirmed +For TCG, the scripts select QEMU's `max` CPU model. A local QMP capability probe confirmed SSE4.2 and POPCNT are exposed by that model and absent from the default `qemu64` model. Server 2025 requires these instructions; this supplies supported emulated instructions rather than bypassing the operating system's hardware checks. +WHPX uses the host CPU model. `SWAN_VM_ACCELERATOR=whpx-userspace` selects the +documented `whpx,hyperv=off,kernel-irqchip=off` variant for diagnosing the observed +interrupt-backend failure; it is not evidence that acceleration works. `New-LocalTestVm.ps1 -EnvironmentFile PATH_TO_PRIVATE_ENV` reads the six `SWAN_VM_*` settings from the private environment file without executing them. It requires `ISO_PATH.download-complete.json` containing `bytes` and `sha256`, @@ -67,3 +70,6 @@ The official Server 2025 ISO subsequently downloaded successfully and its size and SHA-256 were recorded privately. A TCG guest reached the Windows boot loader. The initial TCG attempt used the insufficient default CPU and was restarted with the corrected model. No completed Windows installation is recorded yet. +The corrected TCG attempt reached a Windows crash screen reporting +`SYSTEM_SERVICE_EXCEPTION (0x3B)` in `win32kbase.sys`. Its screenshot and disk +remain private. A WHPX userspace-interrupt attempt is pending verification. From e9457922a6c882f87f2def37359cdf0c1014b2e0 Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Thu, 1 Oct 2026 16:38:10 +0200 Subject: [PATCH 016/652] Isolate WHPX XSAVE failures with validated baseline CPU --- deployment/windows/New-TestVm.ps1 | 6 +++--- deployment/windows/Start-TestVm.ps1 | 6 +++--- deployment/windows/VM-TESTING.md | 6 +++++- 3 files changed, 11 insertions(+), 7 deletions(-) diff --git a/deployment/windows/New-TestVm.ps1 b/deployment/windows/New-TestVm.ps1 index 50649ef5128..e9ba1bfa0a6 100644 --- a/deployment/windows/New-TestVm.ps1 +++ b/deployment/windows/New-TestVm.ps1 @@ -3,7 +3,7 @@ param( [Parameter(Mandatory=$true)][string]$IsoPath, [Parameter(Mandatory=$true)][string]$LabDirectory, [ValidatePattern('^[a-z0-9-]+$')][string]$Name = 'server-2025', - [ValidateSet('whpx','whpx-userspace','tcg')][string]$Accelerator = 'whpx', + [ValidateSet('whpx','whpx-userspace','whpx-baseline','tcg')][string]$Accelerator = 'whpx', [ValidateRange(2048,8192)][int]$MemoryMiB = 4096, [ValidateRange(32,128)][int]$DiskGiB = 64 ) @@ -25,8 +25,8 @@ New-Item -ItemType Directory -Path $vm | Out-Null $disk = Join-Path $vm 'system.qcow2' & $qemuImg create -f qcow2 $disk ($DiskGiB.ToString()+'G') if ($LASTEXITCODE -ne 0) { throw 'QEMU disk creation failed' } -$acceleratorOption = if ($Accelerator -eq 'whpx-userspace') { 'whpx,hyperv=off,kernel-irqchip=off' } else { $Accelerator } -$cpuModel = if ($Accelerator -eq 'tcg') { 'max' } else { 'host' } +$acceleratorOption = if ($Accelerator -in @('whpx-userspace','whpx-baseline')) { 'whpx,hyperv=off,kernel-irqchip=off' } else { $Accelerator } +$cpuModel = if ($Accelerator -eq 'whpx-baseline') { 'Westmere-v2' } elseif ($Accelerator -eq 'tcg') { 'max' } else { 'host' } $arguments = @('-name',$Name,'-machine','q35','-accel',$acceleratorOption, '-cpu',$cpuModel,'-m',$MemoryMiB.ToString(),'-smp','2','-drive',"file=$disk,format=qcow2", '-cdrom',[IO.Path]::GetFullPath($IsoPath),'-boot','order=d', diff --git a/deployment/windows/Start-TestVm.ps1 b/deployment/windows/Start-TestVm.ps1 index 77d62980527..8bdadd469bc 100644 --- a/deployment/windows/Start-TestVm.ps1 +++ b/deployment/windows/Start-TestVm.ps1 @@ -3,7 +3,7 @@ param( [Parameter(Mandatory=$true)][string]$IsoPath, [Parameter(Mandatory=$true)][string]$LabDirectory, [ValidatePattern('^[a-z0-9-]+$')][string]$Name = 'server-2025', - [ValidateSet('whpx','whpx-userspace','tcg')][string]$Accelerator = 'tcg', + [ValidateSet('whpx','whpx-userspace','whpx-baseline','tcg')][string]$Accelerator = 'tcg', [ValidateRange(2048,8192)][int]$MemoryMiB = 4096 ) $ErrorActionPreference = 'Stop' @@ -27,8 +27,8 @@ if ((Get-CimInstance Win32_OperatingSystem).FreePhysicalMemory / 1024 -lt ($Memo $running = Get-CimInstance Win32_Process -Filter "Name='qemu-system-x86_64.exe'" if ($running | Where-Object { $_.CommandLine -and $_.CommandLine.Contains($disk) }) { throw 'Guest disk is already in use' } # Reconstruct fixed arguments; saved metadata must never supply arbitrary QEMU options. -$acceleratorOption = if ($Accelerator -eq 'whpx-userspace') { 'whpx,hyperv=off,kernel-irqchip=off' } else { $Accelerator } -$cpuModel = if ($Accelerator -eq 'tcg') { 'max' } else { 'host' } +$acceleratorOption = if ($Accelerator -in @('whpx-userspace','whpx-baseline')) { 'whpx,hyperv=off,kernel-irqchip=off' } else { $Accelerator } +$cpuModel = if ($Accelerator -eq 'whpx-baseline') { 'Westmere-v2' } elseif ($Accelerator -eq 'tcg') { 'max' } else { 'host' } $arguments = @('-name',$Name,'-machine','q35','-accel',$acceleratorOption, '-cpu',$cpuModel,'-m',$MemoryMiB.ToString(),'-smp','2','-drive',"file=$disk,format=qcow2", '-cdrom',$iso.FullName,'-boot','order=d','-nic','user,model=e1000', diff --git a/deployment/windows/VM-TESTING.md b/deployment/windows/VM-TESTING.md index 2df4ae3e665..905781c94b3 100644 --- a/deployment/windows/VM-TESTING.md +++ b/deployment/windows/VM-TESTING.md @@ -72,4 +72,8 @@ The initial TCG attempt used the insufficient default CPU and was restarted with the corrected model. No completed Windows installation is recorded yet. The corrected TCG attempt reached a Windows crash screen reporting `SYSTEM_SERVICE_EXCEPTION (0x3B)` in `win32kbase.sys`. Its screenshot and disk -remain private. A WHPX userspace-interrupt attempt is pending verification. +remain private. The WHPX userspace-interrupt attempt emitted repeated XSAVE +state errors. `SWAN_VM_ACCELERATOR=whpx-baseline` retains that interrupt mode +and selects `Westmere-v2`; a local QMP probe confirmed SSE4.2, POPCNT, CX16 and +NX are present, while XSAVE and AVX are absent. Its actual installation result +remains unverified. No operating system hardware-check bypass is applied. From e937dfa84a3787572bcf6cc9102991d4db8614fe Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Thu, 1 Oct 2026 16:46:03 +0200 Subject: [PATCH 017/652] Bind session capabilities to signed grants and company group policies --- docs/ACCEPTANCE.md | 2 +- product/agent/src/lib.rs | 4 ++-- product/management/src/schema.sql | 1 + product/management/src/service.rs | 19 ++++++++++++++++++- product/management/src/tests.rs | 8 ++++++++ product/protocol/src/lib.rs | 19 ++++++++++++++++++- src/server/connection.rs | 17 ++++++++++++++++- 7 files changed, 64 insertions(+), 6 deletions(-) diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index 060c204c39f..b2723ee252e 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -41,7 +41,7 @@ native support session or a clean-machine installation. | --- | --- | | Reproducible server, customer, technician and worker builds | Product components pass pinned builds. Native x64 build/package remains pending; baseline rebuild is not newly proved. | | Fresh-company Windows/Linux setup | HTTPS/API setup works. Equivalent full setup wizards, transport provisioning, certificate management and reachability checks remain incomplete. | -| Roles, groups, permissions and revocation | MFA/group/device denial tests pass. Granular signed session permission policy and receiver enforcement still require implementation and verification. | +| Roles, groups, permissions and revocation | MFA/group/device denial tests pass. Signed capability fields, administrator group policy APIs, and receiver capability bounds are implemented in source. Policy reduction denies lease renewal in component tests. Administration controls, native enforcement and direct-connection bypass tests remain unverified. | | Configurable customer and technician apps | Signed profile sync passes. Graphical login/inventory/history, logo/color rendering, contacts, company shortcuts, offline operation and native restart/upgrade preservation remain to verify or complete. | | Signed profiles and rotation | Component tests reject tampering, wrong-company, expired and older profiles and untrusted key changes. Trusted cached key/endpoint rotation preserves enrollment in tests. Real HTTPS rotation and installed-app behavior still need verification. | | Approved managed sessions without password/direct bypass | Grant signature, target, challenge and replay checks pass component/API tests. Actual receiving-device enforcement, direct/relay paths, lease expiry and live revocation are unverified. | diff --git a/product/agent/src/lib.rs b/product/agent/src/lib.rs index e70099c0557..3bd0f27c60b 100644 --- a/product/agent/src/lib.rs +++ b/product/agent/src/lib.rs @@ -200,7 +200,7 @@ impl AgentState { let result:Value=self.client()?.post(self.endpoint("grants/claim")?).bearer_auth(self.device_token.as_ref().context("Not enrolled")?).json(&json!({"grant":login.grant})).send().await?.error_for_status()?.json().await?; let expires=result["lease_until"].as_u64().context("Missing lease expiry")?; ensure!(expires>now() && expires<=now()+310,"Invalid session lease"); - Ok(Lease { grant_id:grant.grant_id,unattended:grant.unattended,expires_at:expires,last_renewed:now(),_activity:activity }) + Ok(Lease { grant_id:grant.grant_id,unattended:grant.unattended,permissions:grant.permissions,expires_at:expires,last_renewed:now(),_activity:activity }) } pub async fn renew(&self,lease:&mut Lease)->Result<()> { let profile=self.company_profile()?; @@ -228,7 +228,7 @@ fn replace_state(temporary:&Path,path:&Path)->Result<()> { #[cfg(not(windows))] {std::fs::rename(temporary,path)?;Ok(())} } -pub struct Lease { pub grant_id:String,pub unattended:bool,pub expires_at:u64,pub last_renewed:u64,_activity:std::fs::File } +pub struct Lease { pub grant_id:String,pub unattended:bool,pub permissions:SessionPermissions,pub expires_at:u64,pub last_renewed:u64,_activity:std::fs::File } pub fn activity_file(directory:&Path)->Result { std::fs::create_dir_all(directory)?; Ok(std::fs::OpenOptions::new().create(true).read(true).write(true).open(directory.join("activity.lock"))?) diff --git a/product/management/src/schema.sql b/product/management/src/schema.sql index e43655b1180..1e4e9adcc80 100644 --- a/product/management/src/schema.sql +++ b/product/management/src/schema.sql @@ -8,4 +8,5 @@ CREATE TABLE IF NOT EXISTS audit(id INTEGER PRIMARY KEY,at INTEGER NOT NULL,acto CREATE TABLE IF NOT EXISTS releases(id TEXT PRIMARY KEY,body TEXT NOT NULL,envelope TEXT NOT NULL,approved INTEGER NOT NULL DEFAULT 0); CREATE TABLE IF NOT EXISTS workers(id TEXT PRIMARY KEY,token_hash TEXT NOT NULL UNIQUE,disabled INTEGER NOT NULL DEFAULT 0); CREATE TABLE IF NOT EXISTS builds(id TEXT PRIMARY KEY,body TEXT NOT NULL,state TEXT NOT NULL,worker TEXT NOT NULL DEFAULT '',claimed_at INTEGER NOT NULL DEFAULT 0,result TEXT NOT NULL DEFAULT ''); +CREATE TABLE IF NOT EXISTS group_permissions(group_id TEXT PRIMARY KEY,body TEXT NOT NULL); PRAGMA user_version=1; diff --git a/product/management/src/service.rs b/product/management/src/service.rs index d76034b9ac4..e494a450c8b 100644 --- a/product/management/src/service.rs +++ b/product/management/src/service.rs @@ -131,6 +131,7 @@ pub fn router(store:Shared)->Router { .route("/api/v1/devices/{id}/state",put(device_state)) .route("/api/v1/device/consent",put(consent)) .route("/api/v1/device/status",get(device_status)) + .route("/api/v1/groups/{group}/permissions",get(group_permissions).put(set_group_permissions)) .route("/api/v1/groups/{group}/users/{user}",put(group_access)) .route("/api/v1/grants",post(grant)) .route("/api/v1/grants/claim",post(claim)) @@ -294,6 +295,20 @@ async fn group_access(State(s):State,headers:HeaderMap,Path((group,user) } #[derive(Deserialize)] struct GrantRequest { device_id:String, proof_public_key:String, unattended:bool } +fn device_permissions(db:&Connection,device:&str)->Result { + let raw:Option=db.query_row("SELECT p.body FROM devices d LEFT JOIN group_permissions p ON p.group_id=d.group_id WHERE d.id=?1",[device],|row|row.get(0))?; + Ok(match raw {Some(value)=>serde_json::from_str(&value)?,None=>SessionPermissions::support_default()}) +} +async fn group_permissions(State(s):State,headers:HeaderMap,Path(group):Path)->ApiResult { + s.user(&headers,true)?; + let raw:Option=s.db.lock().unwrap().query_row("SELECT body FROM group_permissions WHERE group_id=?1",[group],|row|row.get(0)).optional()?; + Ok(Json(match raw {Some(value)=>serde_json::from_str(&value)?,None=>serde_json::to_value(SessionPermissions::support_default())?})) +} +async fn set_group_permissions(State(s):State,headers:HeaderMap,Path(group):Path,Json(policy):Json)->ApiResult { + let actor=s.user(&headers,true)?;if group.is_empty() || group.len()>64 {return Err(bad("Invalid group"));} + s.db.lock().unwrap().execute("INSERT INTO group_permissions(group_id,body) VALUES(?1,?2) ON CONFLICT(group_id) DO UPDATE SET body=excluded.body",params![group,serde_json::to_string(&policy)?])?; + s.audit(&actor,"group.permissions_changed",&group)?;Ok(Json(json!({"ok":true}))) +} async fn grant(State(s):State,headers:HeaderMap,Json(input):Json)->ApiResult { let user=s.user(&headers,false)?;s.rate(&format!("grant:{user}"),30)?; public_key(&input.proof_public_key).map_err(|_|bad("Invalid proof key"))?; @@ -301,7 +316,7 @@ async fn grant(State(s):State,headers:HeaderMap,Json(input):Json=s.db.lock().unwrap().query_row("SELECT d.rustdesk_id FROM devices d JOIN users u ON u.id=?2 WHERE d.id=?1 AND d.state='approved' AND u.disabled=0 AND (?3=0 OR d.unattended=1) AND (u.role='admin' OR EXISTS(SELECT 1 FROM group_access g WHERE g.group_id=d.group_id AND g.user_id=u.id))",params![input.device_id,user,input.unattended],|r|r.get(0)).optional()?; let peer=peer.ok_or_else(denied)?; if input.unattended && !profile.allow_unattended {return Err(denied());} - let grant=SessionGrant { schema:SCHEMA,company_id:profile.company_id,grant_id:Uuid::new_v4().to_string(),technician_id:user.clone(),device_id:input.device_id.clone(),rustdesk_id:peer,proof_public_key:input.proof_public_key,unattended:input.unattended,issued_at:now(),expires_at:now()+60 }; + let grant=SessionGrant { schema:SCHEMA,company_id:profile.company_id,grant_id:Uuid::new_v4().to_string(),technician_id:user.clone(),device_id:input.device_id.clone(),rustdesk_id:peer,proof_public_key:input.proof_public_key,unattended:input.unattended,permissions:device_permissions(&s.db.lock().unwrap(),&input.device_id)?,issued_at:now(),expires_at:now()+60 }; s.db.lock().unwrap().execute("INSERT INTO grants(id,user_id,device_id,body,expires_at) VALUES(?1,?2,?3,?4,?5)",params![grant.grant_id,user,input.device_id,serde_json::to_string(&grant)?,grant.expires_at])?; s.audit(&user,"session.requested",&grant.grant_id)?; Ok(Json(serde_json::to_value(SignedEnvelope::sign(&grant,&s.key)?)?)) @@ -318,6 +333,7 @@ async fn claim(State(s):State,headers:HeaderMap,Json(input):Json) grant.validate(&profile.company_id,&device,&peer,now()).map_err(|_|denied())?; let mut db=s.db.lock().unwrap();let tx=db.transaction()?; if !active_grant(&tx,&grant.grant_id,&device)? {return Err(denied());} + if !device_permissions(&tx,&device)?.allows(&grant.permissions) {return Err(denied());} let consent:bool=tx.query_row("SELECT unattended FROM devices WHERE id=?1",[&device],|r|r.get(0))?; if grant.unattended && !(consent && profile.allow_unattended) {return Err(denied());} if tx.execute("UPDATE grants SET claimed=1,lease_until=?1 WHERE id=?2 AND claimed=0 AND expires_at>?3",params![now()+300,grant.grant_id,now()])?!=1 {return Err(denied());} @@ -330,6 +346,7 @@ async fn renew(State(s):State,headers:HeaderMap,Path(id):Path)-> if !active_grant(&db,&id,&device)? {return Err(denied());} let raw:String=db.query_row("SELECT body FROM grants WHERE id=?1",[&id],|r|r.get(0))?; let grant:SessionGrant=serde_json::from_str(&raw)?; + if !device_permissions(&db,&device)?.allows(&grant.permissions) {return Err(denied());} let consent:bool=db.query_row("SELECT unattended FROM devices WHERE id=?1",[&device],|r|r.get(0))?; if grant.unattended && !(consent && profile.allow_unattended){return Err(denied());} if db.execute("UPDATE grants SET lease_until=?1 WHERE id=?2 AND claimed=1 AND lease_until>?3 AND closed=0",params![now()+300,id,now()])?!=1 {return Err(denied());} diff --git a/product/management/src/tests.rs b/product/management/src/tests.rs index 3f6e9905cf8..0b354499e3a 100644 --- a/product/management/src/tests.rs +++ b/product/management/src/tests.rs @@ -52,6 +52,14 @@ async fn enrollment_mfa_and_grants_fail_closed() { let (status,envelope)=request(&app,"/api/v1/grants","POST",Some(admin),grant_request).await;assert_eq!(status,StatusCode::OK); let claim=json!({"grant":envelope}); assert_eq!(request(&app,"/api/v1/grants/claim","POST",Some(credential),claim.clone()).await.0,StatusCode::OK); + let original:SignedEnvelope=serde_json::from_value(envelope.clone()).unwrap(); + let issued:SessionGrant=serde_json::from_slice(&STANDARD.decode(&original.payload).unwrap()).unwrap(); + assert!(issued.permissions.keyboard);assert!(!issued.permissions.recording); + let mut reduced=SessionPermissions::support_default();reduced.keyboard=false; + assert_eq!(request(&app,"/api/v1/groups/customers/permissions","PUT",Some(admin),serde_json::to_value(&reduced).unwrap()).await.0,StatusCode::OK); + assert_eq!(request(&app,&format!("/api/v1/grants/{}/renew",issued.grant_id),"POST",Some(credential),Value::Null).await.0,StatusCode::FORBIDDEN,"Reduced capabilities invalidate existing authorization leases"); + assert_eq!(request(&app,"/api/v1/groups/customers/permissions","PUT",None,serde_json::to_value(&reduced).unwrap()).await.0,StatusCode::UNAUTHORIZED); + assert_eq!(request(&app,"/api/v1/groups/customers/permissions","PUT",Some(admin),serde_json::to_value(SessionPermissions::support_default()).unwrap()).await.0,StatusCode::OK); assert_eq!(request(&app,"/api/v1/grants/claim","POST",Some(credential),claim).await.0,StatusCode::FORBIDDEN,"Session ticket is single-use"); let (_,history)=request(&app,"/api/v1/sessions","GET",Some(admin),Value::Null).await; assert_eq!(history.as_array().unwrap().len(),1); diff --git a/product/protocol/src/lib.rs b/product/protocol/src/lib.rs index 6ca88e5f35f..5e3be5049ce 100644 --- a/product/protocol/src/lib.rs +++ b/product/protocol/src/lib.rs @@ -104,6 +104,22 @@ impl SignedEnvelope { } } +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct SessionPermissions { + pub keyboard:bool,pub clipboard:bool,pub audio:bool,pub file:bool, + pub restart:bool,pub recording:bool,pub block_input:bool,pub privacy_mode:bool, +} +impl SessionPermissions { + pub fn support_default()->Self {Self{keyboard:true,clipboard:true,audio:true,file:true,restart:true,recording:false,block_input:false,privacy_mode:false}} + pub fn allows(&self,requested:&Self)->bool { + (!requested.keyboard || self.keyboard) && (!requested.clipboard || self.clipboard) && + (!requested.audio || self.audio) && (!requested.file || self.file) && + (!requested.restart || self.restart) && (!requested.recording || self.recording) && + (!requested.block_input || self.block_input) && (!requested.privacy_mode || self.privacy_mode) + } +} + #[derive(Clone, Debug, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct SessionGrant { @@ -115,6 +131,7 @@ pub struct SessionGrant { pub rustdesk_id: String, pub proof_public_key: String, pub unattended: bool, + pub permissions:SessionPermissions, pub issued_at: u64, pub expires_at: u64, } @@ -242,7 +259,7 @@ mod tests { #[test] fn grant_is_target_bound_expiring_and_requires_challenge_proof() { let key = SigningKey::from_bytes(&[3;32]); - let grant = SessionGrant { schema: 1, company_id: "a".into(), grant_id: "jti".into(), technician_id:"t".into(), device_id:"d".into(), rustdesk_id:"123".into(), proof_public_key: STANDARD.encode(key.verifying_key().as_bytes()), unattended:false, issued_at:100, expires_at:160 }; + let grant = SessionGrant { schema: 1, company_id: "a".into(), grant_id: "jti".into(), technician_id:"t".into(), device_id:"d".into(), rustdesk_id:"123".into(), proof_public_key: STANDARD.encode(key.verifying_key().as_bytes()), unattended:false, permissions:SessionPermissions::support_default(), issued_at:100, expires_at:160 }; assert!(grant.validate("a","d","123",101).is_ok()); assert!(grant.validate("b","d","123",101).is_err()); assert!(grant.validate("a","d","124",101).is_err()); diff --git a/src/server/connection.rs b/src/server/connection.rs index 629f1e6d7ba..0ce952e99e2 100644 --- a/src/server/connection.rs +++ b/src/server/connection.rs @@ -451,6 +451,13 @@ const SEND_TIMEOUT_OTHER: u64 = SEND_TIMEOUT_VIDEO * 10; const SESSION_TIMEOUT: Duration = Duration::from_secs(30); impl Connection { + #[cfg(feature = "swan_custom")] + fn managed_permission_cap(&self,name:&str)->bool { + let Some(lease)=self.managed_lease.as_ref() else {return false}; + if swan_agent::protocol::now()>=lease.expires_at {return false;} + let p=&lease.permissions; + match name {"keyboard"=>p.keyboard,"clipboard"=>p.clipboard,"audio"=>p.audio,"file"=>p.file,"restart"=>p.restart,"recording"=>p.recording,"block_input"=>p.block_input,"privacy_mode"=>p.privacy_mode,_=>false} + } pub async fn start( addr: SocketAddr, stream: super::Stream, @@ -713,6 +720,8 @@ impl Connection { conn.chat_unanswered = false; } ipc::Data::SwitchPermission{name, enabled} => { + #[cfg(feature = "swan_custom")] + let enabled=enabled && conn.managed_permission_cap(&name); log::info!("Change permission {} -> {}", name, enabled); if &name == "keyboard" { conn.keyboard = enabled; @@ -2547,7 +2556,13 @@ impl Connection { #[cfg(feature = "swan_custom")] if self.managed_lease.is_none() { match crate::managed::claim(&lr.password, &self.hash.challenge, &Config::get_id()).await { - Ok(lease) => self.managed_lease = Some(lease), + Ok(lease) => { + let p=&lease.permissions; + self.keyboard &= p.keyboard;self.clipboard &= p.clipboard; + self.audio &= p.audio;self.file &= p.file;self.restart &= p.restart; + self.recording &= p.recording;self.block_input &= p.block_input; + self.privacy_mode &= p.privacy_mode;self.managed_lease=Some(lease); + }, Err(_) => { self.send_login_error("Company session authorization denied").await; return false; From 31b13aa89525441bb44b017657e17cefbad3e769 Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Fri, 2 Oct 2026 07:10:36 +0200 Subject: [PATCH 018/652] Expose company session policies and produce technician test artifact --- .github/workflows/swan-windows-build.yml | 5 +++++ product/management/web/app.js | 15 +++++++++++++++ product/management/web/index.html | 2 +- 3 files changed, 21 insertions(+), 1 deletion(-) diff --git a/.github/workflows/swan-windows-build.yml b/.github/workflows/swan-windows-build.yml index 9bcd1b1223e..b12ba8cd877 100644 --- a/.github/workflows/swan-windows-build.yml +++ b/.github/workflows/swan-windows-build.yml @@ -145,6 +145,10 @@ jobs: # 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 @@ -164,6 +168,7 @@ jobs: if-no-files-found: error path: | artifacts/SwanRemoteSupport-${{ env.VERSION }}-x64-unsigned-install.exe + artifacts/SwanRemoteSupport-${{ env.VERSION }}-technician-x64-unsigned.exe artifacts/SHA256SUMS.txt artifacts/swan-*.exe artifacts/Install-Company.ps1 diff --git a/product/management/web/app.js b/product/management/web/app.js index cbaca843de2..33b38c9d3ec 100644 --- a/product/management/web/app.js +++ b/product/management/web/app.js @@ -23,6 +23,21 @@ function setupScreen(){ function loginScreen(){title('Sign in to your company server','Administrator and technician accounts require an authenticator code.');document.querySelector('#nav').hidden=true;content.innerHTML=`
${field('username','Username')}${field('password','Password','password')}${field('code','Authenticator code')}

Your session stays in this pageโ€™s memory and expires after one hour.

`;bindForm('login',async values=>{const login=await api('login','POST',{username:values.get('username'),password:values.get('password'),totp_code:values.get('code')});accessToken=login.token;document.querySelector('#nav').hidden=false;await view('devices');});} async function view(name){notice.textContent='';for(const button of document.querySelectorAll('[data-view]'))button.classList.toggle('active',button.dataset.view===name);try{await screens[name]();}catch(error){showError(error);}} const screens={ + async permissions(){ + title('Group session permissions','Set what technicians may do on devices in each group. Reducing permissions prevents existing sessions from renewing their authorization.'); + const capabilities={keyboard:'Keyboard and mouse control',clipboard:'Clipboard',audio:'Audio',file:'File transfer',restart:'Restart device',recording:'Session recording',block_input:'Block customer input',privacy_mode:'Privacy mode'}; + content.innerHTML=`
${field('policy-group','Device group','text','default')}
`; + bindForm('select-policy',async values=>{ + const group=String(values.get('policy-group')).trim(); + const policy=await api('groups/'+encodeURIComponent(group)+'/permissions'); + document.querySelector('#policy').innerHTML=`

${escapeText(group)}

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

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

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

'; for(const button of document.querySelectorAll('[data-approve]'))button.onclick=async()=>{const group=prompt('Device group','default');if(!group)return;try{await api('devices/'+encodeURIComponent(button.dataset.approve)+'/state','PUT',{state:'approved',group});await view('devices');}catch(error){showError(error);}}; for(const button of document.querySelectorAll('[data-revoke]'))button.onclick=async()=>{try{await api('devices/'+encodeURIComponent(button.dataset.revoke)+'/state','PUT',{state:'revoked',group:'default'});await view('devices');}catch(error){showError(error);}}; diff --git a/product/management/web/index.html b/product/management/web/index.html index 8c47a9f0978..4578fb927a3 100644 --- a/product/management/web/index.html +++ b/product/management/web/index.html @@ -1,3 +1,3 @@ Swan Remote Support โ€” Company server - +

YOUR SUPPORT INFRASTRUCTURE

Welcome to Swan Remote Support

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

From 71ebbec14907f3ee216917a1c678d5803d936c51 Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Fri, 2 Oct 2026 07:13:46 +0200 Subject: [PATCH 019/652] Exercise repair consent and session capability reduction over HTTPS --- deployment/windows/test-company-lifecycle.js | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/deployment/windows/test-company-lifecycle.js b/deployment/windows/test-company-lifecycle.js index c52cfaff871..616b7d07f17 100644 --- a/deployment/windows/test-company-lifecycle.js +++ b/deployment/windows/test-company-lifecycle.js @@ -46,6 +46,10 @@ async function main(){ const customerBootstrap=path.join(data,'customer-bootstrap.json');fs.writeFileSync(customerBootstrap,JSON.stringify(bootstrap)); runAgent(customerDir,['setup',customerBootstrap,'--accept-company']);runAgent(customerDir,['enroll','Local HTTPS test device','987654321']); const customer=state(customerDir);assert.equal(customer.unattended_consent,false); + runAgent(customerDir,['verify-bootstrap',customerBootstrap]); + runAgent(customerDir,['enroll','Local HTTPS test device','987654321']); + assert.equal(state(customerDir).device_id,customer.device_id);assert.equal(state(customerDir).device_token,customer.device_token); + assert.throws(()=>runAgent(customerDir,['enroll','Different identity','987654322']),'Repair replaced transport identity'); const proof=crypto.generateKeyPairSync('ed25519').publicKey.export({type:'spki',format:'der'}).subarray(-32).toString('base64'); const ticket={device_id:customer.device_id,proof_public_key:proof,unattended:false}; assert.equal((await api('grants','POST',admin,ticket)).status,403,'Pending device was accessible'); @@ -57,10 +61,19 @@ async function main(){ const auth={SWAN_TECHNICIAN_TOKEN:technicianLogin.token};assert.equal(JSON.parse(runAgent(techDir,['devices'],auth)).length,0,'Technician without group access saw device'); assert.equal((await api(`groups/test-customers/users/${technician.id}`,'PUT',admin,{})).status,200); assert.equal(JSON.parse(runAgent(techDir,['devices'],auth)).length,1); + runAgent(customerDir,['allow-unattended','--confirm-unattended']);assert.equal(state(customerDir).unattended_consent,true); + runAgent(customerDir,['revoke-unattended']);assert.equal(state(customerDir).unattended_consent,false); + const policy=(await api('groups/test-customers/permissions','GET',admin)).body; + assert.equal(policy.recording,false);policy.keyboard=false; + assert.equal((await api('groups/test-customers/permissions','PUT',technicianLogin.token,policy)).status,401,'Technician changed group policy'); + assert.equal((await api('groups/test-customers/permissions','PUT',admin,policy)).status,200); assert.equal((await api('grants','POST',technicianLogin.token,{...ticket,unattended:true})).status,403,'Unattended grant issued without consent'); const grant=await api('grants','POST',technicianLogin.token,ticket);assert.equal(grant.status,200); + const issued=JSON.parse(Buffer.from(grant.body.payload,'base64'));assert.equal(issued.permissions.keyboard,false);assert.equal(issued.permissions.file,true); assert.equal((await api('grants/claim','POST',customer.device_token,{grant:grant.body})).status,200); assert.equal((await api('grants/claim','POST',customer.device_token,{grant:grant.body})).status,403,'Grant replay accepted'); + policy.file=false;assert.equal((await api('groups/test-customers/permissions','PUT',admin,policy)).status,200); + assert.equal((await api(`grants/${issued.grant_id}/renew`,'POST',customer.device_token)).status,403,'Reduced policy renewed older permissions'); const history=JSON.parse(runAgent(techDir,['history'],auth));assert.equal(history.length,1);assert.equal(history[0].claimed,true);assert.equal(history[0].device_id,customer.device_id); current.customer.display_name='Changed Local Test Branding';assert.equal((await api('profile','PUT',admin,current)).status,200); runAgent(customerDir,['sync']);const updated=JSON.parse(Buffer.from(state(customerDir).profile.payload,'base64'));assert.equal(updated.customer.display_name,'Changed Local Test Branding');assert.equal(updated.revision,2); From e84d6c17b9c6912a83fec8d0904a061648225aa3 Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Fri, 2 Oct 2026 07:17:02 +0200 Subject: [PATCH 020/652] Record native build and expanded real HTTPS acceptance evidence --- docs/ACCEPTANCE.md | 23 +++++++++++++++++------ 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index b2723ee252e..0a3768700ea 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -22,18 +22,29 @@ native support session or a clean-machine installation. test uses a test RustDesk ID and proves **no native remote desktop behavior**. - Flutter bridge generation passed for the technician request stream. Native Rust parsing and changed PowerShell parsing passed. The graphical technician - interface and in-memory ticket broker still need native compilation and use. + interface and in-memory ticket broker compile in the shared endpoint source; + native use remains unverified. - Native lockfile resolution at `75632a2` adds managed-agent dependencies while preserving every pre-existing package version, checksum and Git commit. A - pinned Windows x64 build passed native Rust compilation but failed Flutter - compilation because the technician page lacked its platform bridge import. - That import is now corrected; a successful endpoint build is still required. + [pinned Windows x64 build at `c78a471`](https://github.com/rijff24/SwanRemoteSupport/actions/runs/36873808239) + passed Rust/Flutter compilation, unsigned customer package generation, and + product-component compilation. It predates the capability-policy changes. + A new build at `31b13aa` also produces a distinct technician portable artifact; + its outcome remains pending. +- A fresh-company HTTPS test at `71ebbec` passed with rebuilt agent and server + executables. It additionally verifies enrollment-preserving repair, rejection + of a changed transport identity, explicit consent opt-in/revocation, + administrator-only group policy changes, capability fields in signed grants, + and denied lease renewal after policy reduction. This is API/agent evidence, + not proof of native receiver enforcement or installer repair. - Existing RustDesk, hbbs and hbbr services remained running. Only the isolated development management process was rebuilt and restarted. - Windows hypervisor API probing reports an available hypervisor. QEMU 11.1.0 passed its published SHA-512 checksum and runs from the separate local lab. - Official Server 2025 evaluation media is still downloading. No evaluation - guest installation has passed yet. + Official Server 2025 evaluation media downloaded successfully; its size and + SHA-256 are recorded outside Git. Guest boot attempts and failures are + documented in `deployment/windows/VM-TESTING.md`. No evaluation guest + installation has passed yet. ## Requirements still to demonstrate From 7ef53e9c78a33a44d5e6a1c5f3688302dd1af327 Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Fri, 2 Oct 2026 07:18:56 +0200 Subject: [PATCH 021/652] Reject file transfer paths outside signed company capabilities --- src/server/connection.rs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/server/connection.rs b/src/server/connection.rs index 0ce952e99e2..e4f74cd7e8a 100644 --- a/src/server/connection.rs +++ b/src/server/connection.rs @@ -2581,6 +2581,11 @@ impl Connection { } match lr.union { Some(login_request::Union::FileTransfer(ft)) => { + #[cfg(feature = "swan_custom")] + if !self.managed_permission_cap("file") { + self.send_login_error("Company grant denies file transfer").await; + return false; + } if !Self::permission( keys::OPTION_ENABLE_FILE_TRANSFER, &self.control_permissions, @@ -5400,6 +5405,12 @@ impl Connection { } fn authorized_scope_violation(&self, msg: &Message) -> Option<&'static str> { + #[cfg(feature = "swan_custom")] + match msg.union.as_ref() { + Some(message::Union::FileAction(_)) | Some(message::Union::FileResponse(_)) if !self.managed_permission_cap("file") => return Some("Company grant denies file transfer"), + Some(message::Union::Cliprdr(_)) if !self.managed_permission_cap("file") || !self.managed_permission_cap("clipboard") => return Some("Company grant denies clipboard file transfer"), + _=>{} + } let Some(conn_type) = self.authed_conn_type() else { return (!Self::is_connection_housekeeping_message(msg)).then_some("session.auth_type"); }; From 96cddfb1946124d37c53a1d7d16d08d2b23fc509 Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Fri, 2 Oct 2026 07:21:23 +0200 Subject: [PATCH 022/652] Advertise grant bounds and reject unauthorized voice packets --- src/server/connection.rs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/server/connection.rs b/src/server/connection.rs index e4f74cd7e8a..38614f13cf5 100644 --- a/src/server/connection.rs +++ b/src/server/connection.rs @@ -2562,6 +2562,14 @@ impl Connection { self.audio &= p.audio;self.file &= p.file;self.restart &= p.restart; self.recording &= p.recording;self.block_input &= p.block_input; self.privacy_mode &= p.privacy_mode;self.managed_lease=Some(lease); + // Advertise the actual grant bounds before processing + // login options or any subsequent privileged packets. + for (permission,enabled) in [ + (Permission::Keyboard,self.keyboard),(Permission::Clipboard,self.clipboard), + (Permission::Audio,self.audio),(Permission::File,self.file), + (Permission::Restart,self.restart),(Permission::Recording,self.recording), + (Permission::BlockInput,self.block_input),(Permission::PrivacyMode,self.privacy_mode), + ] {self.send_permission(permission,enabled).await;} }, Err(_) => { self.send_login_error("Company session authorization denied").await; @@ -5407,6 +5415,7 @@ impl Connection { fn authorized_scope_violation(&self, msg: &Message) -> Option<&'static str> { #[cfg(feature = "swan_custom")] match msg.union.as_ref() { + Some(message::Union::AudioFrame(_)) | Some(message::Union::VoiceCallRequest(_)) | Some(message::Union::VoiceCallResponse(_)) if !self.managed_permission_cap("audio") => return Some("Company grant denies audio"), Some(message::Union::FileAction(_)) | Some(message::Union::FileResponse(_)) if !self.managed_permission_cap("file") => return Some("Company grant denies file transfer"), Some(message::Union::Cliprdr(_)) if !self.managed_permission_cap("file") || !self.managed_permission_cap("clipboard") => return Some("Company grant denies clipboard file transfer"), _=>{} From 83d87221a1b67f3d710184f33d98fe37debe7b98 Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Fri, 2 Oct 2026 07:29:55 +0200 Subject: [PATCH 023/652] Back up transport trust configuration and TLS identity with protected restore --- deployment/windows/LOCAL-TESTING.md | 2 +- docs/ACCEPTANCE.md | 2 +- docs/SERVER_BACKUP.md | 70 +++++++++++++++++++++++++++++ product/management/src/backup.rs | 65 ++++++++++++++++++++++++--- product/management/src/main.rs | 13 +++++- 5 files changed, 143 insertions(+), 9 deletions(-) create mode 100644 docs/SERVER_BACKUP.md diff --git a/deployment/windows/LOCAL-TESTING.md b/deployment/windows/LOCAL-TESTING.md index 70b190c037a..d1650c835c7 100644 --- a/deployment/windows/LOCAL-TESTING.md +++ b/deployment/windows/LOCAL-TESTING.md @@ -8,4 +8,4 @@ The default local administration address is http://127.0.0.1:18080. Use the one- This loopback HTTP listener is for administration smoke tests. Endpoint enrollment and profile synchronization require HTTPS with a trusted certificate. `local-https.js` provides an isolated loopback proxy using PFX/password settings from the ignored environment file. Debug agents accept a DER test certificate through `SWAN_TEST_CA_FILE`; release builds ignore this variable and use operating-system trust roots. Certificate validation remains enabled. Public direct and relay testing requires a company-owned reachable endpoint and a separate external test machine. -Verified on this development machine: management starts, administration responds with HTTP 200, and sixteen product-workspace tests pass. `node deployment/windows/test-company-lifecycle.js PATH_TO_PRIVATE_ENV_FILE` also passed against a fresh HTTPS proxy and newly built agent/server: company setup, device enrollment, approval denial, technician MFA/group inventory and session history, logout credential revocation, consent denial, grant replay denial, signed branding sync and device revocation. The script records executable hashes in the protected data directory and refuses an already configured company. For another run, create a separate empty data directory and environment file, choose unused loopback ports, and copy the public DER test certificate into that directory as `localhost.cer`; preserve earlier databases and results. This API integration uses a test device ID and does not demonstrate native remote desktop. The native Flutter/RustDesk integration, production signatures, complete installers, update installation/recovery and clean-machine Windows compatibility remain release requirements; this development build is not a production release. +Verified on this development machine: management starts, administration responds with HTTP 200, and seventeen product-workspace tests pass. `node deployment/windows/test-company-lifecycle.js PATH_TO_PRIVATE_ENV_FILE` also passed against a fresh HTTPS proxy and newly built agent/server: company setup, device enrollment, approval denial, technician MFA/group inventory and session history, logout credential revocation, consent denial, grant replay denial, signed branding sync and device revocation. The script records executable hashes in the protected data directory and refuses an already configured company. For another run, create a separate empty data directory and environment file, choose unused loopback ports, and copy the public DER test certificate into that directory as `localhost.cer`; preserve earlier databases and results. This API integration uses a test device ID and does not demonstrate native remote desktop. The native Flutter/RustDesk integration, production signatures, complete installers, update installation/recovery and clean-machine Windows compatibility remain release requirements; this development build is not a production release. diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index 0a3768700ea..98efc74e0f6 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -8,7 +8,7 @@ native support session or a clean-machine installation. - Public baseline tag `swan-single-company-baseline-1.4.9` preserves the original Swan deployment and build instructions. Its source was not replaced. -- Sixteen product tests pass on the local Windows development host, including +- Seventeen product tests pass on the local Windows development host, including enrollment/trust preservation across stale writes, trusted key rotation, refusal to automatically reinstall a removed endpoint, and explicit consent changes surviving older refreshes. The preceding eleven-test component source passed tests and release compilation on [Windows 2022 and Ubuntu 24.04 CI](https://github.com/rijff24/SwanRemoteSupport/actions/runs/36856716860) diff --git a/docs/SERVER_BACKUP.md b/docs/SERVER_BACKUP.md new file mode 100644 index 00000000000..71509d45855 --- /dev/null +++ b/docs/SERVER_BACKUP.md @@ -0,0 +1,70 @@ +# Company server backup and restore + +Supply `SWAN_DATA_DIR` and `SWAN_BACKUP_PASSPHRASE` to the management process +through private environment configuration. Keep the passphrase separate from +the backup file. It must contain at least 16 characters. These commands never +accept a passphrase on the command line. + +Management-only export remains available: + +```text +swan-management backup company.swan-backup +``` + +For a deployment export, replace the path placeholders with company-controlled +paths. Omit `--tls-identity` only when HTTPS identity will be reissued separately. +The TLS argument accepts one existing identity file, such as a PFX or PEM bundle; +it does not export an entire ACME storage directory. + +```text +swan-management backup company.swan-backup --transport-directory TRANSPORT_DATA --deployment-env PRIVATE_ENV_FILE --tls-identity TLS_IDENTITY_FILE +``` + +The transport directory must contain both `id_ed25519` and `id_ed25519.pub`. +When present, `db_v2.sqlite3` is also snapshotted using SQLite's backup API. +Management uses the same API for its database, including committed WAL data. +Stop configuration changes during the export when a coordinated snapshot of +the separately stored environment, TLS identity and transport data is needed. +The export does not stop or modify running transport services. + +All contents are encrypted with AES-256-GCM and an Argon2-derived key. Existing +backup files are never overwritten. Temporary plaintext database snapshots are +removed after archive generation. Archives and plaintext contents have size +limits; this is not a general-purpose filesystem backup tool. + +Restore into an **empty directory** while the replacement management service +is stopped and its public endpoints are unavailable: + +```text +swan-management restore company.swan-backup +``` + +Set `SWAN_DATA_DIR` to that empty replacement directory before running restore. +The restorer authenticates and validates the archive before writing contents. +It permits fixed filenames only, rejects duplicate names and incomplete key +pairs, and protects restored files with private Unix permissions or Windows +ACLs for the invoking account, SYSTEM and Administrators. + +Management files restore directly. Deployment extras are staged in that same +protected directory; restore never overwrites external service files: + +| Restored file | Destination to configure before exposing the server | +| --- | --- | +| `transport-id_ed25519` | Transport data directory as `id_ed25519` | +| `transport-id_ed25519.pub` | Transport data directory as `id_ed25519.pub` | +| `transport-db.sqlite3` | Transport data directory as `db_v2.sqlite3` | +| `deployment.env` | Private deployment environment configuration | +| `tls-identity` | Company HTTPS identity location, or replace through certificate reissuance | + +Keep transport services stopped while placing their restored data. Preserve +private ownership and permissions appropriate to the service accounts. Use the +same application release first, then perform administrator-controlled upgrades. +Current restores also accept older management-only three-file backups; older +restorers cannot consume the extended deployment archive. + +Before exposing the restored deployment, verify the transport and profile trust +keys, database integrity, MFA login, device identities, consent, permissions and +release approvals. Reconcile any revocations made after the backup. Installed +clients reject profiles older than their cached revision, so recover a profile +revision newer than those clients have accepted before attempting normal sync. +These restore rehearsals and migration rollback checks remain release gates. diff --git a/product/management/src/backup.rs b/product/management/src/backup.rs index 20e42d6cd16..edfe9a5e055 100644 --- a/product/management/src/backup.rs +++ b/product/management/src/backup.rs @@ -9,14 +9,33 @@ fn key(password:&str,salt:&[u8])->Result<[u8;32]> { let mut key=[0u8;32];argon2::Argon2::default().hash_password_into(password.as_bytes(),salt,&mut key).map_err(|_|anyhow::anyhow!("Backup key derivation failed"))?;Ok(key) } pub fn export(data:&Path,destination:&Path,password:&str)->Result<()> { + export_complete(data,destination,password,None,None,None) +} +pub fn export_complete(data:&Path,destination:&Path,password:&str,transport:Option<&Path>,configuration:Option<&Path>,tls_identity:Option<&Path>)->Result<()> { ensure!(!destination.exists(),"Refusing to overwrite an existing backup"); - let db=rusqlite::Connection::open(data.join("management.sqlite3"))?; + let db=rusqlite::Connection::open_with_flags(data.join("management.sqlite3"),rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY)?; let snapshot=data.join(format!("backup-{}.sqlite3",swan_protocol::random_token())); db.backup(rusqlite::DatabaseName::Main,&snapshot,None)?; + let mut snapshots=vec![snapshot.clone()]; let result=(||->Result<()> { let mut archive=zip::ZipWriter::new(Cursor::new(Vec::new())); let options=zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated); - for (name,path) in [("management.sqlite3",snapshot.as_path()),("profile-key.hex",data.join("profile-key.hex").as_path()),("setup-token.txt",data.join("setup-token.txt").as_path())] { + let mut files=vec![("management.sqlite3",snapshot.clone()),("profile-key.hex",data.join("profile-key.hex")),("setup-token.txt",data.join("setup-token.txt"))]; + if let Some(directory)=transport { + files.push(("transport-id_ed25519",directory.join("id_ed25519")));files.push(("transport-id_ed25519.pub",directory.join("id_ed25519.pub"))); + if directory.join("db_v2.sqlite3").exists(){ + let transport_snapshot=data.join(format!("backup-transport-{}.sqlite3",swan_protocol::random_token()));snapshots.push(transport_snapshot.clone()); + let transport_db=rusqlite::Connection::open_with_flags(directory.join("db_v2.sqlite3"),rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY)?; + transport_db.backup(rusqlite::DatabaseName::Main,&transport_snapshot,None)?; + files.push(("transport-db.sqlite3",transport_snapshot)); + } + } + if let Some(path)=configuration {files.push(("deployment.env",path.to_path_buf()));} + if let Some(path)=tls_identity {files.push(("tls-identity",path.to_path_buf()));} + let mut total=0u64; + for (name,path) in files { + let size=std::fs::metadata(&path)?.len();total=total.checked_add(size).context("Backup size overflow")?; + ensure!(total<=512*1024*1024,"Backup contents exceed 512 MiB"); archive.start_file(name,options)?;archive.write_all(&std::fs::read(path)?)?; } let bytes=archive.finish()?.into_inner(); @@ -27,26 +46,42 @@ pub fn export(data:&Path,destination:&Path,password:&str)->Result<()> { #[cfg(unix)] {use std::os::unix::fs::OpenOptionsExt;options.mode(0o600);} let mut file=options.open(destination)?;file.write_all(HEADER)?;file.write_all(&salt)?;file.write_all(&nonce)?;file.write_all(&encrypted)?;file.sync_all()?;Ok(()) })(); - std::fs::remove_file(&snapshot).context("Remove plaintext database snapshot after backup")?; + for snapshot in snapshots {if snapshot.exists(){std::fs::remove_file(&snapshot).context("Remove plaintext database snapshot after backup")?;}} result } pub fn restore(data:&Path,source:&Path,password:&str)->Result<()> { ensure!(!data.exists() || std::fs::read_dir(data)?.next().is_none(),"Restore requires an empty target directory and a stopped management service"); + ensure!(std::fs::metadata(source)?.len()<=1024*1024*1024,"Backup exceeds size limit"); let bytes=std::fs::read(source)?; ensure!(bytes.len()>HEADER.len()+28 && bytes.starts_with(HEADER),"Invalid backup format"); let offset=HEADER.len();let salt=&bytes[offset..offset+16];let nonce=&bytes[offset+16..offset+28]; let cipher=Aes256Gcm::new_from_slice(&key(password,salt)?).map_err(|_|anyhow::anyhow!("Invalid decryption key"))?; let plaintext=cipher.decrypt(Nonce::from_slice(nonce),&bytes[offset+28..]).map_err(|_|anyhow::anyhow!("Backup password incorrect or backup modified"))?; let mut archive=zip::ZipArchive::new(Cursor::new(plaintext))?; - ensure!(archive.len()==3,"Unexpected backup contents"); + ensure!((3..=8).contains(&archive.len()),"Unexpected backup contents"); + let allowed=["management.sqlite3","profile-key.hex","setup-token.txt","transport-id_ed25519","transport-id_ed25519.pub","transport-db.sqlite3","deployment.env","tls-identity"]; + let mut names=std::collections::HashSet::new(); + for index in 0..archive.len(){let entry=archive.by_index(index)?;ensure!(allowed.contains(&entry.name()) && names.insert(entry.name().to_string()),"Unknown or duplicate backup entry");} + ensure!(names.contains("transport-id_ed25519")==names.contains("transport-id_ed25519.pub"),"Incomplete transport trust key pair"); + for required in &allowed[..3]{ensure!(names.contains(*required),"Missing management backup entry");} // Validate all contents before creating anything. Never extract arbitrary archive paths. let mut files=Vec::new(); - for name in ["management.sqlite3","profile-key.hex","setup-token.txt"] { - let mut entry=archive.by_name(name)?;ensure!(entry.size()<=512*1024*1024,"Backup entry too large"); + let mut total=0u64; + for name in allowed.into_iter().filter(|name|names.contains(*name)) { + let mut entry=archive.by_name(name)?;total=total.checked_add(entry.size()).context("Backup size overflow")?;ensure!(total<=512*1024*1024,"Backup contents too large"); let mut content=Vec::new();entry.read_to_end(&mut content)?;files.push((name,content)); } std::fs::create_dir_all(data)?; #[cfg(unix)] {use std::os::unix::fs::PermissionsExt;std::fs::set_permissions(data,std::fs::Permissions::from_mode(0o700))?;} + #[cfg(windows)] { + let identity=std::process::Command::new("whoami.exe").args(["/user","/fo","csv","/nh"]).output()?; + ensure!(identity.status.success(),"Cannot identify backup restore owner"); + let identity=String::from_utf8_lossy(&identity.stdout); + let sid=identity.trim().rsplit(',').next().context("Missing restore owner SID")?.trim_matches('"'); + ensure!(sid.starts_with("S-1-") && sid.bytes().all(|byte|byte==b'S' || byte==b'-' || byte.is_ascii_digit()),"Invalid restore owner SID"); + let status=std::process::Command::new("icacls.exe").arg(data).args(["/inheritance:r","/grant:r"]).arg(format!("*{sid}:(OI)(CI)F")).args(["*S-1-5-18:(OI)(CI)F","*S-1-5-32-544:(OI)(CI)F"]).output()?; + ensure!(status.status.success(),"Cannot protect restored company keys"); + } for (name,content) in files { let mut options=std::fs::OpenOptions::new();options.create_new(true).write(true); #[cfg(unix)] {use std::os::unix::fs::OpenOptionsExt;options.mode(0o600);} @@ -59,6 +94,24 @@ pub fn restore(data:&Path,source:&Path,password:&str)->Result<()> { mod tests { use super::*; #[test] + fn complete_backup_preserves_transport_configuration_and_tls_and_rejects_tampering() { + let root=std::env::temp_dir().join(format!("swan-full-backup-{}",swan_protocol::random_token())); + let data=root.join("data");let transport=root.join("transport");std::fs::create_dir_all(&data).unwrap();std::fs::create_dir(&transport).unwrap(); + let db=rusqlite::Connection::open(data.join("management.sqlite3")).unwrap();db.execute_batch("CREATE TABLE identity(value TEXT);INSERT INTO identity VALUES('company identity');").unwrap();drop(db); + std::fs::write(data.join("profile-key.hex"),"management signing key").unwrap();std::fs::write(data.join("setup-token.txt"),"setup token").unwrap(); + std::fs::write(transport.join("id_ed25519"),b"private transport key").unwrap();std::fs::write(transport.join("id_ed25519.pub"),b"public transport key").unwrap(); + let db=rusqlite::Connection::open(transport.join("db_v2.sqlite3")).unwrap();db.execute_batch("CREATE TABLE peers(id TEXT);INSERT INTO peers VALUES('retained peer');").unwrap();drop(db); + let configuration=root.join("company.env");let tls=root.join("server.pfx");std::fs::write(&configuration,b"company settings and TLS password").unwrap();std::fs::write(&tls,b"TLS identity fixture").unwrap(); + let archive=root.join("complete.swan-backup");export_complete(&data,&archive,"complete backup passphrase",Some(&transport),Some(&configuration),Some(&tls)).unwrap(); + let destination=root.join("restored");restore(&destination,&archive,"complete backup passphrase").unwrap(); + assert_eq!(std::fs::read(destination.join("transport-id_ed25519")).unwrap(),b"private transport key");assert_eq!(std::fs::read(destination.join("transport-id_ed25519.pub")).unwrap(),b"public transport key"); + assert_eq!(std::fs::read(destination.join("deployment.env")).unwrap(),std::fs::read(configuration).unwrap());assert_eq!(std::fs::read(destination.join("tls-identity")).unwrap(),std::fs::read(tls).unwrap()); + let db=rusqlite::Connection::open(destination.join("transport-db.sqlite3")).unwrap();assert_eq!(db.query_row("SELECT id FROM peers",[],|row|row.get::<_,String>(0)).unwrap(),"retained peer");drop(db); + let mut modified=std::fs::read(&archive).unwrap();let last=modified.len()-1;modified[last]^=1;let tampered=root.join("tampered.swan-backup");std::fs::write(&tampered,modified).unwrap(); + let rejected=root.join("rejected");assert!(restore(&rejected,&tampered,"complete backup passphrase").is_err());assert!(!rejected.exists()); + std::fs::remove_dir_all(root).unwrap(); + } + #[test] fn backup_roundtrip_rejects_wrong_password_and_nonempty_restore() { let root=std::env::temp_dir().join(format!("swan-backup-{}",swan_protocol::random_token())); let data=root.join("data");std::fs::create_dir_all(&data).unwrap(); diff --git a/product/management/src/main.rs b/product/management/src/main.rs index dc52b4cda39..61fce14d53b 100644 --- a/product/management/src/main.rs +++ b/product/management/src/main.rs @@ -27,7 +27,18 @@ async fn run(stop:tokio::sync::oneshot::Receiver<()>)->Result<()> { if matches!(args.get(1).map(String::as_str),Some("backup"|"restore")) { let path=PathBuf::from(args.get(2).context("Usage: swan-management backup|restore FILE")?); let passphrase=std::env::var("SWAN_BACKUP_PASSPHRASE").context("Set SWAN_BACKUP_PASSPHRASE for this process")?; - if args[1]=="backup" {backup::export(&data,&path,&passphrase)?;} else {backup::restore(&data,&path,&passphrase)?;} + let operation=args[1].clone();let extra=args[3..].to_vec(); + tokio::task::spawn_blocking(move || ->Result<()> { + let mut inputs=std::collections::HashMap::new(); + anyhow::ensure!(extra.len()%2==0,"Backup options require explicit paths"); + for pair in extra.chunks_exact(2){ + anyhow::ensure!(operation=="backup" && matches!(pair[0].as_str(),"--transport-directory"|"--deployment-env"|"--tls-identity"),"Unknown backup option"); + anyhow::ensure!(inputs.insert(pair[0].clone(),PathBuf::from(&pair[1])).is_none(),"Duplicate backup option"); + } + if operation=="backup" { + backup::export_complete(&data,&path,&passphrase,inputs.get("--transport-directory").map(|p|p.as_path()),inputs.get("--deployment-env").map(|p|p.as_path()),inputs.get("--tls-identity").map(|p|p.as_path())) + }else{backup::restore(&data,&path,&passphrase)} + }).await??; println!("Encrypted {} completed.",args[1]);return Ok(()); } let store = Arc::new(Store::open(&data)?); From 20f88e3eb54fa0dcdf7a41fc60ebec35d7d0d3bf Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Fri, 2 Oct 2026 07:42:01 +0200 Subject: [PATCH 024/652] Retry offline consent revocation with ordered server synchronization --- docs/ACCEPTANCE.md | 4 ++-- docs/CONSENT_SYNC.md | 34 +++++++++++++++++++++++++++++++ product/agent/src/lib.rs | 5 ++++- product/agent/src/main.rs | 6 ++++++ product/management/src/schema.sql | 1 + product/management/src/service.rs | 15 ++++++++++++-- product/management/src/tests.rs | 8 ++++++++ 7 files changed, 68 insertions(+), 5 deletions(-) create mode 100644 docs/CONSENT_SYNC.md diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index 98efc74e0f6..06e97da24b2 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -9,7 +9,7 @@ native support session or a clean-machine installation. - Public baseline tag `swan-single-company-baseline-1.4.9` preserves the original Swan deployment and build instructions. Its source was not replaced. - Seventeen product tests pass on the local Windows development host, including - enrollment/trust preservation across stale writes, trusted key rotation, refusal to automatically reinstall a removed endpoint, and explicit consent changes surviving older refreshes. + enrollment/trust preservation across stale writes, trusted key rotation, refusal to automatically reinstall a removed endpoint, and explicit consent changes surviving older refreshes. Consent API tests also reject delayed enable requests and older revocation retries; same-revision revocation takes precedence. The preceding eleven-test component source passed tests and release compilation on [Windows 2022 and Ubuntu 24.04 CI](https://github.com/rijff24/SwanRemoteSupport/actions/runs/36856716860) at commit `411269b`. @@ -62,7 +62,7 @@ native support session or a clean-machine installation. | Windows worker and public/protected downloads | Queue, worker, fixed bundles and access checks are implemented. A real signed worker job, both EXE/MSI installation products, protected technician download and public customer website installation remain unverified or incomplete. | | Signing and exact corresponding source | Certificate/hash/publisher pins and source metadata are implemented. Provider approval, production signatures, company signing integration and production release checks remain gates. | | Approved automatic updates | Metadata replay/expiry and session exclusion tests pass. Real rollout, pause/windows, full payload/agent identity preservation, technician executable handoff, interruption repair/retry/rollback and recovery remain incomplete or unverified. | -| Backup, restore and migrations | Encrypted management round-trip/tamper checks pass. Full deployment/transport key coverage, server restore rehearsal and migration rollback remain incomplete or unverified. | +| Backup, restore and migrations | Encrypted round-trip/tamper checks cover management data, transport trust keys and SQLite state, deployment configuration and an explicit TLS identity. Protected Windows restore passes component tests. Live server restore, complete certificate-service storage and migration rollback remain unverified or incomplete; see SERVER_BACKUP.md. | ## Clean Windows matrix diff --git a/docs/CONSENT_SYNC.md b/docs/CONSENT_SYNC.md new file mode 100644 index 00000000000..30ecc52bbe4 --- /dev/null +++ b/docs/CONSENT_SYNC.md @@ -0,0 +1,34 @@ +# Unattended consent synchronization + +The receiving device's durable consent is required for unattended access in +addition to company policy and a valid server grant. Revocation is written +locally before attempting HTTPS synchronization. An offline server cannot +prevent local revocation. + +The customer agent's `watch` loop retries a disabled local consent choice every +five minutes, after reloading the latest saved state. It never enables consent +in the background. A pending device's request may be rejected until company +approval; local unattended access remains disabled. + +`PUT /api/v1/device/consent` requires an authenticated device and JSON containing +`unattended` (boolean) and `revision` (nonnegative integer within SQLite's signed +integer range). The server records the revision transactionally with the choice. +Lower revisions receive HTTP 409. Equal revisions are idempotent, with disabled +consent taking precedence over a conflicting enable request. Repeated unchanged +requests do not create audit events. + +Explicit enable requests propose the next local consent revision and must be +accepted by the server before persisting enabled consent. Persistence rejects +an enable response if another process changed consent while it was pending. +The customer must confirm again in that case. A later revocation is durable +locally and its retry cannot be overwritten by that older enable response. + +This changes the development API: older clients sending no revision are +rejected rather than permitted to bypass ordering. Upgrade the server and +endpoint components together before testing this development build. This is +not a production compatibility promise. The additive consent-revision table +is included in management database backups. + +Component tests cover ordering, same-revision conflicts, idempotency, and stale +local enable responses. Offline network recovery and native Windows controls +still require end-to-end acceptance tests. diff --git a/product/agent/src/lib.rs b/product/agent/src/lib.rs index 3bd0f27c60b..6dad767abf5 100644 --- a/product/agent/src/lib.rs +++ b/product/agent/src/lib.rs @@ -90,6 +90,7 @@ impl AgentState { ensure!(existing.accepted_revision<=self.accepted_revision,"A newer profile has already been saved"); saved.last_release_sequence=saved.last_release_sequence.max(existing.last_release_sequence); if let Some(id)=existing.device_id.as_ref(){ + ensure!(consent_change!=Some(true) || self.consent_revision==existing.consent_revision,"Consent changed while enable request was pending; confirm again"); ensure!(consent_change.is_some() || self.consent_revision<=existing.consent_revision,"Consent revision changes require explicit customer consent"); ensure!(self.device_id.as_ref().map(|value|value==id).unwrap_or(true),"Cannot replace enrolled device identity"); ensure!(self.device_token.as_ref().map(|value|Some(value)==existing.device_token.as_ref()).unwrap_or(true),"Cannot replace enrolled device credential"); @@ -177,7 +178,8 @@ impl AgentState { // Local revoke takes effect even if the server is unavailable. if !enabled {self.unattended_consent=false;} ensure!(!enabled || self.company_profile()?.allow_unattended,"Company disallows unattended support"); - self.client()?.put(self.endpoint("device/consent")?).bearer_auth(self.device_token.as_ref().context("Not enrolled")?).json(&json!({"unattended":enabled})).send().await?.error_for_status()?; + let revision=if enabled {self.consent_revision.checked_add(1).context("Consent revision exhausted")?}else{self.consent_revision}; + self.client()?.put(self.endpoint("device/consent")?).bearer_auth(self.device_token.as_ref().context("Not enrolled")?).json(&json!({"unattended":enabled,"revision":revision})).send().await?.error_for_status()?; self.unattended_consent=enabled;Ok(()) } pub async fn request_grant(&self,token:&str,device:&str,unattended:bool,key:&SigningKey)->Result { @@ -287,6 +289,7 @@ mod tests { let current=AgentState::load(&directory).unwrap();assert!(current.unattended_consent);assert_eq!(current.consent_revision,2); state.set_local_consent(&directory,false).unwrap();current.save(&directory).unwrap(); assert!(!AgentState::load(&directory).unwrap().unattended_consent); + assert!(current.clone().set_local_consent(&directory,true).is_err(),"An enable response from before revocation must not reenable access"); std::fs::remove_dir_all(directory).unwrap(); } #[test] diff --git a/product/agent/src/main.rs b/product/agent/src/main.rs index 93dc99c1853..d49d863850c 100644 --- a/product/agent/src/main.rs +++ b/product/agent/src/main.rs @@ -37,6 +37,12 @@ async fn main()->Result<()> { "sync"=>{let mut state=AgentState::load_for_refresh(&directory)?;state.sync().await?;state.save(&directory)?;println!("Profile synchronized.");} "watch"=>{loop {match AgentState::load_for_refresh(&directory){Ok(mut state)=>{ match state.sync().await{Ok(())=>state.save(&directory)?,Err(error)=>eprintln!("Profile refresh failed: {error}")}; + // Reload after refresh: another process may have changed consent. + // Server revisions reject delayed requests from an older choice. + let mut current=AgentState::load_for_refresh(&directory)?; + if current.bootstrap.edition==Edition::Customer && current.device_id.is_some() && !current.unattended_consent { + if let Err(error)=current.consent(false).await {eprintln!("Consent revocation synchronization deferred: {error}");} + } if state.bootstrap.edition==Edition::Customer {if let Err(error)=state.update(&directory,None).await {eprintln!("Update deferred: {error}");}} },Err(error)=>eprintln!("Configuration unavailable: {error}")};tokio::time::sleep(std::time::Duration::from_secs(300)).await;}} "update"=>{ diff --git a/product/management/src/schema.sql b/product/management/src/schema.sql index 1e4e9adcc80..33337a8bafe 100644 --- a/product/management/src/schema.sql +++ b/product/management/src/schema.sql @@ -9,4 +9,5 @@ CREATE TABLE IF NOT EXISTS releases(id TEXT PRIMARY KEY,body TEXT NOT NULL,envel CREATE TABLE IF NOT EXISTS workers(id TEXT PRIMARY KEY,token_hash TEXT NOT NULL UNIQUE,disabled INTEGER NOT NULL DEFAULT 0); CREATE TABLE IF NOT EXISTS builds(id TEXT PRIMARY KEY,body TEXT NOT NULL,state TEXT NOT NULL,worker TEXT NOT NULL DEFAULT '',claimed_at INTEGER NOT NULL DEFAULT 0,result TEXT NOT NULL DEFAULT ''); CREATE TABLE IF NOT EXISTS group_permissions(group_id TEXT PRIMARY KEY,body TEXT NOT NULL); +CREATE TABLE IF NOT EXISTS device_consent_revisions(device_id TEXT PRIMARY KEY REFERENCES devices(id),revision INTEGER NOT NULL); PRAGMA user_version=1; diff --git a/product/management/src/service.rs b/product/management/src/service.rs index e494a450c8b..7060b323bf4 100644 --- a/product/management/src/service.rs +++ b/product/management/src/service.rs @@ -274,7 +274,7 @@ async fn device_state(State(s):State,headers:HeaderMap,Path(id):Path,headers:HeaderMap)->ApiResult { // Pending devices may inspect only their own identity, never session APIs. let device:String=s.db.lock().unwrap().query_row("SELECT id FROM devices WHERE token_hash=?1 AND state IN ('pending','approved')",[digest(token(&headers)?)],|row|row.get(0)).optional()?.ok_or_else(unauthorized)?; @@ -285,7 +285,18 @@ async fn device_status(State(s):State,headers:HeaderMap)->ApiResult { async fn consent(State(s):State,headers:HeaderMap,Json(input):Json)->ApiResult { let device=s.device(&headers)?; if input.unattended && !s.profile()?.allow_unattended {return Err(denied());} - s.db.lock().unwrap().execute("UPDATE devices SET unattended=?1 WHERE id=?2",params![input.unattended,device])?; + if input.revision>i64::MAX as u64 {return Err(bad("Invalid consent revision"));} + { + let mut db=s.db.lock().unwrap();let transaction=db.transaction()?; + let revision:u64=transaction.query_row("SELECT revision FROM device_consent_revisions WHERE device_id=?1",[&device],|row|row.get(0)).optional()?.unwrap_or(0); + let enabled:bool=transaction.query_row("SELECT unattended FROM devices WHERE id=?1",[&device],|row|row.get(0))?; + // Equal revisions are idempotent; revocation wins conflicting requests. + if input.revision,headers:HeaderMap,Path((group,user)):Path<(String,String)>)->ApiResult { diff --git a/product/management/src/tests.rs b/product/management/src/tests.rs index 0b354499e3a..6201adfea1f 100644 --- a/product/management/src/tests.rs +++ b/product/management/src/tests.rs @@ -65,6 +65,14 @@ async fn enrollment_mfa_and_grants_fail_closed() { assert_eq!(history.as_array().unwrap().len(),1); assert_eq!(history[0]["claimed"],true); assert_eq!(request(&app,"/api/v1/grants","POST",Some(admin),json!({"device_id":device_id,"proof_public_key":STANDARD.encode(proof.verifying_key().as_bytes()),"unattended":true})).await.0,StatusCode::FORBIDDEN,"Unattended access needs device consent"); + assert_eq!(request(&app,"/api/v1/device/consent","PUT",Some(credential),json!({"unattended":true,"revision":1})).await.0,StatusCode::OK); + assert_eq!(request(&app,"/api/v1/device/consent","PUT",Some(credential),json!({"unattended":false,"revision":2})).await.0,StatusCode::OK); + assert_eq!(request(&app,"/api/v1/device/consent","PUT",Some(credential),json!({"unattended":true,"revision":1})).await.0,StatusCode::CONFLICT,"Delayed enable cannot undo revocation"); + assert_eq!(request(&app,"/api/v1/device/consent","PUT",Some(credential),json!({"unattended":true,"revision":2})).await.0,StatusCode::CONFLICT,"Revocation wins same-revision races"); + assert_eq!(request(&app,"/api/v1/device/consent","PUT",Some(credential),json!({"unattended":true,"revision":3})).await.0,StatusCode::OK); + assert_eq!(request(&app,"/api/v1/device/consent","PUT",Some(credential),json!({"unattended":false,"revision":2})).await.0,StatusCode::CONFLICT,"Old retry cannot undo newer explicit consent"); + assert_eq!(request(&app,"/api/v1/device/consent","PUT",Some(credential),json!({"unattended":false,"revision":4})).await.0,StatusCode::OK); + assert_eq!(request(&app,"/api/v1/device/consent","PUT",Some(credential),json!({"unattended":false,"revision":4})).await.0,StatusCode::OK,"Revocation retries are idempotent"); let (_,new_user)=request(&app,"/api/v1/users","POST",Some(admin),json!({"username":"technician","password":"another long strong password","role":"technician"})).await; let tech_secret=new_user["totp_secret"].as_str().unwrap(); let tech_generator=TOTP::new(Algorithm::SHA1,6,1,30,Secret::Encoded(tech_secret.into()).to_bytes().unwrap()).unwrap(); From 22093f54dbe4d34d4a9e46085bdf005c29acd607 Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Fri, 2 Oct 2026 07:46:13 +0200 Subject: [PATCH 025/652] Verify offline consent recovery with real isolated HTTPS executables --- deployment/windows/LOCAL-TESTING.md | 14 ++++++++++ deployment/windows/Start-LocalTest.ps1 | 2 +- deployment/windows/local-https.js | 4 +++ deployment/windows/test-company-lifecycle.js | 27 +++++++++++++++++--- docs/ACCEPTANCE.md | 8 ++++++ docs/CONSENT_SYNC.md | 7 +++-- 6 files changed, 55 insertions(+), 7 deletions(-) diff --git a/deployment/windows/LOCAL-TESTING.md b/deployment/windows/LOCAL-TESTING.md index d1650c835c7..4f49302bcd0 100644 --- a/deployment/windows/LOCAL-TESTING.md +++ b/deployment/windows/LOCAL-TESTING.md @@ -1,5 +1,19 @@ # Local company-server testing +For offline-consent recovery tests, set `SWAN_TEST_FAULTS=1` in the private test +environment file. The loopback HTTPS proxy responds with HTTP 503 while a file +named `simulate-offline` exists inside that test data directory. There is no +HTTP control endpoint. The lifecycle script creates and removes this file, +checks durable local revocation while offline, then starts the real agent +watcher and verifies that recovered server authorization denies unattended +grants. Use this only with the isolated local test proxy. + +An optional second script argument selects a separately built executable +directory: `node deployment/windows/test-company-lifecycle.js PRIVATE_ENV_FILE +PRIVATE_BINARY_DIRECTORY`. This permits testing newer agent/server builds +without replacing running test or production executables. Recorded hashes use +that directory. + Each company runs its own management, rendezvous, relay and Windows build worker. The project does not operate a shared hosting or signing service. Copy `local-test.env.example` to `local-test.env`, build `product/Cargo.toml`, then run `Start-LocalTest.ps1` from the repository root. The launcher reads named settings as data and binds management to loopback. `local-test.env` and `swan-data/` are ignored by Git. The data directory contains the database, private profile-signing key and setup token; its Windows ACL permits the current user, administrators and SYSTEM. diff --git a/deployment/windows/Start-LocalTest.ps1 b/deployment/windows/Start-LocalTest.ps1 index 5df2e062fe6..20e9d0041a7 100644 --- a/deployment/windows/Start-LocalTest.ps1 +++ b/deployment/windows/Start-LocalTest.ps1 @@ -5,7 +5,7 @@ param( $ErrorActionPreference = 'Stop' if (!(Test-Path -LiteralPath $EnvironmentFile)) { throw 'Create local-test.env from local-test.env.example first.' } # Only named server settings are accepted; the file is data, never PowerShell code. -$allowed = @('SWAN_LISTEN', 'SWAN_DATA_DIR', 'SWAN_RELEASE_PUBLIC_KEY', 'SWAN_TEST_TLS_PORT', 'SWAN_TEST_TLS_PFX', 'SWAN_TEST_TLS_PASSWORD') +$allowed = @('SWAN_LISTEN', 'SWAN_DATA_DIR', 'SWAN_RELEASE_PUBLIC_KEY', 'SWAN_TEST_TLS_PORT', 'SWAN_TEST_TLS_PFX', 'SWAN_TEST_TLS_PASSWORD', 'SWAN_TEST_FAULTS') foreach ($line in Get-Content -LiteralPath $EnvironmentFile) { if (!$line.Trim() -or $line.TrimStart().StartsWith('#')) { continue } if ($line -notmatch '^([A-Z_]+)=(.*)$' -or $Matches[1] -notin $allowed) { throw 'Invalid local environment setting.' } diff --git a/deployment/windows/local-https.js b/deployment/windows/local-https.js index ef077a56aed..36b3195ce22 100644 --- a/deployment/windows/local-https.js +++ b/deployment/windows/local-https.js @@ -20,6 +20,10 @@ const server = https.createServer({ passphrase: settings.SWAN_TEST_TLS_PASSWORD, minVersion: 'TLSv1.2', }, (request, response) => { + // Explicit, local-only test fault. No HTTP endpoint can control this switch. + if (settings.SWAN_TEST_FAULTS === '1' && fs.existsSync(require('node:path').join(settings.SWAN_DATA_DIR,'simulate-offline'))) { + response.writeHead(503,{'content-type':'application/json'});response.end('{"error":"Isolated test outage"}');return; + } const upstream = http.request({hostname:'127.0.0.1', port:upstreamPort, path:request.url, method:request.method, headers:{...request.headers, host:`localhost:${upstreamPort}`}}, result => { response.writeHead(result.statusCode, result.headers); result.pipe(response); diff --git a/deployment/windows/test-company-lifecycle.js b/deployment/windows/test-company-lifecycle.js index 616b7d07f17..c19dca7437a 100644 --- a/deployment/windows/test-company-lifecycle.js +++ b/deployment/windows/test-company-lifecycle.js @@ -2,7 +2,7 @@ // This does not substitute for native remote desktop or clean-machine tests. const fs=require('node:fs'), path=require('node:path'), https=require('node:https'); const crypto=require('node:crypto'), assert=require('node:assert/strict'); -const {spawnSync}=require('node:child_process'); +const {spawnSync,spawn}=require('node:child_process'); const root=path.resolve(__dirname,'../..'); const envFile=process.argv[2]; if(!envFile)throw new Error('Pass a private local-test.env file for a fresh isolated server'); @@ -15,7 +15,8 @@ for(const line of fs.readFileSync(envFile,'utf8').split(/\r?\n/)){ assert.match(settings.SWAN_LISTEN??'',/^127\.0\.0\.1:\d+$/); assert(settings.SWAN_DATA_DIR,'A separate test data directory is required'); const data=path.resolve(settings.SWAN_DATA_DIR); -const agent=path.join(root,'product/target/debug/swan-agent.exe'); +const binaryDirectory=process.argv[3]?path.resolve(process.argv[3]):path.join(root,'product/target/debug'); +const agent=path.join(binaryDirectory,'swan-agent.exe'); const ca=new crypto.X509Certificate(fs.readFileSync(path.join(data,'localhost.cer'))).toString(); const port=Number(settings.SWAN_TEST_TLS_PORT);assert(Number.isInteger(port)&&port>=1024&&port<=65535); const base=`https://localhost:${port}`; @@ -62,7 +63,25 @@ async function main(){ assert.equal((await api(`groups/test-customers/users/${technician.id}`,'PUT',admin,{})).status,200); assert.equal(JSON.parse(runAgent(techDir,['devices'],auth)).length,1); runAgent(customerDir,['allow-unattended','--confirm-unattended']);assert.equal(state(customerDir).unattended_consent,true); - runAgent(customerDir,['revoke-unattended']);assert.equal(state(customerDir).unattended_consent,false); + if(settings.SWAN_TEST_FAULTS==='1') { + assert.equal((await api('grants','POST',admin,{...ticket,unattended:true})).status,200,'Enabled consent did not authorize unattended request'); + const outage=path.join(data,'simulate-offline');fs.writeFileSync(outage,'local test outage'); + try {runAgent(customerDir,['revoke-unattended']);assert.equal(state(customerDir).unattended_consent,false,'Offline revoke was not durable');} + finally {fs.unlinkSync(outage);} + assert.equal((await api('grants','POST',admin,{...ticket,unattended:true})).status,200,'Outage did not leave server consent unsynchronized'); + const watcher=spawn(agent,['watch'],{env:{...process.env,SWAN_STATE_DIR:customerDir,SWAN_TEST_CA_FILE:path.join(data,'localhost.cer')},stdio:'ignore'}); + let synchronized=false; + try { + const deadline=Date.now()+20000; + while(Date.now()setTimeout(resolve,200)); + } + assert(synchronized,'Background watcher did not synchronize offline revocation'); + assert.equal(state(customerDir).unattended_consent,false,'Recovery restored revoked consent'); + } finally {if(watcher.exitCode===null)watcher.kill();} + } else {runAgent(customerDir,['revoke-unattended']);assert.equal(state(customerDir).unattended_consent,false);} const policy=(await api('groups/test-customers/permissions','GET',admin)).body; assert.equal(policy.recording,false);policy.keyboard=false; assert.equal((await api('groups/test-customers/permissions','PUT',technicianLogin.token,policy)).status,401,'Technician changed group policy'); @@ -82,7 +101,7 @@ async function main(){ runAgent(techDir,['logout'],auth);assert.equal((await api('devices','GET',technicianLogin.token)).status,401,'Logged-out credential still authorized'); const source=spawnSync('git',['rev-parse','HEAD'],{cwd:root,encoding:'utf8'}); const dirty=spawnSync('git',['status','--porcelain'],{cwd:root,encoding:'utf8'}); - fs.writeFileSync(path.join(data,'lifecycle-result.json'),JSON.stringify({passed:true,at:new Date().toISOString(),source_commit:source.status===0?source.stdout.trim():'unknown',source_dirty:dirty.status===0?dirty.stdout.trim().length>0:null,agent_sha256:crypto.createHash('sha256').update(fs.readFileSync(agent)).digest('hex'),management_sha256:crypto.createHash('sha256').update(fs.readFileSync(path.join(root,'product/target/debug/swan-management.exe'))).digest('hex'),checks:['HTTPS certificate validation','fresh company setup','built agent bootstrap and enrollment','pending approval denial','technician MFA login and group inventory','technician session history','logout revokes credentials','unattended consent denial','grant replay denial','signed branding sync','device revocation']},null,2)); + fs.writeFileSync(path.join(data,'lifecycle-result.json'),JSON.stringify({passed:true,at:new Date().toISOString(),source_commit:source.status===0?source.stdout.trim():'unknown',source_dirty:dirty.status===0?dirty.stdout.trim().length>0:null,agent_sha256:crypto.createHash('sha256').update(fs.readFileSync(agent)).digest('hex'),management_sha256:crypto.createHash('sha256').update(fs.readFileSync(path.join(binaryDirectory,'swan-management.exe'))).digest('hex'),offline_revocation_recovery:settings.SWAN_TEST_FAULTS==='1',checks:['HTTPS certificate validation','fresh company setup','built agent bootstrap and enrollment','pending approval denial','technician MFA login and group inventory','technician session history','logout revokes credentials','unattended consent denial','grant replay denial','signed branding sync','device revocation']},null,2)); console.log('PASS: real local HTTPS company lifecycle and built-agent checks. Native sessions and installers remain separate tests.'); } main().catch(error=>{console.error(error.message);process.exitCode=1;}); diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index 06e97da24b2..8b114b0d61f 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -48,6 +48,14 @@ native support session or a clean-machine installation. ## Requirements still to demonstrate +The built product executables at `20f88e3` additionally passed a fresh-company +HTTPS lifecycle test with an offline-consent fault injected through the local +proxy. Revocation remained durable locally during HTTP 503 responses; the +server retained its older choice until the real agent watcher synchronized it, +then denied unattended grant requests. Executable hashes, exact harness source +dirty-state and the recovery result are recorded privately. Native sessions +remain unverified. + | Requirement | Current evidence and remaining work | | --- | --- | | Reproducible server, customer, technician and worker builds | Product components pass pinned builds. Native x64 build/package remains pending; baseline rebuild is not newly proved. | diff --git a/docs/CONSENT_SYNC.md b/docs/CONSENT_SYNC.md index 30ecc52bbe4..d0d48c430fa 100644 --- a/docs/CONSENT_SYNC.md +++ b/docs/CONSENT_SYNC.md @@ -30,5 +30,8 @@ not a production compatibility promise. The additive consent-revision table is included in management database backups. Component tests cover ordering, same-revision conflicts, idempotency, and stale -local enable responses. Offline network recovery and native Windows controls -still require end-to-end acceptance tests. +local enable responses. A real isolated HTTPS test with built agent/server +executables also passes: simulated outage, durable local revocation, unchanged +server consent during the outage, and background synchronization denying +unattended grants after recovery. This uses a test transport ID; native Windows +controls and actual unattended sessions remain unverified. From a035e14098c6c0310aeb3a6333bf8ac8f2f69844 Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Fri, 2 Oct 2026 07:49:32 +0200 Subject: [PATCH 026/652] Record verified initial releases and install durable technician files --- deployment/windows/Install-Company.ps1 | 22 +++++++++++++++++++++- deployment/windows/Open-Technician.ps1 | 10 ++-------- docs/ACCEPTANCE.md | 8 ++++++++ product/agent/src/main.rs | 7 +++++++ product/agent/src/update.rs | 15 +++++++++++++++ 5 files changed, 53 insertions(+), 9 deletions(-) diff --git a/deployment/windows/Install-Company.ps1 b/deployment/windows/Install-Company.ps1 index 256aff6a858..70054c820dc 100644 --- a/deployment/windows/Install-Company.ps1 +++ b/deployment/windows/Install-Company.ps1 @@ -53,6 +53,8 @@ if ($bootstrap.edition -eq 'customer') { & $agent @enrollmentArguments if ($LASTEXITCODE -ne 0) { throw 'Device enrollment failed. Access remains blocked.' } Copy-Item -LiteralPath $agent -Destination (Join-Path $editionDirectory 'swan-agent.exe') -Force + & $agent record-installation (Join-Path $root 'release.json') + if ($LASTEXITCODE -ne 0) { throw 'Installed application or configuration agent verification failed. Background updates were not enabled.' } $action = New-ScheduledTaskAction -Execute (Join-Path $editionDirectory 'swan-agent.exe') -Argument 'watch' $trigger = New-ScheduledTaskTrigger -AtStartup $settings = New-ScheduledTaskSettingsSet -RestartCount 3 -RestartInterval (New-TimeSpan -Minutes 1) -ExecutionTimeLimit ([TimeSpan]::Zero) @@ -60,5 +62,23 @@ if ($bootstrap.edition -eq 'customer') { Start-ScheduledTask -TaskName 'Swan Company Configuration' Write-Host 'Installed and enrolled. Your company must approve the device. Support status and stop controls remain available.' } else { - Write-Host 'Technician company profile verified. Run Open-Technician.ps1 to sign in and request support sessions.' + $releaseEnvelope = Get-Content -LiteralPath (Join-Path $root 'release.json') -Raw | ConvertFrom-Json + # The agent already verified this signed metadata and installer before any copy. + $release = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($releaseEnvelope.payload)) | ConvertFrom-Json + if ($release.format -ne 'exe' -or $release.sha256 -cne $release.installed_sha256) { throw 'Technician setup requires a portable EXE with matching installed identity.' } + $portable = Join-Path $editionDirectory 'SwanRemoteSupport-Technician.exe' + Copy-Item -LiteralPath $installers[0].FullName -Destination $portable -Force + Copy-Item -LiteralPath $agent -Destination (Join-Path $editionDirectory 'swan-agent.exe') -Force + & $agent record-installation (Join-Path $root 'release.json') + if ($LASTEXITCODE -ne 0) { throw 'Technician installed application or agent verification failed.' } + Copy-Item -LiteralPath (Join-Path $root 'Open-Technician.ps1') -Destination (Join-Path $editionDirectory 'Open-Technician.ps1') -Force + $shortcutPath = Join-Path ([Environment]::GetFolderPath('Programs')) 'Swan Remote Support Technician.lnk' + $shell = New-Object -ComObject WScript.Shell + $shortcut = $shell.CreateShortcut($shortcutPath) + $shortcut.TargetPath = Join-Path $env:SystemRoot 'System32/WindowsPowerShell/v1.0/powershell.exe' + $shortcut.Arguments = '-NoLogo -NoProfile -ExecutionPolicy Bypass -File "' + (Join-Path $editionDirectory 'Open-Technician.ps1') + '"' + $shortcut.WorkingDirectory = $editionDirectory + $shortcut.IconLocation = $portable + $shortcut.Save() + Write-Host 'Technician application installed. Use the Start menu shortcut to sign in and request support sessions.' } diff --git a/deployment/windows/Open-Technician.ps1 b/deployment/windows/Open-Technician.ps1 index bead6fabb28..0b20fbfbb95 100644 --- a/deployment/windows/Open-Technician.ps1 +++ b/deployment/windows/Open-Technician.ps1 @@ -2,19 +2,13 @@ param() $ErrorActionPreference = 'Stop' $env:SWAN_STATE_DIR = Join-Path $env:LOCALAPPDATA 'SwanRemoteSupport-Technician' -$agent = Join-Path $PSScriptRoot 'swan-agent.exe' +$agent = Join-Path $env:SWAN_STATE_DIR 'swan-agent.exe' if ((Get-AuthenticodeSignature -LiteralPath $agent).Status -ne 'Valid') { throw 'Technician agent requires a valid signature.' } & $agent sync if ($LASTEXITCODE -ne 0) { Write-Warning 'Company server unavailable. Cached branding can be displayed; authorization still requires valid company policy.' } -$app = Join-Path $PSScriptRoot 'SwanRemoteSupport-install.exe' $portable = Join-Path $env:SWAN_STATE_DIR 'SwanRemoteSupport-Technician.exe' if (-not (Test-Path -LiteralPath $portable)) { - if (-not (Test-Path -LiteralPath $app)) { throw 'Portable technician EXE is required.' } - & $agent verify-package (Join-Path $PSScriptRoot 'release.json') $app - if ($LASTEXITCODE -ne 0) { throw 'Technician package verification failed.' } - # The fixed filename avoids the portable packer opening installation mode. - Copy-Item -LiteralPath $app -Destination $portable - Copy-Item -LiteralPath (Join-Path $PSScriptRoot 'release.json') -Destination (Join-Path $env:SWAN_STATE_DIR 'installed-release.json') + throw 'Technician application is not installed. Run the company installation bundle.' } & $agent verify-installed if ($LASTEXITCODE -ne 0) { throw 'Technician installed identity or pinned publisher verification failed.' } diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index 8b114b0d61f..94b32cdebc2 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -48,6 +48,14 @@ native support session or a clean-machine installation. ## Requirements still to demonstrate +Initial setup now verifies installed endpoint and agent hashes/publishers against +signed release metadata and records the release sequence before enabling the +customer watcher. Technician setup copies its portable executable, agent and +launcher to a durable per-user directory and creates a Start menu shortcut; +launch no longer installs opportunistically from the extracted download. Rust +compilation/component tests and PowerShell parsing pass. Actual signed setup, +same-release repair, launcher use and uninstall remain unverified or incomplete. + The built product executables at `20f88e3` additionally passed a fresh-company HTTPS lifecycle test with an offline-consent fault injected through the local proxy. Revocation remained durable locally during HTTP 503 responses; the diff --git a/product/agent/src/main.rs b/product/agent/src/main.rs index d49d863850c..28d351b19b8 100644 --- a/product/agent/src/main.rs +++ b/product/agent/src/main.rs @@ -51,6 +51,13 @@ async fn main()->Result<()> { println!("Update installed: {}",state.update(&directory,token.as_deref()).await?); } #[cfg(windows)] + "record-installation"=>{ + let file=args.get(2).context("Usage: swan-agent record-installation release.json")?; + let envelope:SignedEnvelope=serde_json::from_slice(&std::fs::read(file)?)?; + let mut state=AgentState::load(&directory)?;state.record_installation(&directory,&envelope)?; + println!("Installed application and agent identities verified; release sequence recorded."); + } + #[cfg(windows)] "recover-update"=>{let mut state=AgentState::load_for_refresh(&directory)?;state.recover_update(&directory)?;println!("Installed release verified and update state recovered.");} #[cfg(windows)] "verify-installed"=>{ diff --git a/product/agent/src/update.rs b/product/agent/src/update.rs index 97af1ac70b1..52113c510d0 100644 --- a/product/agent/src/update.rs +++ b/product/agent/src/update.rs @@ -91,12 +91,27 @@ fn save_installed_metadata(directory:&Path,envelope:&SignedEnvelope)->Result<()> } impl AgentState { + #[cfg(windows)] + pub fn record_installation(&mut self,directory:&Path,envelope:&SignedEnvelope)->Result<()> { + let activity=activity_file(directory)?;fs2::FileExt::try_lock_exclusive(&activity).context("Installation verification requires all sessions to close")?; + let mut latest=AgentState::load(directory)?; + let release=validate_release(&latest,envelope)?; + verify_compatibility(directory,&release)?; + verify_installed(directory,&release)?; + let agent=directory.join("swan-agent.exe"); + ensure!(digest(std::fs::read(&agent)?).eq_ignore_ascii_case(&release.agent_sha256),"Installed configuration agent differs from signed release"); + let mut agent_release=release.clone();agent_release.sha256=release.agent_sha256.clone();verify_publisher(&agent,&agent_release)?; + save_installed_metadata(directory,envelope)?; + latest.last_release_sequence=release.sequence;latest.save(directory)?;*self=latest;Ok(()) + } #[cfg(windows)] pub fn recover_update(&mut self,directory:&Path)->Result<()> { let activity=activity_file(directory)?;fs2::FileExt::try_lock_exclusive(&activity).context("Recovery requires all sessions to close")?; let receipt_path=directory.join("pending-update.json"); let receipt:Receipt=serde_json::from_slice(&std::fs::read(&receipt_path)?)?; let release:Release=receipt.release.verify(&public_key(&self.bootstrap.release_public_key)?)?; + ensure!(receipt.phase=="installing","Unknown update recovery phase"); + release.validate(&self.bootstrap.edition,receipt.previous_sequence,now().min(release.expires_at.saturating_sub(1)))?; ensure!(release.edition==self.bootstrap.edition && release.product==PRODUCT && release.schema==SCHEMA,"Wrong recovery release"); ensure!(self.last_release_sequence==receipt.previous_sequence || self.last_release_sequence==release.sequence,"Recovery sequence conflict"); verify_installed(directory,&release)?; From 7e589badf477976235c09b02fd33da5cf5106921 Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Fri, 2 Oct 2026 07:52:22 +0200 Subject: [PATCH 027/652] Allow explicit repair only for exact pinned installed release --- deployment/windows/Install-Company.ps1 | 11 +++++--- docs/ACCEPTANCE.md | 6 +++-- docs/INSTALLER_REPAIR.md | 32 +++++++++++++++++++++++ product/agent/src/lib.rs | 18 +++++++++++++ product/agent/src/main.rs | 5 ++-- product/agent/src/update.rs | 36 +++++++++++++++++++++++--- 6 files changed, 96 insertions(+), 12 deletions(-) create mode 100644 docs/INSTALLER_REPAIR.md diff --git a/deployment/windows/Install-Company.ps1 b/deployment/windows/Install-Company.ps1 index 70054c820dc..817f3b3c8a0 100644 --- a/deployment/windows/Install-Company.ps1 +++ b/deployment/windows/Install-Company.ps1 @@ -1,5 +1,5 @@ [CmdletBinding()] -param([switch]$UnattendedConsent) +param([switch]$UnattendedConsent,[switch]$Repair) $ErrorActionPreference = 'Stop' $root = $PSScriptRoot $bootstrap = Get-Content -LiteralPath (Join-Path $root 'bootstrap.json') -Raw | ConvertFrom-Json @@ -36,7 +36,10 @@ if (-not (Test-Path -LiteralPath (Join-Path $editionDirectory 'managed-state.jso if ($LASTEXITCODE -ne 0) { throw 'Installer does not match the configured company, edition or trust keys.' } $installers = @(Get-ChildItem -LiteralPath $root -File | Where-Object { $_.Name -match '^SwanRemoteSupport-install\.(exe|msi)$' }) if ($installers.Count -ne 1) { throw 'Expected one company installer.' } -& $agent verify-package (Join-Path $root 'release.json') $installers[0].FullName +$packageArguments = @('verify-package',(Join-Path $root 'release.json'),$installers[0].FullName) +$recordArguments = @('record-installation',(Join-Path $root 'release.json')) +if ($Repair) { $packageArguments += '--repair'; $recordArguments += '--repair' } +& $agent @packageArguments if ($LASTEXITCODE -ne 0) { throw 'Installer metadata, hash or publisher validation failed.' } if ($bootstrap.edition -eq 'customer') { if ($installers[0].Extension -eq '.msi') { @@ -53,7 +56,7 @@ if ($bootstrap.edition -eq 'customer') { & $agent @enrollmentArguments if ($LASTEXITCODE -ne 0) { throw 'Device enrollment failed. Access remains blocked.' } Copy-Item -LiteralPath $agent -Destination (Join-Path $editionDirectory 'swan-agent.exe') -Force - & $agent record-installation (Join-Path $root 'release.json') + & $agent @recordArguments if ($LASTEXITCODE -ne 0) { throw 'Installed application or configuration agent verification failed. Background updates were not enabled.' } $action = New-ScheduledTaskAction -Execute (Join-Path $editionDirectory 'swan-agent.exe') -Argument 'watch' $trigger = New-ScheduledTaskTrigger -AtStartup @@ -69,7 +72,7 @@ if ($bootstrap.edition -eq 'customer') { $portable = Join-Path $editionDirectory 'SwanRemoteSupport-Technician.exe' Copy-Item -LiteralPath $installers[0].FullName -Destination $portable -Force Copy-Item -LiteralPath $agent -Destination (Join-Path $editionDirectory 'swan-agent.exe') -Force - & $agent record-installation (Join-Path $root 'release.json') + & $agent @recordArguments if ($LASTEXITCODE -ne 0) { throw 'Technician installed application or agent verification failed.' } Copy-Item -LiteralPath (Join-Path $root 'Open-Technician.ps1') -Destination (Join-Path $editionDirectory 'Open-Technician.ps1') -Force $shortcutPath = Join-Path ([Environment]::GetFolderPath('Programs')) 'Swan Remote Support Technician.lnk' diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index 94b32cdebc2..27626f8c5c8 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -8,7 +8,7 @@ native support session or a clean-machine installation. - Public baseline tag `swan-single-company-baseline-1.4.9` preserves the original Swan deployment and build instructions. Its source was not replaced. -- Seventeen product tests pass on the local Windows development host, including +- Eighteen product tests pass on the local Windows development host, including enrollment/trust preservation across stale writes, trusted key rotation, refusal to automatically reinstall a removed endpoint, and explicit consent changes surviving older refreshes. Consent API tests also reject delayed enable requests and older revocation retries; same-revision revocation takes precedence. The preceding eleven-test component source passed tests and release compilation on [Windows 2022 and Ubuntu 24.04 CI](https://github.com/rijff24/SwanRemoteSupport/actions/runs/36856716860) @@ -54,7 +54,9 @@ customer watcher. Technician setup copies its portable executable, agent and launcher to a durable per-user directory and creates a Start menu shortcut; launch no longer installs opportunistically from the extracted download. Rust compilation/component tests and PowerShell parsing pass. Actual signed setup, -same-release repair, launcher use and uninstall remain unverified or incomplete. +signed same-release repair, launcher use and uninstall remain unverified or incomplete. +The explicit repair trust gate passes component tests and requires exact pinned +metadata without allowing automatic-update replay; see INSTALLER_REPAIR.md. The built product executables at `20f88e3` additionally passed a fresh-company HTTPS lifecycle test with an offline-consent fault injected through the local diff --git a/docs/INSTALLER_REPAIR.md b/docs/INSTALLER_REPAIR.md new file mode 100644 index 00000000000..ecf892d7962 --- /dev/null +++ b/docs/INSTALLER_REPAIR.md @@ -0,0 +1,32 @@ +# Explicit company installer repair + +Run `Install-Company.ps1 -Repair` from the company's installation bundle after +reviewing the company HTTPS hostname. Customer installation requires +administrator access. Technician installation uses the current user's local +application directory. + +Repair requires a previously recorded installation and its signed +`installed-release.json`. The supplied release envelope must exactly match that +record, and its sequence must equal the durable installed sequence. The agent +verifies the company, edition, pinned signing key, release metadata expiry, +Windows compatibility, package hash and publisher before installation. Older +releases and a different package signed with the same sequence are rejected. +Pending automatic-update recovery must be resolved first. + +After installation, the agent verifies installed endpoint and configuration +agent identities, then records the same sequence. Device enrollment validates +the existing device and transport identity; it does not issue new credentials +or change existing unattended consent. Technician setup restores the portable +application, local launcher and Start menu shortcut. + +Automatic updates do not use the repair exception and still require an +increasing release sequence. An automatic update cannot reinstall an application +that was removed. Missing or expired repair metadata requires administrator +recovery or a newer approved company bundle; do not delete device state or lower +the recorded sequence to work around verification. + +Component tests cover the repair metadata trust gate and ordinary update replay +rejection. Complete signed EXE/MSI repair, interruption recovery, session +coordination during setup, and clean-machine behavior remain acceptance work. +This document describes the development implementation, not tested production +compatibility. diff --git a/product/agent/src/lib.rs b/product/agent/src/lib.rs index 6dad767abf5..ccf095c057f 100644 --- a/product/agent/src/lib.rs +++ b/product/agent/src/lib.rs @@ -280,6 +280,24 @@ mod tests { let mut changed=original;changed.profile_public_key=STANDARD.encode(SigningKey::from_bytes(&[12;32]).verifying_key().as_bytes());assert!(state.validate_installer_bootstrap(&changed).is_err()); } #[test] + fn explicit_repair_accepts_only_exact_pinned_release_without_weakening_updates() { + let mut state=fixture();let key=SigningKey::from_bytes(&[7;32]); + let mut release=Release{schema:1,product:PRODUCT.into(),version:"1.5.0".into(),sequence:2,edition:Edition::Customer,architecture:"x64".into(),channel:"test".into(),expires_at:now()+3600,artifact_url:"https://releases.example/package.exe".into(),sha256:"a".repeat(64),installed_sha256:"b".repeat(64),agent_url:"https://releases.example/agent.exe".into(),agent_sha256:"c".repeat(64),publisher:"Example".into(),publisher_certificate_sha256:"d".repeat(64),windows_versions:vec!["windows_11".into()],source_url:"https://releases.example/source.tar.gz".into(),format:"exe".into()}; + let pinned=SignedEnvelope::sign(&release,&key).unwrap(); + assert!(update::validate_repair_release(&state,&pinned,&pinned).is_err(),"Unrecorded installation cannot use repair"); + state.last_release_sequence=2; + assert!(update::validate_repair_release(&state,&pinned,&pinned).is_ok()); + assert!(update::validate_release(&state,&pinned).is_err(),"Automatic updates still reject current-sequence replay"); + release.sha256="e".repeat(64);let changed=SignedEnvelope::sign(&release,&key).unwrap(); + assert!(update::validate_repair_release(&state,&changed,&pinned).is_err(),"Even newly signed replacements at the same sequence require a new release"); + release.sequence=1;let older=SignedEnvelope::sign(&release,&key).unwrap(); + assert!(update::validate_repair_release(&state,&older,&older).is_err()); + release.sequence=2;release.expires_at=now();let expired=SignedEnvelope::sign(&release,&key).unwrap(); + assert!(update::validate_repair_release(&state,&expired,&expired).is_err()); + let mut tampered=pinned.clone();tampered.signature=STANDARD.encode([0u8;64]); + assert!(update::validate_repair_release(&state,&tampered,&pinned).is_err()); + } + #[test] fn explicit_consent_changes_survive_older_refreshes() { let directory=std::env::temp_dir().join(format!("swan-consent-{}",random_token())); let mut state=fixture();state.save(&directory).unwrap();let old_allowed=state.clone(); diff --git a/product/agent/src/main.rs b/product/agent/src/main.rs index 28d351b19b8..b0069c51355 100644 --- a/product/agent/src/main.rs +++ b/product/agent/src/main.rs @@ -54,7 +54,7 @@ async fn main()->Result<()> { "record-installation"=>{ let file=args.get(2).context("Usage: swan-agent record-installation release.json")?; let envelope:SignedEnvelope=serde_json::from_slice(&std::fs::read(file)?)?; - let mut state=AgentState::load(&directory)?;state.record_installation(&directory,&envelope)?; + let mut state=AgentState::load(&directory)?;state.record_installation(&directory,&envelope,args.iter().any(|value|value=="--repair"))?; println!("Installed application and agent identities verified; release sequence recorded."); } #[cfg(windows)] @@ -71,7 +71,8 @@ async fn main()->Result<()> { let metadata=args.get(2).context("Usage: swan-agent verify-package release.json INSTALLER")?; let package=args.get(3).context("Missing installer path")?; let envelope:SignedEnvelope=serde_json::from_slice(&std::fs::read(metadata)?)?; - swan_agent::update::verify_package(&state,&envelope,std::path::Path::new(package))?; + if args.iter().any(|value|value=="--repair") {swan_agent::update::verify_repair_package(&state,&directory,&envelope,std::path::Path::new(package))?;} + else{swan_agent::update::verify_package(&state,&directory,&envelope,std::path::Path::new(package))?;} println!("Installer metadata, bytes and publisher verified."); } "revoke-unattended"=>{ diff --git a/product/agent/src/update.rs b/product/agent/src/update.rs index 52113c510d0..708e1092e94 100644 --- a/product/agent/src/update.rs +++ b/product/agent/src/update.rs @@ -29,6 +29,26 @@ pub fn validate_release(state:&AgentState,envelope:&SignedEnvelope)->ResultResult { + state.company_profile()?; + ensure!(state.last_release_sequence>0,"Repair requires a previously recorded installation"); + let key=public_key(&state.bootstrap.release_public_key)?; + let release:Release=envelope.verify(&key)?; + let previous:Release=installed.verify(&key)?; + ensure!(envelope.payload==installed.payload && envelope.signature==installed.signature && release.sequence==previous.sequence && release.sequence==state.last_release_sequence,"Repair must use the exact currently pinned release"); + release.validate(&state.bootstrap.edition,state.last_release_sequence-1,now())?; + Ok(release) +} + +#[cfg(windows)] +fn installation_release(state:&AgentState,directory:&Path,envelope:&SignedEnvelope,repair:bool)->Result { + ensure!(!directory.join("pending-update.json").exists(),"Resolve pending update recovery before setup or repair"); + if repair { + let installed:SignedEnvelope=serde_json::from_slice(&std::fs::read(directory.join("installed-release.json"))?)?; + validate_repair_release(state,envelope,&installed) + }else{validate_release(state,envelope)} +} + async fn download(url:&str,hash:&str,path:&Path)->Result<()> { https_url(url)?; // Artifact endpoints may redirect to HTTPS object storage. No credentials @@ -52,13 +72,21 @@ fn verify_publisher(path:&Path,release:&Release)->Result<()> { } #[cfg(windows)] -pub fn verify_package(state:&AgentState,envelope:&SignedEnvelope,path:&Path)->Result<()> { - let release=validate_release(state,envelope)?; +pub fn verify_package(state:&AgentState,directory:&Path,envelope:&SignedEnvelope,path:&Path)->Result<()> { + let release=installation_release(state,directory,envelope,false)?; verify_compatibility(path.parent().context("Missing package directory")?,&release)?; ensure!(digest(std::fs::read(path)?).eq_ignore_ascii_case(&release.sha256),"Installer hash differs from signed metadata"); verify_publisher(path,&release) } +#[cfg(windows)] +pub fn verify_repair_package(state:&AgentState,directory:&Path,envelope:&SignedEnvelope,path:&Path)->Result<()> { + let release=installation_release(state,directory,envelope,true)?; + verify_compatibility(path.parent().context("Missing package directory")?,&release)?; + ensure!(digest(std::fs::read(path)?).eq_ignore_ascii_case(&release.sha256),"Repair installer hash differs from signed metadata"); + verify_publisher(path,&release) +} + #[cfg(windows)] fn verify_compatibility(directory:&Path,release:&Release)->Result<()> { let script=directory.join("Get-WindowsCompatibility.ps1"); @@ -92,10 +120,10 @@ fn save_installed_metadata(directory:&Path,envelope:&SignedEnvelope)->Result<()> impl AgentState { #[cfg(windows)] - pub fn record_installation(&mut self,directory:&Path,envelope:&SignedEnvelope)->Result<()> { + pub fn record_installation(&mut self,directory:&Path,envelope:&SignedEnvelope,repair:bool)->Result<()> { let activity=activity_file(directory)?;fs2::FileExt::try_lock_exclusive(&activity).context("Installation verification requires all sessions to close")?; let mut latest=AgentState::load(directory)?; - let release=validate_release(&latest,envelope)?; + let release=installation_release(&latest,directory,envelope,repair)?; verify_compatibility(directory,&release)?; verify_installed(directory,&release)?; let agent=directory.join("swan-agent.exe"); From f6e05f701be82c3fc939cd1490750eb7a5051830 Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Fri, 2 Oct 2026 07:53:06 +0200 Subject: [PATCH 028/652] Record successful dual-edition Windows build and pending updated verification --- docs/ACCEPTANCE.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index 27626f8c5c8..d4dd8cc41eb 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -29,8 +29,10 @@ native support session or a clean-machine installation. [pinned Windows x64 build at `c78a471`](https://github.com/rijff24/SwanRemoteSupport/actions/runs/36873808239) passed Rust/Flutter compilation, unsigned customer package generation, and product-component compilation. It predates the capability-policy changes. - A new build at `31b13aa` also produces a distinct technician portable artifact; - its outcome remains pending. + The [build at `31b13aa`](https://github.com/rijff24/SwanRemoteSupport/actions/runs/36967791061) + also passed and produces a distinct technician portable artifact. It predates + subsequent native file/audio capability guards, consent ordering, complete + backup and installation/repair changes. A new build at `7e589ba` is queued. - A fresh-company HTTPS test at `71ebbec` passed with rebuilt agent and server executables. It additionally verifies enrollment-preserving repair, rejection of a changed transport identity, explicit consent opt-in/revocation, From e391e9153ed098cf207613b75c62371f47c153ef Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Fri, 2 Oct 2026 07:59:47 +0200 Subject: [PATCH 029/652] Rehearse encrypted server restore against actual HTTPS management endpoints --- deployment/windows/VM-TESTING.md | 1 + deployment/windows/test-server-restore.js | 34 +++++++++++++++++++++++ docs/ACCEPTANCE.md | 2 +- docs/SERVER_BACKUP.md | 20 +++++++++++++ 4 files changed, 56 insertions(+), 1 deletion(-) create mode 100644 deployment/windows/test-server-restore.js diff --git a/deployment/windows/VM-TESTING.md b/deployment/windows/VM-TESTING.md index 905781c94b3..a8a9be05921 100644 --- a/deployment/windows/VM-TESTING.md +++ b/deployment/windows/VM-TESTING.md @@ -58,6 +58,7 @@ Official media starting points: - - +- - Evaluation expiration or missing supported media must be reported as an diff --git a/deployment/windows/test-server-restore.js b/deployment/windows/test-server-restore.js new file mode 100644 index 00000000000..e5024f5bf38 --- /dev/null +++ b/deployment/windows/test-server-restore.js @@ -0,0 +1,34 @@ +// Real HTTPS restore rehearsal for an isolated lifecycle fixture, not production. +const fs=require('node:fs'),path=require('node:path'),https=require('node:https'); +const crypto=require('node:crypto'),assert=require('node:assert/strict'); +function settings(file){const values={};for(const line of fs.readFileSync(file,'utf8').split(/\r?\n/)){if(!line.trim()||line.trimStart().startsWith('#'))continue;const match=/^([A-Z_]+)=(.*)$/.exec(line);assert(match,'Invalid private test environment');values[match[1]]=match[2];}assert.match(values.SWAN_LISTEN,/^127\.0\.0\.1:\d+$/);return values;} +function endpoint(values){const data=path.resolve(values.SWAN_DATA_DIR),port=Number(values.SWAN_TEST_TLS_PORT);assert(Number.isInteger(port)&&port>=1024&&port<=65535);return {data,base:`https://localhost:${port}`,ca:new crypto.X509Certificate(fs.readFileSync(path.join(data,'localhost.cer'))).toString()};} +function api(server,route,method='GET',token,body){return new Promise((resolve,reject)=>{const bytes=body===undefined?null:Buffer.from(JSON.stringify(body));const request=https.request(server.base+'/api/v1/'+route,{method,ca:server.ca,headers:{'content-type':'application/json',...(token?{authorization:'Bearer '+token}:{}),...(bytes?{'content-length':bytes.length}:{})}},response=>{const chunks=[];response.on('data',chunk=>chunks.push(chunk));response.on('end',()=>{try{resolve({status:response.statusCode,body:JSON.parse(Buffer.concat(chunks))});}catch(error){reject(error);}});});request.setTimeout(15000,()=>request.destroy(new Error('Test request timed out')));request.on('error',reject);if(bytes)request.write(bytes);request.end();});} +function totp(secret){let bits=0,value=0,decoded=[];for(const c of secret.replace(/=/g,'')){const index='ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'.indexOf(c);assert(index>=0);value=(value<<5)|index;bits+=5;if(bits>=8){decoded.push((value>>>(bits-8))&255);bits-=8;}}const counter=Buffer.alloc(8);counter.writeBigUInt64BE(BigInt(Math.floor(Date.now()/30000)+1));const hash=crypto.createHmac('sha1',Buffer.from(decoded)).update(counter).digest(),offset=hash[19]&15;return String((hash.readUInt32BE(offset)&0x7fffffff)%1000000).padStart(6,'0');} +function verifiedProfile(envelope,key){const der=Buffer.concat([Buffer.from('302a300506032b6570032100','hex'),Buffer.from(key,'base64')]);assert(crypto.verify(null,Buffer.from(envelope.payload,'base64'),{key:der,type:'spki',format:'der'},Buffer.from(envelope.signature,'base64')),'Restored profile signature is invalid');return JSON.parse(Buffer.from(envelope.payload,'base64'));} +async function main(){ + assert(process.argv[2]&&process.argv[3],'Pass original and restored private test environment files'); + const original=endpoint(settings(process.argv[2])),restored=endpoint(settings(process.argv[3]));assert.notEqual(original.data,restored.data);assert.notEqual(original.base,restored.base); + const status=(await api(original,'status')).body,newStatus=(await api(restored,'status')).body; + assert.equal(status.configured,true);assert.deepEqual(newStatus,status,'Company setup or trust key changed on restore'); + const profile=verifiedProfile((await api(original,'profile')).body,status.profile_public_key); + assert.deepEqual(verifiedProfile((await api(restored,'profile')).body,status.profile_public_key),profile,'Signed configuration changed on restore'); + const identity=JSON.parse(fs.readFileSync(path.join(original.data,'test-admin.private.json'))); + const loginBody={username:identity.username,password:identity.password,totp_code:totp(identity.totp_secret)}; + const oldLogin=await api(original,'login','POST',null,loginBody),newLogin=await api(restored,'login','POST',null,loginBody); + assert.equal(oldLogin.status,200,'Original test MFA login failed');assert.equal(newLogin.status,200,'Restored MFA login failed'); + const oldToken=oldLogin.body.token,newToken=newLogin.body.token; + try { + for(const route of ['devices','users','groups/test-customers/permissions']){ + const before=await api(original,route,'GET',oldToken),after=await api(restored,route,'GET',newToken); + assert.equal(before.status,200);assert.equal(after.status,200);assert.deepEqual(after.body,before.body,`Restored ${route} differs`); + } + const device=JSON.parse(fs.readFileSync(path.join(original.data,'customer-agent/managed-state.json'))); + assert.equal((await api(restored,'device/status','GET',device.device_token)).status,401,'Restore reenabled a revoked device'); + const proof=crypto.generateKeyPairSync('ed25519').publicKey.export({type:'spki',format:'der'}).subarray(-32).toString('base64'); + assert.equal((await api(restored,'grants','POST',newToken,{device_id:device.device_id,proof_public_key:proof,unattended:false})).status,403,'Restored server issued a grant for a revoked device'); + fs.writeFileSync(path.join(restored.data,'restore-result.json'),JSON.stringify({passed:true,at:new Date().toISOString(),company_id:profile.company_id,checks:['HTTPS certificate validation','configuration signing key preserved','signed profile preserved','administrator MFA login','accounts and devices preserved','group capability policy preserved','revoked device denied','revoked-device grant denied']},null,2)); + } finally {await api(original,'logout','POST',oldToken);await api(restored,'logout','POST',newToken);} + console.log('PASS: real HTTPS server restore preserves company trust, MFA, device state and group policy; revoked access remains denied.'); +} +main().catch(error=>{console.error(error.message);process.exitCode=1;}); diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index d4dd8cc41eb..961c8d224a0 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -82,7 +82,7 @@ remain unverified. | Windows worker and public/protected downloads | Queue, worker, fixed bundles and access checks are implemented. A real signed worker job, both EXE/MSI installation products, protected technician download and public customer website installation remain unverified or incomplete. | | Signing and exact corresponding source | Certificate/hash/publisher pins and source metadata are implemented. Provider approval, production signatures, company signing integration and production release checks remain gates. | | Approved automatic updates | Metadata replay/expiry and session exclusion tests pass. Real rollout, pause/windows, full payload/agent identity preservation, technician executable handoff, interruption repair/retry/rollback and recovery remain incomplete or unverified. | -| Backup, restore and migrations | Encrypted round-trip/tamper checks cover management data, transport trust keys and SQLite state, deployment configuration and an explicit TLS identity. Protected Windows restore passes component tests. Live server restore, complete certificate-service storage and migration rollback remain unverified or incomplete; see SERVER_BACKUP.md. | +| Backup, restore and migrations | Encrypted round-trip/tamper checks cover management data, transport trust keys and SQLite state, deployment configuration and an explicit TLS identity. Actual CLI export/restore and a live isolated HTTPS management-server rehearsal preserve signing trust, MFA, accounts, device state and group policy; revoked access remains denied. Restored SQLite quick_check passes. Live transport recovery, complete certificate-service storage, post-backup reconciliation and migration rollback remain unverified or incomplete; see SERVER_BACKUP.md. | ## Clean Windows matrix diff --git a/docs/SERVER_BACKUP.md b/docs/SERVER_BACKUP.md index 71509d45855..9f1234de73e 100644 --- a/docs/SERVER_BACKUP.md +++ b/docs/SERVER_BACKUP.md @@ -68,3 +68,23 @@ release approvals. Reconcile any revocations made after the backup. Installed clients reject profiles older than their cached revision, so recover a profile revision newer than those clients have accepted before attempting normal sync. These restore rehearsals and migration rollback checks remain release gates. + +## Isolated restore rehearsal + +`node deployment/windows/test-server-restore.js ORIGINAL_PRIVATE_ENV +RESTORED_PRIVATE_ENV` checks two separately running local HTTPS test servers. +Use only the isolated fixture created by `test-company-lifecycle.js`; its private +administrator credentials and customer state must remain in protected test +directories. Never point this harness at a company production deployment. + +The actual management CLI at `20f88e3` exported and restored that fixture with +its private deployment environment and TLS identity. Hash comparisons preserved +the profile-signing material, setup token, environment and TLS identity. The +restored SQLite database passed `PRAGMA quick_check`. The restored server passed +real HTTPS verification of signed configuration, administrator MFA, accounts, +device state, group capabilities, and denial of a revoked device and its session +grant. Private results are recorded in the restored test directory. + +This does not demonstrate live hbbs/hbbr recovery, certificate renewal, recovery +of changes after the backup, profile revision reconciliation or migration +rollback. Those remain acceptance requirements. From 26dab7b4216c842256e6f46f70b3958df3ce53eb Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Fri, 2 Oct 2026 08:10:18 +0200 Subject: [PATCH 030/652] Verify signed installed payload manifests including native libraries and assets --- .github/workflows/swan-windows-build.yml | 9 +++ .../windows/generate-payload-manifest.py | 65 +++++++++++++++++++ deployment/windows/test_payload_manifest.py | 51 +++++++++++++++ docs/ACCEPTANCE.md | 4 +- docs/RELEASE_PAYLOAD.md | 42 ++++++++++++ product/agent/Cargo.toml | 2 +- product/agent/src/lib.rs | 3 +- product/agent/src/payload.rs | 52 +++++++++++++++ product/agent/src/update.rs | 13 ++++ product/protocol/src/lib.rs | 33 +++++++++- 10 files changed, 269 insertions(+), 5 deletions(-) create mode 100644 deployment/windows/generate-payload-manifest.py create mode 100644 deployment/windows/test_payload_manifest.py create mode 100644 docs/RELEASE_PAYLOAD.md create mode 100644 product/agent/src/payload.rs diff --git a/.github/workflows/swan-windows-build.yml b/.github/workflows/swan-windows-build.yml index b12ba8cd877..bbcdf6e53c0 100644 --- a/.github/workflows/swan-windows-build.yml +++ b/.github/workflows/swan-windows-build.yml @@ -153,6 +153,13 @@ jobs: sha256sum SwanRemoteSupport-*.exe | tee SHA256SUMS.txt popd + - name: Generate unsigned installed payload manifests + shell: pwsh + run: | + python3 -m unittest discover -s deployment/windows -p test_payload_manifest.py + 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: | @@ -174,4 +181,6 @@ jobs: artifacts/Install-Company.ps1 artifacts/Open-Technician.ps1 artifacts/Verify-Package.ps1 + artifacts/Get-WindowsCompatibility.ps1 + artifacts/*-installed-files.unsigned.json artifacts/LICENCE diff --git a/deployment/windows/generate-payload-manifest.py b/deployment/windows/generate-payload-manifest.py new file mode 100644 index 00000000000..0a6467c773d --- /dev/null +++ b/deployment/windows/generate-payload-manifest.py @@ -0,0 +1,65 @@ +"""Generate release installed_files input after final binary signing. + +Unsigned CI output is only a draft and must be regenerated after signing. +This script never signs metadata or executes payload files. +""" +import argparse +import hashlib +import json +from pathlib import Path + + +def digest(file): + if file.stat().st_size > 512 * 1024 * 1024: + raise ValueError("Payload file exceeds limit") + with file.open("rb") as stream: + result = hashlib.sha256() + for chunk in iter(lambda: stream.read(1024 * 1024), b""): + result.update(chunk) + return result.hexdigest() + + +def manifest(edition, source): + source = source.resolve(strict=True) + if edition == "technician": + if not source.is_file() or source.suffix.lower() != ".exe": + raise ValueError("Technician source must be the complete portable EXE") + return [{"path": "SwanRemoteSupport-Technician.exe", "sha256": digest(source)}] + if not source.is_dir(): + raise ValueError("Customer source must be the final Flutter payload directory") + files = [] + names = set() + for file in sorted(source.rglob("*")): + if file.is_symlink() or getattr(file.lstat(), "st_file_attributes", 0) & 0x400: + raise ValueError("Payload links are not allowed") + if not file.is_file(): + continue + name = file.relative_to(source).as_posix() + if name.lower() == "rustdesk.exe": + name = "Swan Remote Support.exe" + if name.lower() in names or name.lower() == "runtimebroker_rustdesk.exe": + raise ValueError("Duplicate or Windows-owned payload file") + names.add(name.lower()) + files.append({"path": name, "sha256": digest(file)}) + if len(files) > 4096: + raise ValueError("Payload manifest exceeds limit") + if not {"swan remote support.exe", "librustdesk.dll", "flutter_windows.dll"} <= names: + raise ValueError("Required customer executable or native libraries missing") + return files + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--edition", choices=("customer", "technician"), required=True) + parser.add_argument("--source", type=Path, required=True) + parser.add_argument("--output", type=Path, required=True) + args = parser.parse_args() + files = manifest(args.edition, args.source) + with args.output.open("x", encoding="utf-8") as stream: + json.dump(files, stream, indent=2) + stream.write("\n") + print(f"Generated {len(files)} installed payload identities; metadata is unsigned.") + + +if __name__ == "__main__": + main() diff --git a/deployment/windows/test_payload_manifest.py b/deployment/windows/test_payload_manifest.py new file mode 100644 index 00000000000..44f265b2786 --- /dev/null +++ b/deployment/windows/test_payload_manifest.py @@ -0,0 +1,51 @@ +import importlib.util +import json +from pathlib import Path +import subprocess +import sys +import tempfile +import unittest + +SCRIPT = Path(__file__).with_name("generate-payload-manifest.py") +SPEC = importlib.util.spec_from_file_location("payload_manifest", SCRIPT) +MODULE = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(MODULE) + + +class PayloadManifestTests(unittest.TestCase): + def test_customer_manifest_maps_executable_and_covers_libraries_and_assets(self): + with tempfile.TemporaryDirectory(prefix="swan-manifest-") as directory: + root = Path(directory) + for name in ["rustdesk.exe", "librustdesk.dll", "flutter_windows.dll", "data/assets.json"]: + file = root / name + file.parent.mkdir(parents=True, exist_ok=True) + file.write_bytes(name.encode()) + files = {file["path"]: file["sha256"] for file in MODULE.manifest("customer", root)} + self.assertEqual(len(files), 4) + self.assertEqual(files["Swan Remote Support.exe"], MODULE.digest(root / "rustdesk.exe")) + self.assertIn("data/assets.json", files) + (root / "flutter_windows.dll").unlink() + with self.assertRaises(ValueError): + MODULE.manifest("customer", root) + + def test_technician_manifest_covers_complete_packed_executable(self): + with tempfile.TemporaryDirectory(prefix="swan-manifest-") as directory: + file = Path(directory) / "technician.exe" + file.write_bytes(b"portable packed executable fixture") + self.assertEqual(MODULE.manifest("technician", file), [{"path": "SwanRemoteSupport-Technician.exe", "sha256": MODULE.digest(file)}]) + + def test_cli_generates_json_without_overwriting_previous_output(self): + with tempfile.TemporaryDirectory(prefix="swan-manifest-") as directory: + file = Path(directory) / "technician.exe" + output = Path(directory) / "manifest.json" + file.write_bytes(b"portable fixture") + command = [sys.executable, str(SCRIPT), "--edition", "technician", "--source", str(file), "--output", str(output)] + self.assertEqual(subprocess.run(command, capture_output=True).returncode, 0) + original = output.read_bytes() + self.assertEqual(json.loads(original)[0]["sha256"], MODULE.digest(file)) + self.assertNotEqual(subprocess.run(command, capture_output=True).returncode, 0) + self.assertEqual(output.read_bytes(), original) + + +if __name__ == "__main__": + unittest.main() diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index 961c8d224a0..cdc2854eea7 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -8,7 +8,7 @@ native support session or a clean-machine installation. - Public baseline tag `swan-single-company-baseline-1.4.9` preserves the original Swan deployment and build instructions. Its source was not replaced. -- Eighteen product tests pass on the local Windows development host, including +- Twenty product tests pass on the local Windows development host, including enrollment/trust preservation across stale writes, trusted key rotation, refusal to automatically reinstall a removed endpoint, and explicit consent changes surviving older refreshes. Consent API tests also reject delayed enable requests and older revocation retries; same-revision revocation takes precedence. The preceding eleven-test component source passed tests and release compilation on [Windows 2022 and Ubuntu 24.04 CI](https://github.com/rijff24/SwanRemoteSupport/actions/runs/36856716860) @@ -81,7 +81,7 @@ remain unverified. | Public company transport without Tailscale | Source uses company transport and fails closed before setup. External NAT traversal, relay fallback and negative-access tests pending; no public endpoint has been changed. | | Windows worker and public/protected downloads | Queue, worker, fixed bundles and access checks are implemented. A real signed worker job, both EXE/MSI installation products, protected technician download and public customer website installation remain unverified or incomplete. | | Signing and exact corresponding source | Certificate/hash/publisher pins and source metadata are implemented. Provider approval, production signatures, company signing integration and production release checks remain gates. | -| Approved automatic updates | Metadata replay/expiry and session exclusion tests pass. Real rollout, pause/windows, full payload/agent identity preservation, technician executable handoff, interruption repair/retry/rollback and recovery remain incomplete or unverified. | +| Approved automatic updates | Metadata replay/expiry and session exclusion tests pass. Signed installed manifests now bound customer DLLs/assets and reject unlisted native code in component tests; real signed installation remains unverified. Real rollout, pause/windows, agent replacement, technician executable handoff, interruption repair/retry/rollback and recovery remain incomplete or unverified; see RELEASE_PAYLOAD.md. | | Backup, restore and migrations | Encrypted round-trip/tamper checks cover management data, transport trust keys and SQLite state, deployment configuration and an explicit TLS identity. Actual CLI export/restore and a live isolated HTTPS management-server rehearsal preserve signing trust, MFA, accounts, device state and group policy; revoked access remains denied. Restored SQLite quick_check passes. Live transport recovery, complete certificate-service storage, post-backup reconciliation and migration rollback remain unverified or incomplete; see SERVER_BACKUP.md. | ## Clean Windows matrix diff --git a/docs/RELEASE_PAYLOAD.md b/docs/RELEASE_PAYLOAD.md new file mode 100644 index 00000000000..e18427a552e --- /dev/null +++ b/docs/RELEASE_PAYLOAD.md @@ -0,0 +1,42 @@ +# Signed installed payload identity + +Release metadata now requires `installed_files`, an array of objects containing +relative `path` and SHA-256 `sha256`. Customer manifests cover the final Flutter +payload: the installed Swan executable, native libraries and application assets. +The installed main executable hash must also equal `installed_sha256`. +Technician manifests contain one entry for `SwanRemoteSupport-Technician.exe`; +that hash covers its complete self-extracting payload. + +Generate release inputs after signing the final binaries: + +```text +python deployment/windows/generate-payload-manifest.py --edition customer --source FINAL_FLUTTER_DIRECTORY --output customer-installed-files.json +python deployment/windows/generate-payload-manifest.py --edition technician --source SIGNED_PORTABLE_EXE --output technician-installed-files.json +``` + +For customer packaging, the generator maps the build's `rustdesk.exe` to the +installed `Swan Remote Support.exe`. Include the resulting array in release +metadata and sign that metadata with the configured release-signing key. +Unsigned CI manifest drafts identify unsigned build bytes only; regenerate them +after signing. Do not relabel the drafts as signed release evidence. + +Release validation rejects duplicate Windows path aliases, traversal, alternate +streams, reserved names, missing core native libraries and an inconsistent main +executable identity. Initial installation, repair, update completion, installed +metadata verification and recovery verify customer file hashes and reject +unlisted DLLs or EXEs. Paths resolving outside the installed directory fail. +The agent has its separate signed release hash and is checked during initial +installation; agent replacement during updates remains incomplete. + +RustDesk's copied `RuntimeBroker_rustdesk.exe` is Windows-owned and cannot be +listed in project metadata. Verification instead requires its bytes to match +`RuntimeBroker.exe` in the Windows system directory returned by the OS API. +Windows servicing can change those bytes, requiring the installed copy to be +refreshed. Compatibility and repair behavior after Windows servicing still need +clean-machine testing. + +This is a development metadata change. Previously generated releases without +the mandatory manifest are rejected; do not weaken validation to import them. +No production compatibility or signing approval is implied. Full real signed +payload installation, update replacement, rollback and native behavior remain +acceptance requirements. diff --git a/product/agent/Cargo.toml b/product/agent/Cargo.toml index c18f61a3b20..d7ee52250ed 100644 --- a/product/agent/Cargo.toml +++ b/product/agent/Cargo.toml @@ -22,4 +22,4 @@ axum = "0.8" fs2 = "0.4" [target.'cfg(windows)'.dependencies] -windows-sys = { version = "0.61", features = ["Win32_Storage_FileSystem"] } +windows-sys = { version = "0.61", features = ["Win32_Storage_FileSystem", "Win32_System_SystemInformation"] } diff --git a/product/agent/src/lib.rs b/product/agent/src/lib.rs index ccf095c057f..be94951edea 100644 --- a/product/agent/src/lib.rs +++ b/product/agent/src/lib.rs @@ -7,6 +7,7 @@ use std::path::{Path, PathBuf}; use swan_protocol::*; pub use swan_protocol as protocol; pub mod update; +pub mod payload; pub mod technician; #[derive(Clone, Serialize, Deserialize)] @@ -282,7 +283,7 @@ mod tests { #[test] fn explicit_repair_accepts_only_exact_pinned_release_without_weakening_updates() { let mut state=fixture();let key=SigningKey::from_bytes(&[7;32]); - let mut release=Release{schema:1,product:PRODUCT.into(),version:"1.5.0".into(),sequence:2,edition:Edition::Customer,architecture:"x64".into(),channel:"test".into(),expires_at:now()+3600,artifact_url:"https://releases.example/package.exe".into(),sha256:"a".repeat(64),installed_sha256:"b".repeat(64),agent_url:"https://releases.example/agent.exe".into(),agent_sha256:"c".repeat(64),publisher:"Example".into(),publisher_certificate_sha256:"d".repeat(64),windows_versions:vec!["windows_11".into()],source_url:"https://releases.example/source.tar.gz".into(),format:"exe".into()}; + let mut release=Release{schema:1,product:PRODUCT.into(),version:"1.5.0".into(),sequence:2,edition:Edition::Customer,architecture:"x64".into(),channel:"test".into(),expires_at:now()+3600,artifact_url:"https://releases.example/package.exe".into(),sha256:"a".repeat(64),installed_sha256:"b".repeat(64),installed_files:vec![InstalledFile{path:"Swan Remote Support.exe".into(),sha256:"b".repeat(64)},InstalledFile{path:"librustdesk.dll".into(),sha256:"c".repeat(64)},InstalledFile{path:"flutter_windows.dll".into(),sha256:"d".repeat(64)}],agent_url:"https://releases.example/agent.exe".into(),agent_sha256:"c".repeat(64),publisher:"Example".into(),publisher_certificate_sha256:"d".repeat(64),windows_versions:vec!["windows_11".into()],source_url:"https://releases.example/source.tar.gz".into(),format:"exe".into()}; let pinned=SignedEnvelope::sign(&release,&key).unwrap(); assert!(update::validate_repair_release(&state,&pinned,&pinned).is_err(),"Unrecorded installation cannot use repair"); state.last_release_sequence=2; diff --git a/product/agent/src/payload.rs b/product/agent/src/payload.rs new file mode 100644 index 00000000000..93dc89da097 --- /dev/null +++ b/product/agent/src/payload.rs @@ -0,0 +1,52 @@ +//! Verify the signed installed payload, including native libraries and assets. +use anyhow::{ensure,Context,Result}; +use std::path::Path; +use swan_protocol::{digest,InstalledFile}; + +pub fn verify(root:&Path,files:&[InstalledFile])->Result<()> { + ensure!(!files.is_empty() && files.len()<=4096,"Installed payload manifest required"); + let root=std::fs::canonicalize(root)?; + let mut expected=std::collections::HashSet::new(); + for file in files { + file.validate()?; + ensure!(expected.insert(file.path.to_ascii_lowercase()),"Duplicate installed file path"); + let path=std::fs::canonicalize(root.join(&file.path)).context("Installed payload file missing")?; + ensure!(path.starts_with(&root) && path.is_file(),"Installed payload escapes its directory"); + ensure!(std::fs::metadata(&path)?.len()<=512*1024*1024,"Installed payload file exceeds size limit"); + ensure!(digest(std::fs::read(path)?).eq_ignore_ascii_case(&file.sha256),"Installed payload hash differs from signed manifest"); + } + // Additional native code can be loaded without changing the main EXE. + // Reject unlisted DLLs/EXEs rather than treating them as ordinary user data. + let mut directories=vec![(root.clone(),0u32)];let mut count=0usize; + while let Some((directory,depth))=directories.pop() { + ensure!(depth<=32,"Installed directory nesting exceeds limit"); + for entry in std::fs::read_dir(directory)? { + let entry=entry?;count+=1;ensure!(count<=16384,"Installed directory exceeds verification limit"); + let path=entry.path();let canonical=std::fs::canonicalize(&path)?; + ensure!(canonical.starts_with(&root),"Installed directory contains an external link"); + if canonical.is_dir(){directories.push((path,depth+1));continue;} + let extension=path.extension().and_then(|value|value.to_str()).unwrap_or(""); + if extension.eq_ignore_ascii_case("exe") || extension.eq_ignore_ascii_case("dll") { + let relative=path.strip_prefix(&root)?.to_str().context("Invalid installed filename")?.replace('\\',"/").to_ascii_lowercase(); + ensure!(expected.contains(&relative),"Installed native code is absent from signed manifest"); + } + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn payload_rejects_library_asset_tampering_missing_files_and_extra_native_code(){ + let root=std::env::temp_dir().join(format!("swan-payload-{}",swan_protocol::random_token())); + std::fs::create_dir_all(root.join("data")).unwrap(); + let files:Vec<_>=[("endpoint.exe",b"executable".as_slice()),("library.dll",b"library".as_slice()),("data/assets.json",b"assets".as_slice())].into_iter().map(|(path,bytes)|{std::fs::write(root.join(path),bytes).unwrap();InstalledFile{path:path.into(),sha256:digest(bytes)}}).collect(); + verify(&root,&files).unwrap(); + for (path,original) in [("library.dll",b"library".as_slice()),("data/assets.json",b"assets".as_slice())]{std::fs::write(root.join(path),b"tampered").unwrap();assert!(verify(&root,&files).is_err());std::fs::write(root.join(path),original).unwrap();} + std::fs::write(root.join("injected.dll"),b"unlisted native code").unwrap();assert!(verify(&root,&files).is_err());std::fs::remove_file(root.join("injected.dll")).unwrap(); + std::fs::remove_file(root.join("library.dll")).unwrap();assert!(verify(&root,&files).is_err()); + std::fs::remove_dir_all(root).unwrap(); + } +} diff --git a/product/agent/src/update.rs b/product/agent/src/update.rs index 708e1092e94..5ce6b518215 100644 --- a/product/agent/src/update.rs +++ b/product/agent/src/update.rs @@ -205,6 +205,19 @@ impl AgentState { #[cfg(windows)] fn verify_installed(directory:&Path,release:&Release)->Result<()> { let target=installed_target(directory,&release.edition)?; + // Portable technician state also contains the agent and updater. Its single + // packed EXE is verified separately; customer files share an install root. + if release.edition==Edition::Customer { + let mut files=release.installed_files.clone(); + // RustDesk copies this Windows-owned process for input/privacy support. + // It varies with Windows updates and is not a project release artifact. + let mut system_directory=[0u16;32768]; + let length=unsafe{windows_sys::Win32::System::SystemInformation::GetSystemDirectoryW(system_directory.as_mut_ptr(),system_directory.len() as u32)} as usize; + ensure!(length>0 && lengthResult<()> { + ensure!(!self.path.is_empty() && self.path.len()<=240 && self.sha256.len()==64 && self.sha256.bytes().all(|byte|byte.is_ascii_hexdigit()),"Invalid installed file identity"); + for component in self.path.split('/') { + ensure!(!component.is_empty() && component!="." && component!=".." && !component.ends_with(['.',' ']) && component.bytes().all(|byte|byte.is_ascii_alphanumeric() || b" _-.@+".contains(&byte)),"Unsafe installed file path"); + let stem=component.split('.').next().unwrap_or("").to_ascii_uppercase(); + ensure!(!["CON","PRN","AUX","NUL","COM1","COM2","COM3","COM4","COM5","COM6","COM7","COM8","COM9","LPT1","LPT2","LPT3","LPT4","LPT5","LPT6","LPT7","LPT8","LPT9"].contains(&stem.as_str()),"Reserved installed file path"); + } + Ok(()) + } +} + #[derive(Clone, Debug, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct Release { @@ -180,6 +195,7 @@ pub struct Release { pub artifact_url: String, pub sha256: String, pub installed_sha256: String, + pub installed_files:Vec, pub agent_url: String, pub agent_sha256: String, pub publisher: String, @@ -195,6 +211,14 @@ impl Release { ensure!(["stable", "test"].contains(&self.channel.as_str()) && ["exe", "msi"].contains(&self.format.as_str()), "Invalid release type"); ensure!(self.sha256.len() == 64 && self.sha256.bytes().all(|x| x.is_ascii_hexdigit()) && !self.publisher.is_empty(), "Invalid artifact identity"); ensure!(self.installed_sha256.len()==64 && self.installed_sha256.bytes().all(|x|x.is_ascii_hexdigit()),"Invalid installed executable identity"); + ensure!(!self.installed_files.is_empty() && self.installed_files.len()<=4096,"Installed payload manifest required"); + let mut paths=std::collections::HashSet::new(); + for file in &self.installed_files {file.validate()?;ensure!(!file.path.eq_ignore_ascii_case("RuntimeBroker_rustdesk.exe"),"Windows-owned helper cannot be supplied by project release metadata");ensure!(paths.insert(file.path.to_ascii_lowercase()),"Duplicate installed file path");} + let executable=if self.edition==Edition::Technician {"SwanRemoteSupport-Technician.exe"}else{"Swan Remote Support.exe"}; + ensure!(self.installed_files.iter().any(|file|file.path.eq_ignore_ascii_case(executable) && file.sha256.eq_ignore_ascii_case(&self.installed_sha256)),"Manifest must contain the installed executable identity"); + if self.edition==Edition::Customer { + for library in ["librustdesk.dll","flutter_windows.dll"]{ensure!(paths.contains(library),"Customer payload manifest lacks a required native library");} + }else{ensure!(self.installed_files.len()==1,"Portable technician payload must identify its complete packed executable");} ensure!(self.publisher_certificate_sha256.len()==64 && self.publisher_certificate_sha256.bytes().all(|x|x.is_ascii_hexdigit()),"Invalid publisher certificate identity"); ensure!(!self.windows_versions.is_empty() && self.windows_versions.len()<=6 && self.windows_versions.iter().all(|v|WINDOWS_VERSIONS.contains(&v.as_str())),"Invalid Windows compatibility declaration"); let fields: Vec<_> = self.version.split('.').collect(); @@ -275,8 +299,15 @@ mod tests { assert!(!maintenance_open(22,3,12*3600)); assert!(maintenance_open(0,0,12*3600)); } #[test] + fn installed_file_paths_reject_windows_aliases_and_traversal() { + for path in ["../outside.dll","/absolute.dll","C:/outside.dll","data\\outside.dll","file.dll:stream","file.dll.","data//file.dll","CON.txt","data/LPT1.dll","data/../file.dll"] { + assert!(InstalledFile{path:path.into(),sha256:"a".repeat(64)}.validate().is_err(),"Accepted unsafe path {path}"); + } + assert!(InstalledFile{path:"data/flutter_assets/fonts/MaterialIcons-Regular.otf".into(),sha256:"a".repeat(64)}.validate().is_ok()); + } + #[test] fn releases_reject_replay_wrong_edition_expiry_and_missing_installed_identity() { - let mut release=Release {schema:1,product:PRODUCT.into(),version:"1.5.0".into(),sequence:2,edition:Edition::Customer,architecture:"x64".into(),channel:"stable".into(),expires_at:200,artifact_url:"https://releases.example/package.exe".into(),sha256:"a".repeat(64),installed_sha256:"b".repeat(64),agent_url:"https://releases.example/agent.exe".into(),agent_sha256:"c".repeat(64),publisher:"Example".into(),publisher_certificate_sha256:"d".repeat(64),windows_versions:vec!["windows_11".into()],source_url:"https://releases.example/source.tar.gz".into(),format:"exe".into()}; + let mut release=Release {schema:1,product:PRODUCT.into(),version:"1.5.0".into(),sequence:2,edition:Edition::Customer,architecture:"x64".into(),channel:"stable".into(),expires_at:200,artifact_url:"https://releases.example/package.exe".into(),sha256:"a".repeat(64),installed_sha256:"b".repeat(64),installed_files:vec![InstalledFile{path:"Swan Remote Support.exe".into(),sha256:"b".repeat(64)},InstalledFile{path:"librustdesk.dll".into(),sha256:"c".repeat(64)},InstalledFile{path:"flutter_windows.dll".into(),sha256:"d".repeat(64)}],agent_url:"https://releases.example/agent.exe".into(),agent_sha256:"c".repeat(64),publisher:"Example".into(),publisher_certificate_sha256:"d".repeat(64),windows_versions:vec!["windows_11".into()],source_url:"https://releases.example/source.tar.gz".into(),format:"exe".into()}; assert!(release.validate(&Edition::Customer,1,100).is_ok()); assert!(release.validate(&Edition::Customer,2,100).is_err()); assert!(release.validate(&Edition::Technician,0,100).is_err()); From 405b8cc47c00fcfe7b19a86abcbe222b34c70d70 Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Fri, 2 Oct 2026 08:18:49 +0200 Subject: [PATCH 031/652] Exclude sessions during company setup and retain failed installation markers --- deployment/windows/Install-Company.ps1 | 56 +++++++++++++++++++++----- docs/ACCEPTANCE.md | 2 +- docs/INSTALLER_REPAIR.md | 22 ++++++++-- product/agent/src/lib.rs | 3 +- product/agent/src/update.rs | 37 ++++++++++++++++- 5 files changed, 104 insertions(+), 16 deletions(-) diff --git a/deployment/windows/Install-Company.ps1 b/deployment/windows/Install-Company.ps1 index 817f3b3c8a0..810ae18068a 100644 --- a/deployment/windows/Install-Company.ps1 +++ b/deployment/windows/Install-Company.ps1 @@ -41,7 +41,34 @@ $recordArguments = @('record-installation',(Join-Path $root 'release.json')) if ($Repair) { $packageArguments += '--repair'; $recordArguments += '--repair' } & $agent @packageArguments if ($LASTEXITCODE -ne 0) { throw 'Installer metadata, hash or publisher validation failed.' } +$installationMarker = Join-Path $editionDirectory 'pending-install.json' +$activityStream = [IO.File]::Open((Join-Path $editionDirectory 'activity.lock'),[IO.FileMode]::OpenOrCreate,[IO.FileAccess]::ReadWrite,[IO.FileShare]::ReadWrite) +$activityLocked = $false +try { + # LockFile's exclusive byte range overlaps fs2's session/update lock range. + # Refuse active sessions; do not terminate them to perform setup. + $activityStream.Lock(0,1) + $activityLocked = $true + $markerStream = [IO.File]::Open($installationMarker,[IO.FileMode]::Create,[IO.FileAccess]::Write,[IO.FileShare]::Read) + try { + $markerBytes = [IO.File]::ReadAllBytes((Join-Path $root 'release.json')) + $markerStream.Write($markerBytes,0,$markerBytes.Length) + $markerStream.Flush($true) + } finally { $markerStream.Dispose() } if ($bootstrap.edition -eq 'customer') { + $configurationTask = @(Get-ScheduledTask | Where-Object { $_.TaskName -eq 'Swan Company Configuration' }) + if ($configurationTask.Count -gt 1) { throw 'Ambiguous existing configuration task.' } + if ($configurationTask.Count -eq 1) { + $expectedAgent = [IO.Path]::GetFullPath((Join-Path $editionDirectory 'swan-agent.exe')) + if ($configurationTask[0].Actions.Count -ne 1 -or [IO.Path]::GetFullPath($configurationTask[0].Actions[0].Execute) -ine $expectedAgent -or $configurationTask[0].Actions[0].Arguments -cne 'watch') { throw 'Existing configuration task does not match this installation.' } + $configurationTask[0] | Disable-ScheduledTask | Out-Null + $configurationTask[0] | Stop-ScheduledTask + $stopDeadline = [DateTime]::UtcNow.AddSeconds(10) + while (@(Get-CimInstance Win32_Process -Filter "Name='swan-agent.exe'" | Where-Object { $_.ExecutablePath -ieq $expectedAgent }).Count -gt 0) { + if ([DateTime]::UtcNow -ge $stopDeadline) { throw 'Installed configuration agent did not stop. Close its running processes and retry setup.' } + Start-Sleep -Milliseconds 100 + } + } if ($installers[0].Extension -eq '.msi') { $process = Start-Process -FilePath 'msiexec.exe' -ArgumentList @('/i',('"' + $installers[0].FullName + '"'),'/passive','/norestart') -PassThru -Wait -WindowStyle Hidden } else { @@ -56,14 +83,6 @@ if ($bootstrap.edition -eq 'customer') { & $agent @enrollmentArguments if ($LASTEXITCODE -ne 0) { throw 'Device enrollment failed. Access remains blocked.' } Copy-Item -LiteralPath $agent -Destination (Join-Path $editionDirectory 'swan-agent.exe') -Force - & $agent @recordArguments - if ($LASTEXITCODE -ne 0) { throw 'Installed application or configuration agent verification failed. Background updates were not enabled.' } - $action = New-ScheduledTaskAction -Execute (Join-Path $editionDirectory 'swan-agent.exe') -Argument 'watch' - $trigger = New-ScheduledTaskTrigger -AtStartup - $settings = New-ScheduledTaskSettingsSet -RestartCount 3 -RestartInterval (New-TimeSpan -Minutes 1) -ExecutionTimeLimit ([TimeSpan]::Zero) - Register-ScheduledTask -TaskName 'Swan Company Configuration' -Action $action -Trigger $trigger -Settings $settings -User 'SYSTEM' -RunLevel Highest -Force | Out-Null - Start-ScheduledTask -TaskName 'Swan Company Configuration' - Write-Host 'Installed and enrolled. Your company must approve the device. Support status and stop controls remain available.' } else { $releaseEnvelope = Get-Content -LiteralPath (Join-Path $root 'release.json') -Raw | ConvertFrom-Json # The agent already verified this signed metadata and installer before any copy. @@ -72,8 +91,25 @@ if ($bootstrap.edition -eq 'customer') { $portable = Join-Path $editionDirectory 'SwanRemoteSupport-Technician.exe' Copy-Item -LiteralPath $installers[0].FullName -Destination $portable -Force Copy-Item -LiteralPath $agent -Destination (Join-Path $editionDirectory 'swan-agent.exe') -Force - & $agent @recordArguments - if ($LASTEXITCODE -ne 0) { throw 'Technician installed application or agent verification failed.' } +} +} finally { + try { if ($activityLocked) { $activityStream.Unlock(0,1) } } + finally { $activityStream.Dispose() } +} +# Sessions remain blocked by the durable marker while the agent verifies the +# complete installed identity under its own exclusive lock. +& $agent @recordArguments +if ($LASTEXITCODE -ne 0) { throw 'Installed application or agent verification failed. Installation recovery is required.' } +if (Test-Path -LiteralPath $installationMarker) { throw 'Company installation recovery marker remains; support sessions stay blocked.' } +if ($bootstrap.edition -eq 'customer') { + $action = New-ScheduledTaskAction -Execute (Join-Path $editionDirectory 'swan-agent.exe') -Argument 'watch' + $trigger = New-ScheduledTaskTrigger -AtStartup + $settings = New-ScheduledTaskSettingsSet -RestartCount 3 -RestartInterval (New-TimeSpan -Minutes 1) -ExecutionTimeLimit ([TimeSpan]::Zero) + Register-ScheduledTask -TaskName 'Swan Company Configuration' -Action $action -Trigger $trigger -Settings $settings -User 'SYSTEM' -RunLevel Highest -Force | Out-Null + Enable-ScheduledTask -TaskName 'Swan Company Configuration' | Out-Null + Start-ScheduledTask -TaskName 'Swan Company Configuration' + Write-Host 'Installed and enrolled. Your company must approve the device. Support status and stop controls remain available.' +} else { Copy-Item -LiteralPath (Join-Path $root 'Open-Technician.ps1') -Destination (Join-Path $editionDirectory 'Open-Technician.ps1') -Force $shortcutPath = Join-Path ([Environment]::GetFolderPath('Programs')) 'Swan Remote Support Technician.lnk' $shell = New-Object -ComObject WScript.Shell diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index cdc2854eea7..c14ff431a7a 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -8,7 +8,7 @@ native support session or a clean-machine installation. - Public baseline tag `swan-single-company-baseline-1.4.9` preserves the original Swan deployment and build instructions. Its source was not replaced. -- Twenty product tests pass on the local Windows development host, including +- Twenty-two product tests pass on the local Windows development host, including enrollment/trust preservation across stale writes, trusted key rotation, refusal to automatically reinstall a removed endpoint, and explicit consent changes surviving older refreshes. Consent API tests also reject delayed enable requests and older revocation retries; same-revision revocation takes precedence. The preceding eleven-test component source passed tests and release compilation on [Windows 2022 and Ubuntu 24.04 CI](https://github.com/rijff24/SwanRemoteSupport/actions/runs/36856716860) diff --git a/docs/INSTALLER_REPAIR.md b/docs/INSTALLER_REPAIR.md index ecf892d7962..7af31f6b89a 100644 --- a/docs/INSTALLER_REPAIR.md +++ b/docs/INSTALLER_REPAIR.md @@ -19,14 +19,30 @@ the existing device and transport identity; it does not issue new credentials or change existing unattended consent. Technician setup restores the portable application, local launcher and Start menu shortcut. +Setup holds an exclusive Windows byte-range lock while replacing files; this +overlaps the native session/update lock. Active sessions cause setup to refuse +replacement. Customer setup disables and stops only the matching managed agent +task before copying the agent, and refuses an unexpected task definition or an +agent that did not stop. + +A flushed `pending-install.json` records the release before file replacement. +It blocks managed sessions and automatic updates after an interrupted attempt. +The agent removes that marker under the exclusive lock only after the marker +matches the release and installed identity/sequence verification succeeds. +Retry the same initial bundle if installation never recorded a sequence, or use +`-Repair` with the pinned installed bundle. Do not manually clear the marker to +restore access to a potentially partial installation. + Automatic updates do not use the repair exception and still require an increasing release sequence. An automatic update cannot reinstall an application that was removed. Missing or expired repair metadata requires administrator recovery or a newer approved company bundle; do not delete device state or lower the recorded sequence to work around verification. -Component tests cover the repair metadata trust gate and ordinary update replay -rejection. Complete signed EXE/MSI repair, interruption recovery, session -coordination during setup, and clean-machine behavior remain acceptance work. +Component tests cover the repair metadata trust gate, ordinary update replay +rejection and interrupted-setup session denial. A real local Windows +cross-process test confirms the PowerShell installer byte lock excludes native +Rust session locks. Complete signed EXE/MSI repair, interruption recovery, +scheduled-task behavior and clean-machine session coordination remain acceptance work. This document describes the development implementation, not tested production compatibility. diff --git a/product/agent/src/lib.rs b/product/agent/src/lib.rs index be94951edea..8219dfba37e 100644 --- a/product/agent/src/lib.rs +++ b/product/agent/src/lib.rs @@ -237,7 +237,8 @@ pub fn activity_file(directory:&Path)->Result { Ok(std::fs::OpenOptions::new().create(true).read(true).write(true).open(directory.join("activity.lock"))?) } pub fn lock_session(directory:&Path)->Result { - let file=activity_file(directory)?;fs2::FileExt::try_lock_shared(&file).context("An update is in progress")?;Ok(file) + let file=activity_file(directory)?;fs2::FileExt::try_lock_shared(&file).context("An update or installation is in progress")?; + ensure!(!directory.join("pending-install.json").exists(),"Company installation requires recovery before support sessions can start");Ok(file) } pub fn state_directory()->PathBuf { if let Some(directory)=std::env::var_os("SWAN_STATE_DIR"){return directory.into();} diff --git a/product/agent/src/update.rs b/product/agent/src/update.rs index 5ce6b518215..529723bccd2 100644 --- a/product/agent/src/update.rs +++ b/product/agent/src/update.rs @@ -124,13 +124,20 @@ impl AgentState { let activity=activity_file(directory)?;fs2::FileExt::try_lock_exclusive(&activity).context("Installation verification requires all sessions to close")?; let mut latest=AgentState::load(directory)?; let release=installation_release(&latest,directory,envelope,repair)?; + let setup_marker=directory.join("pending-install.json"); + if setup_marker.exists(){ + let pending:SignedEnvelope=serde_json::from_slice(&std::fs::read(&setup_marker)?)?; + ensure!(pending.payload==envelope.payload && pending.signature==envelope.signature,"Another company installation must be recovered first"); + } verify_compatibility(directory,&release)?; verify_installed(directory,&release)?; let agent=directory.join("swan-agent.exe"); ensure!(digest(std::fs::read(&agent)?).eq_ignore_ascii_case(&release.agent_sha256),"Installed configuration agent differs from signed release"); let mut agent_release=release.clone();agent_release.sha256=release.agent_sha256.clone();verify_publisher(&agent,&agent_release)?; save_installed_metadata(directory,envelope)?; - latest.last_release_sequence=release.sequence;latest.save(directory)?;*self=latest;Ok(()) + latest.last_release_sequence=release.sequence;latest.save(directory)?; + if setup_marker.exists(){std::fs::remove_file(setup_marker)?;} + *self=latest;Ok(()) } #[cfg(windows)] pub fn recover_update(&mut self,directory:&Path)->Result<()> { @@ -152,6 +159,7 @@ impl AgentState { pub async fn update(&mut self,directory:&Path,technician_token:Option<&str>)->Result { #[cfg(not(windows))] {let _=(directory,technician_token);anyhow::bail!("Endpoint updates require Windows");} #[cfg(windows)] { + ensure!(!directory.join("pending-install.json").exists(),"Company setup recovery is required before automatic updates"); require_existing_application(&installed_target(directory,&self.bootstrap.edition)?)?; let profile=self.company_profile()?; if profile.updates_paused || !maintenance_open(profile.maintenance_start_utc,profile.maintenance_end_utc,now()){return Ok(false);} @@ -172,6 +180,7 @@ impl AgentState { verify_publisher(&package,&release)?; let activity=activity_file(directory)?; fs2::FileExt::try_lock_exclusive(&activity).context("Update deferred while a session or technician app is active")?; + ensure!(!directory.join("pending-install.json").exists(),"Company setup started while the update was downloading"); // Re-read consent and enrollment immediately before installation. let mut latest=AgentState::load(directory)?; // Removal during download must not be turned into a fresh install. @@ -226,6 +235,32 @@ fn verify_installed(directory:&Path,release:&Release)->Result<()> { mod tests { use super::*; #[test] + fn interrupted_setup_blocks_sessions_until_recovered(){ + let folder=std::env::temp_dir().join(format!("swan-pending-install-{}",random_token())); + std::fs::create_dir(&folder).unwrap();std::fs::write(folder.join("pending-install.json"),b"interrupted setup fixture").unwrap(); + assert!(lock_session(&folder).is_err()); + std::fs::remove_file(folder.join("pending-install.json")).unwrap(); + assert!(lock_session(&folder).is_ok());std::fs::remove_dir_all(folder).unwrap(); + } + #[cfg(windows)] + #[test] + fn powershell_installer_byte_lock_excludes_native_sessions(){ + use std::io::{BufRead,Write};use std::os::windows::process::CommandExt; + let folder=std::env::temp_dir().join(format!("swan-installer-lock-{}",random_token()));std::fs::create_dir(&folder).unwrap(); + let script=folder.join("hold-install-lock.ps1"); + std::fs::write(&script,r#"param([string]$Directory) +$ErrorActionPreference='Stop' +$stream=[IO.File]::Open((Join-Path $Directory 'activity.lock'),[IO.FileMode]::OpenOrCreate,[IO.FileAccess]::ReadWrite,[IO.FileShare]::ReadWrite) +try {$stream.Lock(0,1);[Console]::WriteLine('locked');[Console]::ReadLine()|Out-Null}finally{$stream.Dispose()} +"#).unwrap(); + let mut child=std::process::Command::new("powershell.exe").args(["-NoLogo","-NoProfile","-NonInteractive","-ExecutionPolicy","Bypass","-File"]).arg(&script).arg(&folder).creation_flags(0x08000000).stdin(std::process::Stdio::piped()).stdout(std::process::Stdio::piped()).spawn().unwrap(); + let mut output=std::io::BufReader::new(child.stdout.take().unwrap());let mut line=String::new();output.read_line(&mut line).unwrap(); + let ready=line.trim()=="locked";let blocked=lock_session(&folder).is_err(); + if let Some(mut input)=child.stdin.take(){input.write_all(b"release\n").unwrap();} + let status=child.wait().unwrap();assert!(ready && blocked && status.success()); + assert!(lock_session(&folder).is_ok());std::fs::remove_dir_all(folder).unwrap(); + } + #[test] fn removed_application_requires_explicit_setup_instead_of_automatic_reinstall() { let folder=std::env::temp_dir().join(format!("swan-removed-{}",random_token())); std::fs::create_dir(&folder).unwrap();let target=folder.join("endpoint.exe"); From 0bc1c128cc0b4b0e9f476b4be2980374653205fe Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Fri, 2 Oct 2026 08:21:13 +0200 Subject: [PATCH 032/652] Record downloaded Windows artifact integrity and unsigned publication gate --- docs/ACCEPTANCE.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index c14ff431a7a..44c8888441e 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -33,6 +33,11 @@ native support session or a clean-machine installation. also passed and produces a distinct technician portable artifact. It predates subsequent native file/audio capability guards, consent ordering, complete backup and installation/repair changes. A new build at `7e589ba` is queued. +- The unsigned Windows artifact from `31b13aa` downloaded successfully. Its + digest matches GitHub's artifact digest and both endpoint package checksums + match `SHA256SUMS.txt`. Both endpoint packages and all three product-component + executables report `NotSigned`; no downloaded executable was launched on the + production host. This proves artifact integrity, not trusted publication. - A fresh-company HTTPS test at `71ebbec` passed with rebuilt agent and server executables. It additionally verifies enrollment-preserving repair, rejection of a changed transport identity, explicit consent opt-in/revocation, From d6f166534139bf9289adf7a4ae4c8288760e44c3 Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Fri, 2 Oct 2026 08:39:16 +0200 Subject: [PATCH 033/652] Add administrator company network configuration and scoped reachability diagnostics --- deployment/windows/Start-LocalTest.ps1 | 2 +- deployment/windows/test-company-lifecycle.js | 8 +++ docs/ACCEPTANCE.md | 4 +- docs/NETWORK_DIAGNOSTICS.md | 30 +++++++++++ product/management/src/main.rs | 1 + product/management/src/network.rs | 56 ++++++++++++++++++++ product/management/src/service.rs | 7 +++ product/management/src/tests.rs | 2 + product/management/web/app.js | 17 ++++++ product/management/web/index.html | 2 +- product/protocol/src/lib.rs | 7 +++ 11 files changed, 132 insertions(+), 4 deletions(-) create mode 100644 docs/NETWORK_DIAGNOSTICS.md create mode 100644 product/management/src/network.rs diff --git a/deployment/windows/Start-LocalTest.ps1 b/deployment/windows/Start-LocalTest.ps1 index 20e9d0041a7..710ebf1c28d 100644 --- a/deployment/windows/Start-LocalTest.ps1 +++ b/deployment/windows/Start-LocalTest.ps1 @@ -5,7 +5,7 @@ param( $ErrorActionPreference = 'Stop' if (!(Test-Path -LiteralPath $EnvironmentFile)) { throw 'Create local-test.env from local-test.env.example first.' } # Only named server settings are accepted; the file is data, never PowerShell code. -$allowed = @('SWAN_LISTEN', 'SWAN_DATA_DIR', 'SWAN_RELEASE_PUBLIC_KEY', 'SWAN_TEST_TLS_PORT', 'SWAN_TEST_TLS_PFX', 'SWAN_TEST_TLS_PASSWORD', 'SWAN_TEST_FAULTS') +$allowed = @('SWAN_LISTEN', 'SWAN_DATA_DIR', 'SWAN_RELEASE_PUBLIC_KEY', 'SWAN_TEST_TLS_PORT', 'SWAN_TEST_TLS_PFX', 'SWAN_TEST_TLS_PASSWORD', 'SWAN_TEST_FAULTS', 'SWAN_TEST_CA_FILE') foreach ($line in Get-Content -LiteralPath $EnvironmentFile) { if (!$line.Trim() -or $line.TrimStart().StartsWith('#')) { continue } if ($line -notmatch '^([A-Z_]+)=(.*)$' -or $Matches[1] -notin $allowed) { throw 'Invalid local environment setting.' } diff --git a/deployment/windows/test-company-lifecycle.js b/deployment/windows/test-company-lifecycle.js index c19dca7437a..1e68c9dac1d 100644 --- a/deployment/windows/test-company-lifecycle.js +++ b/deployment/windows/test-company-lifecycle.js @@ -41,6 +41,14 @@ async function main(){ const setup=await api('setup','POST',fs.readFileSync(path.join(data,'setup-token.txt'),'utf8').trim(),{profile,username:'test-admin',password,totp_secret:secret,totp_code:totp(secret)});assert.equal(setup.status,200); fs.writeFileSync(path.join(data,'test-admin.private.json'),JSON.stringify({username:'test-admin',password,totp_secret:secret})); const login=await api('login','POST',null,{username:'test-admin',password,totp_code:totp(secret,1)});assert.equal(login.status,200);const admin=login.body.token; + if(settings.SWAN_TEST_CA_FILE){ + assert.equal((await api('network/check','POST')).status,401,'Unauthenticated network diagnostics accepted'); + const diagnostics=await api('network/check','POST',admin,{management_url:'https://unconfigured.invalid'}); + assert.equal(diagnostics.status,200);assert.equal(diagnostics.body.management_url,base,'Probe used an unconfigured request target'); + assert.equal(diagnostics.body.https_and_company_signature_valid,true,'Configured HTTPS company trust check failed'); + assert.equal(diagnostics.body.vantage_point,'company_server');assert.equal(diagnostics.body.tcp_checks.length,3); + assert.equal(diagnostics.body.external_reachability_verified,false);assert.equal(diagnostics.body.udp_reachability_verified,false); + } const signed=(await api('profile')).body;const current=JSON.parse(Buffer.from(signed.payload,'base64')); const bootstrap={schema:1,edition:'customer',company_id:current.company_id,management_url:base,profile_public_key:status.profile_public_key,release_public_key:''}; const customerDir=path.join(data,'customer-agent'), techDir=path.join(data,'technician-agent'); diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index 44c8888441e..4fa59f3035d 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -8,7 +8,7 @@ native support session or a clean-machine installation. - Public baseline tag `swan-single-company-baseline-1.4.9` preserves the original Swan deployment and build instructions. Its source was not replaced. -- Twenty-two product tests pass on the local Windows development host, including +- Twenty-three product tests pass on the local Windows development host, including enrollment/trust preservation across stale writes, trusted key rotation, refusal to automatically reinstall a removed endpoint, and explicit consent changes surviving older refreshes. Consent API tests also reject delayed enable requests and older revocation retries; same-revision revocation takes precedence. The preceding eleven-test component source passed tests and release compilation on [Windows 2022 and Ubuntu 24.04 CI](https://github.com/rijff24/SwanRemoteSupport/actions/runs/36856716860) @@ -76,7 +76,7 @@ remain unverified. | Requirement | Current evidence and remaining work | | --- | --- | | Reproducible server, customer, technician and worker builds | Product components pass pinned builds. Native x64 build/package remains pending; baseline rebuild is not newly proved. | -| Fresh-company Windows/Linux setup | HTTPS/API setup works. Equivalent full setup wizards, transport provisioning, certificate management and reachability checks remain incomplete. | +| Fresh-company Windows/Linux setup | HTTPS/API setup works. Administrator network settings and server-vantage DNS/TCP/HTTPS company-signature diagnostics are implemented with access tests. Equivalent full setup wizards, transport provisioning, certificate management and external reachability checks remain incomplete; see NETWORK_DIAGNOSTICS.md. | | Roles, groups, permissions and revocation | MFA/group/device denial tests pass. Signed capability fields, administrator group policy APIs, and receiver capability bounds are implemented in source. Policy reduction denies lease renewal in component tests. Administration controls, native enforcement and direct-connection bypass tests remain unverified. | | Configurable customer and technician apps | Signed profile sync passes. Graphical login/inventory/history, logo/color rendering, contacts, company shortcuts, offline operation and native restart/upgrade preservation remain to verify or complete. | | Signed profiles and rotation | Component tests reject tampering, wrong-company, expired and older profiles and untrusted key changes. Trusted cached key/endpoint rotation preserves enrollment in tests. Real HTTPS rotation and installed-app behavior still need verification. | diff --git a/docs/NETWORK_DIAGNOSTICS.md b/docs/NETWORK_DIAGNOSTICS.md new file mode 100644 index 00000000000..91724a88400 --- /dev/null +++ b/docs/NETWORK_DIAGNOSTICS.md @@ -0,0 +1,30 @@ +# Company network diagnostics + +The administration interface's Company network screen publishes the management +HTTPS URL, rendezvous/relay endpoints and public transport trust key through the +signed company profile. DNS, HTTPS provisioning, transport processes and firewall +configuration must match those settings. Publishing a URL does not move the +server or configure a reverse proxy. + +The Check saved endpoints button calls administrator-only +`POST /api/v1/network/check`. It probes only the saved profile; request-body +addresses are not accepted. Checks resolve rendezvous/relay DNS, connect to the +ID TCP port, its preceding NAT-test TCP port, and the relay TCP port, and fetch +the public management profile over certificate-validated HTTPS. The returned +profile must validate against this company's signing key, identity and revision. +The operation is rate-limited and audited. + +Results explicitly identify `company_server` as the vantage point and never +claim external or UDP reachability. Private/local/shared-address classifications +are diagnostic hints. A public-looking DNS address and successful TCP connection +from the hosting server do not prove that clients elsewhere can reach it. + +Run actual native direct and relay tests from an outside network before +distribution. Office hosting needs suitable port forwarding; CGNAT may require +a company-controlled public relay. Test UDP on the ID port separately. No project +VPS is assumed and compatibility with every HTTPS-only proxy is not promised. + +Component tests cover a real loopback TCP listener/closed port, address parsing, +zero-port rejection and administrator access restrictions. Full external +reachability, native transport trust, NAT traversal and relay behavior remain +acceptance requirements. diff --git a/product/management/src/main.rs b/product/management/src/main.rs index 61fce14d53b..6c12940c36f 100644 --- a/product/management/src/main.rs +++ b/product/management/src/main.rs @@ -1,5 +1,6 @@ mod service; mod backup; +mod network; #[cfg(test)] mod tests; use anyhow::{Context, Result}; diff --git a/product/management/src/network.rs b/product/management/src/network.rs new file mode 100644 index 00000000000..fe5a0b9d1f9 --- /dev/null +++ b/product/management/src/network.rs @@ -0,0 +1,56 @@ +//! Server-side diagnostics are not evidence of reachability from the Internet. +use serde_json::{json,Value}; +use std::{net::IpAddr,time::Duration}; +use swan_protocol::{CompanyProfile,SignedEnvelope}; + +fn address_class(address:IpAddr)->&'static str { + let private=match address { + IpAddr::V4(ip)=>{let octets=ip.octets();ip.is_private() || ip.is_loopback() || ip.is_link_local() || ip.is_unspecified() || ip.is_multicast() || ip.is_broadcast() || (octets[0]==100 && (64..=127).contains(&octets[1])) || ip.is_documentation()}, + IpAddr::V6(ip)=>ip.is_loopback() || ip.is_unspecified() || ip.is_multicast() || ip.is_unique_local() || ip.is_unicast_link_local() || ip.to_ipv4().map(|ip|address_class(IpAddr::V4(ip))=="private_or_local").unwrap_or(false), + }; + if private {"private_or_local"}else{"public_candidate"} +} + +async fn tcp(name:&str,host:&str,port:u16)->Value { + let resolved=tokio::time::timeout(Duration::from_secs(3),tokio::net::lookup_host((host,port))).await; + let addresses:Vec<_>=match resolved {Ok(Ok(addresses))=>addresses.take(8).collect(),_=>return json!({"name":name,"host":host,"port":port,"reachable":false,"result":"DNS resolution failed or timed out","addresses":[]})}; + let visible:Vec<_>=addresses.iter().map(|address|json!({"ip":address.ip().to_string(),"classification":address_class(address.ip())})).collect(); + for address in addresses.iter().take(4) { + if matches!(tokio::time::timeout(Duration::from_secs(3),tokio::net::TcpStream::connect(address)).await,Ok(Ok(_))) { + return json!({"name":name,"host":host,"port":port,"reachable":true,"result":"TCP connection succeeded from this company server","addresses":visible}); + } + } + json!({"name":name,"host":host,"port":port,"reachable":false,"result":"TCP connection failed or timed out","addresses":visible}) +} + +async fn management(profile:&CompanyProfile,key:&ed25519_dalek::VerifyingKey)->bool { + let result=async { + let envelope:SignedEnvelope=swan_agent::http_client(5,false)?.get(format!("{}/api/v1/profile",profile.management_url.trim_end_matches('/'))).send().await?.error_for_status()?.json().await?; + let returned:CompanyProfile=envelope.verify(key)?; + returned.validate(&profile.company_id,profile.revision,swan_protocol::now())?; + anyhow::Ok(()) + }.await; + result.is_ok() +} + +pub async fn check(profile:&CompanyProfile,key:&ed25519_dalek::VerifyingKey)->anyhow::Result { + let (host,id_port)=swan_protocol::transport_socket(&profile.rendezvous,21116)?; + let (relay_host,relay_port)=swan_protocol::transport_socket(&profile.relay,21117)?; + let nat_port=id_port.checked_sub(1).filter(|port|*port>0).ok_or_else(||anyhow::anyhow!("ID server port must allow its preceding NAT-test port"))?; + let (nat,id,relay,https)=tokio::join!(tcp("NAT-test TCP",&host,nat_port),tcp("Rendezvous TCP",&host,id_port),tcp("Relay TCP",&relay_host,relay_port),management(profile,key)); + Ok(json!({"vantage_point":"company_server","management_url":profile.management_url,"https_and_company_signature_valid":https,"tcp_checks":[nat,id,relay],"external_reachability_verified":false,"udp_reachability_verified":false,"guidance":"Repeat native direct and relay tests from outside this network. Office hosting needs appropriate port forwarding; CGNAT may require a company-controlled public relay. HTTPS-only proxies are not guaranteed. UDP 21116 (or your custom ID port) still requires a separate external test."})) +} + +#[cfg(test)] +mod tests { + use super::*; + #[tokio::test] + async fn tcp_diagnostics_detect_local_listener_and_closed_port_without_claiming_public_access(){ + let listener=tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();let port=listener.local_addr().unwrap().port(); + let result=tcp("test","127.0.0.1",port).await;assert_eq!(result["reachable"],true);assert_eq!(result["addresses"][0]["classification"],"private_or_local"); + drop(listener);assert_eq!(tcp("test","127.0.0.1",port).await["reachable"],false); + assert_eq!(address_class("100.64.1.2".parse().unwrap()),"private_or_local");assert_eq!(address_class("::1".parse().unwrap()),"private_or_local"); + assert_eq!(swan_protocol::transport_socket("support.example.com:443",21116).unwrap(),("support.example.com".into(),443)); + assert!(swan_protocol::transport_socket("support.example.com:0",21116).is_err()); + } +} diff --git a/product/management/src/service.rs b/product/management/src/service.rs index 7060b323bf4..962c625f2c1 100644 --- a/product/management/src/service.rs +++ b/product/management/src/service.rs @@ -124,6 +124,7 @@ pub fn router(store:Shared)->Router { .route("/api/v1/login",post(login)) .route("/api/v1/logout",post(logout)) .route("/api/v1/profile",get(profile).put(save_profile)) + .route("/api/v1/network/check",post(network_check)) .route("/api/v1/users",get(users).post(create_user)) .route("/api/v1/users/{id}/disable",post(disable_user)) .route("/api/v1/enroll",post(enroll)) @@ -159,6 +160,12 @@ async fn status(State(s):State)->ApiResult { let configured=s.db.lock().unwrap().query_row("SELECT EXISTS(SELECT 1 FROM profile)",[],|r|r.get::<_,bool>(0))?; Ok(Json(json!({"configured":configured,"profile_public_key":STANDARD.encode(s.key.verifying_key().as_bytes())}))) } +async fn network_check(State(s):State,headers:HeaderMap)->ApiResult { + let actor=s.user(&headers,true)?;s.rate("network-check",10)?; + let profile=s.profile()?; + let result=crate::network::check(&profile,&s.key.verifying_key()).await?; + s.audit(&actor,"network.checked",&profile.company_id)?;Ok(Json(result)) +} #[derive(Deserialize)] struct Setup { profile:CompanyProfile, username:String,password:String,totp_secret:String,totp_code:String } async fn setup(State(s):State,headers:HeaderMap,Json(mut input):Json)->ApiResult { diff --git a/product/management/src/tests.rs b/product/management/src/tests.rs index 6201adfea1f..5acac8fa69e 100644 --- a/product/management/src/tests.rs +++ b/product/management/src/tests.rs @@ -26,6 +26,7 @@ async fn enrollment_mfa_and_grants_fail_closed() { let directory=std::env::temp_dir().join(format!("swan-api-{}",random_token())); let store=Arc::new(Store::open(&directory).unwrap());let app=router(store.clone()); assert_eq!(request(&app,"/api/v1/devices","GET",None,Value::Null).await.0,StatusCode::UNAUTHORIZED); + assert_eq!(request(&app,"/api/v1/network/check","POST",None,Value::Null).await.0,StatusCode::UNAUTHORIZED); let secret=Secret::generate_secret().to_encoded().to_string(); let generator=TOTP::new(Algorithm::SHA1,6,1,30,Secret::Encoded(secret.clone()).to_bytes().unwrap()).unwrap(); let time=now();let setup_token=std::fs::read_to_string(directory.join("setup-token.txt")).unwrap(); @@ -79,6 +80,7 @@ async fn enrollment_mfa_and_grants_fail_closed() { let (_,tech_login)=request(&app,"/api/v1/login","POST",None,json!({"username":"technician","password":"another long strong password","totp_code":tech_generator.generate(now())})).await; let technician=tech_login["token"].as_str().unwrap(); assert_eq!(request(&app,"/api/v1/users","GET",Some(technician),Value::Null).await.0,StatusCode::UNAUTHORIZED); + assert_eq!(request(&app,"/api/v1/network/check","POST",Some(technician),Value::Null).await.0,StatusCode::UNAUTHORIZED,"Only company administrators may probe configured endpoints"); assert_eq!(request(&app,"/api/v1/grants","POST",Some(technician),json!({"device_id":device_id,"proof_public_key":STANDARD.encode(proof.verifying_key().as_bytes()),"unattended":false})).await.0,StatusCode::FORBIDDEN,"Technicians need explicit group access"); let (_,devices)=request(&app,"/api/v1/devices","GET",Some(technician),Value::Null).await;assert_eq!(devices.as_array().unwrap().len(),0); assert_eq!(request(&app,&format!("/api/v1/groups/customers/users/{}",new_user["id"].as_str().unwrap()),"PUT",Some(admin),Value::Null).await.0,StatusCode::OK); diff --git a/product/management/web/app.js b/product/management/web/app.js index 33b38c9d3ec..661b7264d11 100644 --- a/product/management/web/app.js +++ b/product/management/web/app.js @@ -23,6 +23,23 @@ function setupScreen(){ function loginScreen(){title('Sign in to your company server','Administrator and technician accounts require an authenticator code.');document.querySelector('#nav').hidden=true;content.innerHTML=`
${field('username','Username')}${field('password','Password','password')}${field('code','Authenticator code')}

Your session stays in this pageโ€™s memory and expires after one hour.

`;bindForm('login',async values=>{const login=await api('login','POST',{username:values.get('username'),password:values.get('password'),totp_code:values.get('code')});accessToken=login.token;document.querySelector('#nav').hidden=false;await view('devices');});} async function view(name){notice.textContent='';for(const button of document.querySelectorAll('[data-view]'))button.classList.toggle('active',button.dataset.view===name);try{await screens[name]();}catch(error){showError(error);}} const screens={ + async network(){ + title('Company network','Configure company-owned public endpoints and check them from this server. External access needs a separate test.'); + const envelope=await api('profile'); + const profile=JSON.parse(new TextDecoder().decode(Uint8Array.from(atob(envelope.payload),c=>c.charCodeAt(0)))); + content.innerHTML=`

Transport and management

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

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

Reachability check

This check uses the saved company configuration. It can detect DNS, TCP and management trust problems from this server, but cannot prove access from an outside network or UDP traversal.

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('#check-network').onclick=async event=>{ + event.target.disabled=true; + try { + const result=await api('network/check','POST'); + document.querySelector('#network-result').innerHTML=`

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

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

${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'}; diff --git a/product/management/web/index.html b/product/management/web/index.html index 4578fb927a3..ad88c5e40fd 100644 --- a/product/management/web/index.html +++ b/product/management/web/index.html @@ -1,3 +1,3 @@ Swan Remote Support โ€” Company server - +

YOUR SUPPORT INFRASTRUCTURE

Welcome to Swan Remote Support

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

diff --git a/product/protocol/src/lib.rs b/product/protocol/src/lib.rs index 4a0aef9d49a..3fb97122ff8 100644 --- a/product/protocol/src/lib.rs +++ b/product/protocol/src/lib.rs @@ -243,8 +243,15 @@ pub fn validate_host(value: &str) -> Result<()> { ensure!(value.bytes().all(|c| c.is_ascii_alphanumeric() || b".-:[]".contains(&c)), "Invalid transport address"); let endpoint=url::Url::parse(&format!("tcp://{value}"))?; ensure!(endpoint.host_str().is_some() && endpoint.username().is_empty() && endpoint.password().is_none() && endpoint.query().is_none() && endpoint.fragment().is_none() && ["","/"].contains(&endpoint.path()),"Expected transport hostname and optional port"); + ensure!(endpoint.port()!=Some(0),"Transport port zero is invalid"); Ok(()) } +pub fn transport_socket(value:&str,default_port:u16)->Result<(String,u16)> { + validate_host(value)?;ensure!(default_port>0,"Transport port zero is invalid"); + let endpoint=url::Url::parse(&format!("tcp://{value}"))?; + let host=endpoint.host_str().context("Missing transport hostname")?.trim_matches(['[',']']).to_string(); + Ok((host,endpoint.port().unwrap_or(default_port))) +} pub fn public_key(value: &str) -> Result { let bytes: [u8; 32] = STANDARD.decode(value)?.try_into().map_err(|_| anyhow::anyhow!("Expected 32-byte key"))?; Ok(VerifyingKey::from_bytes(&bytes)?) From 07343944bef9d637d0064c24de6d92cf44618c42 Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Fri, 2 Oct 2026 08:49:59 +0200 Subject: [PATCH 034/652] Record real HTTPS runtime validation of scoped network diagnostics --- docs/ACCEPTANCE.md | 7 +++++++ docs/NETWORK_DIAGNOSTICS.md | 9 +++++++++ 2 files changed, 16 insertions(+) diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index 4fa59f3035d..e8517c74bdc 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -55,6 +55,13 @@ native support session or a clean-machine installation. ## Requirements still to demonstrate +A fresh-company HTTPS lifecycle run at `d6f1665` passed with clean source and +newly built executables. It additionally verifies saved-target network +diagnostics, management certificate/company-signature validation, and refusal +to claim external or UDP reachability. Enrollment, permissions, branding and +offline-consent recovery checks also passed in that run; native transport and +clean-machine installation remain unverified. + Initial setup now verifies installed endpoint and agent hashes/publishers against signed release metadata and records the release sequence before enabling the customer watcher. Technician setup copies its portable executable, agent and diff --git a/docs/NETWORK_DIAGNOSTICS.md b/docs/NETWORK_DIAGNOSTICS.md index 91724a88400..a0184b7b060 100644 --- a/docs/NETWORK_DIAGNOSTICS.md +++ b/docs/NETWORK_DIAGNOSTICS.md @@ -28,3 +28,12 @@ Component tests cover a real loopback TCP listener/closed port, address parsing, zero-port rejection and administrator access restrictions. Full external reachability, native transport trust, NAT traversal and relay behavior remain acceptance requirements. + +A fresh-company HTTPS lifecycle run at `d6f1665` also passed against actual built +agent and management executables. It verifies the management HTTPS certificate +and company signature, rejects unauthenticated checks, ignores request-body +targets in favor of saved configuration, and reports external/UDP reachability +as unverified. The debug server uses an explicit local DER certificate through +the private `SWAN_TEST_CA_FILE` setting; release builds ignore this test setting. +Exact executable hashes and clean source state are recorded privately. Its test +transport ID is not a native remote-session test. From 2608dd4b3dca3abc9b4fa72f2363e32791d8c131 Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Fri, 2 Oct 2026 09:02:36 +0200 Subject: [PATCH 035/652] Retain shared Swan branding defaults during company setup --- branding/swan-support-mark.svg | 2 +- docs/ACCEPTANCE.md | 8 +++++++- product/management/src/service.rs | 2 ++ product/management/web/app.js | 8 ++++---- product/management/web/index.html | 2 +- product/management/web/style.css | 2 +- product/protocol/src/lib.rs | 2 +- 7 files changed, 17 insertions(+), 9 deletions(-) diff --git a/branding/swan-support-mark.svg b/branding/swan-support-mark.svg index 7a04bfff239..c2062f576a4 100644 --- a/branding/swan-support-mark.svg +++ b/branding/swan-support-mark.svg @@ -1 +1 @@ - + diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index e8517c74bdc..b5c18996061 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -82,7 +82,7 @@ remain unverified. | Requirement | Current evidence and remaining work | | --- | --- | -| Reproducible server, customer, technician and worker builds | Product components pass pinned builds. Native x64 build/package remains pending; baseline rebuild is not newly proved. | +| Reproducible server, customer, technician and worker builds | Product components pass pinned builds. Native Windows build/package run 36970913876 passed at exact source `7e589ba`; the later manifest/installer-locking build 36973173582 remains in progress. Baseline rebuild and clean-machine installation are not newly proved. | | Fresh-company Windows/Linux setup | HTTPS/API setup works. Administrator network settings and server-vantage DNS/TCP/HTTPS company-signature diagnostics are implemented with access tests. Equivalent full setup wizards, transport provisioning, certificate management and external reachability checks remain incomplete; see NETWORK_DIAGNOSTICS.md. | | Roles, groups, permissions and revocation | MFA/group/device denial tests pass. Signed capability fields, administrator group policy APIs, and receiver capability bounds are implemented in source. Policy reduction denies lease renewal in component tests. Administration controls, native enforcement and direct-connection bypass tests remain unverified. | | Configurable customer and technician apps | Signed profile sync passes. Graphical login/inventory/history, logo/color rendering, contacts, company shortcuts, offline operation and native restart/upgrade preservation remain to verify or complete. | @@ -114,3 +114,9 @@ See [VM testing](../deployment/windows/VM-TESTING.md) and [local HTTPS testing](../deployment/windows/LOCAL-TESTING.md). Test credentials, signing keys, certificates with private keys, VM images and raw operational logs must remain outside public source. The project provides no shared company VPS. + +First-run branding now reads the protocol defaults from the management service, +including the bundled swan/gear SVG and teal primary color. The server keeps its +Swan wordmark and mark; existing company profiles are not rewritten. Product +workspace tests (23) and administration JavaScript syntax checks pass. Graphical +rendering and installed-app behavior remain to verify. diff --git a/product/management/src/service.rs b/product/management/src/service.rs index 962c625f2c1..b1a1550b4a4 100644 --- a/product/management/src/service.rs +++ b/product/management/src/service.rs @@ -119,6 +119,8 @@ pub fn router(store:Shared)->Router { .route("/app.js",get(||async{([(header::CONTENT_TYPE,"text/javascript; charset=utf-8")],include_str!("../web/app.js"))})) .route("/style.css",get(||async{([(header::CONTENT_TYPE,"text/css")],include_str!("../web/style.css"))})) .route("/health",get(||async{Json(json!({"status":"ok","product":PRODUCT}))})) + .route("/swan-support-mark.svg",get(||async{([(header::CONTENT_TYPE,"image/svg+xml")],include_str!("../../../branding/swan-support-mark.svg"))})) + .route("/api/v1/default-branding",get(||async{Json(Branding::default())})) .route("/api/v1/status",get(status)) .route("/api/v1/setup",post(setup)) .route("/api/v1/login",post(login)) diff --git a/product/management/web/app.js b/product/management/web/app.js index 661b7264d11..0fef32d6d03 100644 --- a/product/management/web/app.js +++ b/product/management/web/app.js @@ -12,13 +12,13 @@ function bindForm(id,callback){document.querySelector('#'+id).addEventListener(' function field(name,label,type='text',value=''){return ``;} function title(text,description=''){document.querySelector('#title').textContent=text;document.querySelector('#description').textContent=description;} function base32(bytes){let bits=0,value=0,out='';for(const byte of bytes){value=(value<<8)|byte;bits+=8;while(bits>=5){out+='ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'[(value>>>(bits-5))&31];bits-=5;}}if(bits)out+='ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'[(value<<(5-bits))&31];return out;} -function defaultProfile(){const brand={display_name:'Swan Remote Support',primary_color:'#004B6E',logo_svg:'',support_url:'',consent_text:'Only allow support from a technician you trust. You can stop support or revoke ongoing access at any time.'};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};} -async function initialize(){try{const state=await api('status');if(!state.configured)setupScreen();else loginScreen();}catch(error){showError(error);}} -function setupScreen(){ +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};} +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))); content.innerHTML=`

Company and network

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

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

`; - bindForm('setup',async values=>{const profile=defaultProfile();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.';}); + bindForm('setup',async values=>{const profile=defaultProfile(brand);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(){title('Sign in to your company server','Administrator and technician accounts require an authenticator code.');document.querySelector('#nav').hidden=true;content.innerHTML=`
${field('username','Username')}${field('password','Password','password')}${field('code','Authenticator code')}

Your session stays in this pageโ€™s memory and expires after one hour.

`;bindForm('login',async values=>{const login=await api('login','POST',{username:values.get('username'),password:values.get('password'),totp_code:values.get('code')});accessToken=login.token;document.querySelector('#nav').hidden=false;await view('devices');});} async function view(name){notice.textContent='';for(const button of document.querySelectorAll('[data-view]'))button.classList.toggle('active',button.dataset.view===name);try{await screens[name]();}catch(error){showError(error);}} diff --git a/product/management/web/index.html b/product/management/web/index.html index ad88c5e40fd..4180f6a2db5 100644 --- a/product/management/web/index.html +++ b/product/management/web/index.html @@ -1,3 +1,3 @@ Swan Remote Support โ€” Company server - +

YOUR SUPPORT INFRASTRUCTURE

Welcome to Swan Remote Support

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

diff --git a/product/management/web/style.css b/product/management/web/style.css index 1da833256f5..3ffc0c2cbf1 100644 --- a/product/management/web/style.css +++ b/product/management/web/style.css @@ -1 +1 @@ -: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:#004b6e;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{border:2px solid #b2dbe5;border-radius:50%;height:49px;width:49px;text-align:center;font-size:32px}.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:#004b6e;color:white;border:0;border-radius:6px;padding:12px 18px;font:inherit;cursor:pointer}button.secondary{background:#e8f0f3;color:#004b6e}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}} +: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}} diff --git a/product/protocol/src/lib.rs b/product/protocol/src/lib.rs index 3fb97122ff8..05e40ccaf67 100644 --- a/product/protocol/src/lib.rs +++ b/product/protocol/src/lib.rs @@ -25,7 +25,7 @@ pub struct Branding { } impl Default for Branding { fn default() -> Self { - Self { display_name: PRODUCT.into(), primary_color: "#004B6E".into(), + Self { display_name: PRODUCT.into(), primary_color: "#007F82".into(), logo_svg: include_str!("../../../branding/swan-support-mark.svg").into(), support_url: String::new(), consent_text: "Only allow remote support from a technician you trust. You can stop support or revoke ongoing access at any time.".into() } From 9f66395d722b812bf0f0f58c39b24dd60367abea Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Fri, 2 Oct 2026 09:06:39 +0200 Subject: [PATCH 036/652] Keep interrupted updates closed to sessions until full identity recovery --- docs/ACCEPTANCE.md | 7 +++++++ docs/RELEASE_PAYLOAD.md | 8 ++++++++ product/agent/src/lib.rs | 3 ++- product/agent/src/update.rs | 18 +++++++++++++++--- 4 files changed, 32 insertions(+), 4 deletions(-) diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index b5c18996061..40c22cfc6de 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -120,3 +120,10 @@ including the bundled swan/gear SVG and teal primary color. The server keeps its Swan wordmark and mark; existing company profiles are not rewritten. Product workspace tests (23) and administration JavaScript syntax checks pass. Graphical rendering and installed-app behavior remain to verify. + +Update recovery now retains session exclusion after process exit through the +pending-update receipt. A new regression test verifies rejection after the +exclusive process lock has closed; all 24 workspace tests pass. Installed-agent +hash/publisher and Windows compatibility are included in recovery validation. +Full signed agent replacement, retry/rollback and VM recovery remain unverified +or incomplete. diff --git a/docs/RELEASE_PAYLOAD.md b/docs/RELEASE_PAYLOAD.md index e18427a552e..3726517784e 100644 --- a/docs/RELEASE_PAYLOAD.md +++ b/docs/RELEASE_PAYLOAD.md @@ -40,3 +40,11 @@ the mandatory manifest are rejected; do not weaken validation to import them. No production compatibility or signing approval is implied. Full real signed payload installation, update replacement, rollback and native behavior remain acceptance requirements. + +Interrupted-update receipts also block new managed sessions after the updater +process exits or releases its activity lock. Recovery rechecks Windows support +and the installed agent hash and publisher as well as the endpoint payload. +An old or missing agent cannot be accepted as a completed release. Automatic +agent replacement and full interrupted-install retry/rollback remain incomplete; +a changed-agent release stays pending until its full installed identity is +restored and verified. diff --git a/product/agent/src/lib.rs b/product/agent/src/lib.rs index 8219dfba37e..209c63e7d92 100644 --- a/product/agent/src/lib.rs +++ b/product/agent/src/lib.rs @@ -238,7 +238,8 @@ pub fn activity_file(directory:&Path)->Result { } pub fn lock_session(directory:&Path)->Result { let file=activity_file(directory)?;fs2::FileExt::try_lock_shared(&file).context("An update or installation is in progress")?; - ensure!(!directory.join("pending-install.json").exists(),"Company installation requires recovery before support sessions can start");Ok(file) + ensure!(!directory.join("pending-install.json").exists(),"Company installation requires recovery before support sessions can start"); + ensure!(!directory.join("pending-update.json").exists(),"Software update requires recovery before support sessions can start");Ok(file) } pub fn state_directory()->PathBuf { if let Some(directory)=std::env::var_os("SWAN_STATE_DIR"){return directory.into();} diff --git a/product/agent/src/update.rs b/product/agent/src/update.rs index 529723bccd2..18100122bb8 100644 --- a/product/agent/src/update.rs +++ b/product/agent/src/update.rs @@ -131,9 +131,6 @@ impl AgentState { } verify_compatibility(directory,&release)?; verify_installed(directory,&release)?; - let agent=directory.join("swan-agent.exe"); - ensure!(digest(std::fs::read(&agent)?).eq_ignore_ascii_case(&release.agent_sha256),"Installed configuration agent differs from signed release"); - let mut agent_release=release.clone();agent_release.sha256=release.agent_sha256.clone();verify_publisher(&agent,&agent_release)?; save_installed_metadata(directory,envelope)?; latest.last_release_sequence=release.sequence;latest.save(directory)?; if setup_marker.exists(){std::fs::remove_file(setup_marker)?;} @@ -149,6 +146,7 @@ impl AgentState { release.validate(&self.bootstrap.edition,receipt.previous_sequence,now().min(release.expires_at.saturating_sub(1)))?; ensure!(release.edition==self.bootstrap.edition && release.product==PRODUCT && release.schema==SCHEMA,"Wrong recovery release"); ensure!(self.last_release_sequence==receipt.previous_sequence || self.last_release_sequence==release.sequence,"Recovery sequence conflict"); + verify_compatibility(directory,&release)?; verify_installed(directory,&release)?; save_installed_metadata(directory,&receipt.release)?; // A crashed updater may have installed successfully or saved state before @@ -213,6 +211,9 @@ impl AgentState { #[cfg(windows)] fn verify_installed(directory:&Path,release:&Release)->Result<()> { + let agent=directory.join("swan-agent.exe"); + ensure!(digest(std::fs::read(&agent)?).eq_ignore_ascii_case(&release.agent_sha256),"Installed configuration agent differs from signed release; recovery remains pending"); + let mut agent_release=release.clone();agent_release.sha256=release.agent_sha256.clone();verify_publisher(&agent,&agent_release)?; let target=installed_target(directory,&release.edition)?; // Portable technician state also contains the agent and updater. Its single // packed EXE is verified separately; customer files share an install root. @@ -242,6 +243,17 @@ mod tests { std::fs::remove_file(folder.join("pending-install.json")).unwrap(); assert!(lock_session(&folder).is_ok());std::fs::remove_dir_all(folder).unwrap(); } + #[test] + fn interrupted_update_blocks_sessions_after_process_lock_is_released(){ + let folder=std::env::temp_dir().join(format!("swan-pending-update-{}",random_token())); + std::fs::create_dir(&folder).unwrap(); + let updater=activity_file(&folder).unwrap();fs2::FileExt::try_lock_exclusive(&updater).unwrap(); + std::fs::write(folder.join("pending-update.json"),b"interrupted update fixture").unwrap(); + drop(updater); + assert!(lock_session(&folder).is_err()); + std::fs::remove_file(folder.join("pending-update.json")).unwrap(); + assert!(lock_session(&folder).is_ok());std::fs::remove_dir_all(folder).unwrap(); + } #[cfg(windows)] #[test] fn powershell_installer_byte_lock_excludes_native_sessions(){ From 6b5b26da5bd082a3675f009822a5c2ec86f12a15 Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Fri, 2 Oct 2026 09:15:33 +0200 Subject: [PATCH 037/652] Hand off approved updates to a staged signed replacement agent --- deployment/windows/Restart-Configuration.ps1 | 16 +++ docs/ACCEPTANCE.md | 6 + docs/RELEASE_PAYLOAD.md | 26 +++- product/agent/src/main.rs | 24 +++- product/agent/src/update.rs | 121 ++++++++++++++++--- 5 files changed, 169 insertions(+), 24 deletions(-) create mode 100644 deployment/windows/Restart-Configuration.ps1 diff --git a/deployment/windows/Restart-Configuration.ps1 b/deployment/windows/Restart-Configuration.ps1 new file mode 100644 index 00000000000..28f7495e058 --- /dev/null +++ b/deployment/windows/Restart-Configuration.ps1 @@ -0,0 +1,16 @@ +[CmdletBinding()] +param([Parameter(Mandatory=$true)][string]$Directory) +$ErrorActionPreference = 'Stop' +$expectedAgent = [IO.Path]::GetFullPath((Join-Path $Directory 'swan-agent.exe')) +$deadline = [DateTime]::UtcNow.AddSeconds(30) +do { + $tasks = @(Get-ScheduledTask | Where-Object { $_.TaskName -eq 'Swan Company Configuration' }) + if ($tasks.Count -ne 1) { throw 'Expected one company configuration task.' } + $task = $tasks[0] + if ($task.Actions.Count -ne 1 -or [IO.Path]::GetFullPath($task.Actions[0].Execute) -ine $expectedAgent -or $task.Actions[0].Arguments -cne 'watch' -or $task.Principal.UserId -notin @('SYSTEM','S-1-5-18','NT AUTHORITY\SYSTEM')) { throw 'Configuration task identity does not match this installation.' } + if ($task.State -ne 'Running') { break } + if ([DateTime]::UtcNow -ge $deadline) { throw 'Previous configuration task has not exited.' } + Start-Sleep -Milliseconds 100 +} while ($true) +$task | Enable-ScheduledTask | Out-Null +$task | Start-ScheduledTask diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index 40c22cfc6de..b45cc976797 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -127,3 +127,9 @@ exclusive process lock has closed; all 24 workspace tests pass. Installed-agent hash/publisher and Windows compatibility are included in recovery validation. Full signed agent replacement, retry/rollback and VM recovery remain unverified or incomplete. + +Staged signed-agent handoff and agent replacement are now implemented in source, +including receipt-based retry and validation of the customer SYSTEM task before +restart. Windows Rust compilation and existing 24 product tests pass; signed +self-replacement, interrupted installer retry, scheduled-task restart and +technician handoff require actual VM tests. Rollback remains incomplete. diff --git a/docs/RELEASE_PAYLOAD.md b/docs/RELEASE_PAYLOAD.md index 3726517784e..20cb731e707 100644 --- a/docs/RELEASE_PAYLOAD.md +++ b/docs/RELEASE_PAYLOAD.md @@ -44,7 +44,25 @@ acceptance requirements. Interrupted-update receipts also block new managed sessions after the updater process exits or releases its activity lock. Recovery rechecks Windows support and the installed agent hash and publisher as well as the endpoint payload. -An old or missing agent cannot be accepted as a completed release. Automatic -agent replacement and full interrupted-install retry/rollback remain incomplete; -a changed-agent release stays pending until its full installed identity is -restored and verified. +An old or missing agent cannot be accepted as a completed release. Agent replacement is implemented through a staged signed helper; its real +Windows installation and full rollback behavior remain unverified. A release +stays pending until its full installed identity is restored and verified. + +Automatic updates now stage both the installer and agent and verify their +signed hashes and pinned publishers before writing the pending receipt. The +watcher hands off to the staged agent and exits. The helper acquires exclusive +session exclusion, revalidates the receipt and staged artifacts, installs the +endpoint, replaces the old agent, and verifies the complete installed identity +before advancing the sequence or removing the receipt. Customer completion +restarts only the existing SYSTEM task whose action matches the canonical agent +and `watch` arguments. No credentials are passed to the helper. + +A watcher can resume a pending update after restart; administrators can invoke +`swan-agent resume-update` to hand off explicitly. `recover-update` continues to +verify an already completed installation without rerunning its installer. +Recovery may finish the previously selected signed release after metadata expiry; +it cannot authorize a new release or relax the stored sequence. The helper log +and previous agent/portable executable are retained in the protected update +staging directory. Automatic rollback, damaged-stage re-download and graphical +technician update coordination remain incomplete. Signed clean-machine tests +are required before claiming interruption or self-update acceptance. diff --git a/product/agent/src/main.rs b/product/agent/src/main.rs index b0069c51355..f90bc2ab67a 100644 --- a/product/agent/src/main.rs +++ b/product/agent/src/main.rs @@ -43,12 +43,32 @@ async fn main()->Result<()> { if current.bootstrap.edition==Edition::Customer && current.device_id.is_some() && !current.unattended_consent { if let Err(error)=current.consent(false).await {eprintln!("Consent revocation synchronization deferred: {error}");} } - if state.bootstrap.edition==Edition::Customer {if let Err(error)=state.update(&directory,None).await {eprintln!("Update deferred: {error}");}} + if state.bootstrap.edition==Edition::Customer {match state.update(&directory,None).await {Ok(true)=>return Ok(()),Ok(false)=>{},Err(error)=>eprintln!("Update deferred: {error}")}} },Err(error)=>eprintln!("Configuration unavailable: {error}")};tokio::time::sleep(std::time::Duration::from_secs(300)).await;}} "update"=>{ let mut state=AgentState::load_for_refresh(&directory)?;state.sync().await?;state.save(&directory)?; let token=std::env::var("SWAN_TECHNICIAN_TOKEN").ok(); - println!("Update installed: {}",state.update(&directory,token.as_deref()).await?); + println!("Update handed off: {}",state.update(&directory,token.as_deref()).await?); + } + #[cfg(windows)] + "resume-update"=>{ + let state=AgentState::load_for_refresh(&directory)?; + println!("Update recovery handed off: {}",state.resume_pending_update(&directory)?); + } + #[cfg(windows)] + "apply-update"=>{ + let update_directory=directory.clone(); + let completed=tokio::task::spawn_blocking(move ||->anyhow::Result{ + let mut state=AgentState::load_for_refresh(&update_directory)?; + state.apply_pending_update(&update_directory) + }).await??; + println!("Installed release verified: {completed}"); + if completed && AgentState::load_for_refresh(&directory)?.bootstrap.edition==Edition::Customer { + let script=directory.join("Restart-Configuration.ps1"); + std::fs::write(&script,include_str!("../../../deployment/windows/Restart-Configuration.ps1"))?; + let status=std::process::Command::new("powershell.exe").args(["-NoLogo","-NoProfile","-NonInteractive","-ExecutionPolicy","Bypass","-File"]).arg(script).arg("-Directory").arg(&directory).status()?; + ensure!(status.success(),"Update installed but configuration task restart failed"); + } } #[cfg(windows)] "record-installation"=>{ diff --git a/product/agent/src/update.rs b/product/agent/src/update.rs index 18100122bb8..83ee8e77d85 100644 --- a/product/agent/src/update.rs +++ b/product/agent/src/update.rs @@ -118,6 +118,20 @@ fn save_installed_metadata(directory:&Path,envelope:&SignedEnvelope)->Result<()> crate::replace_state(&temporary,&directory.join("installed-release.json")) } +#[cfg(windows)] +fn launch_update_helper(directory:&Path,release:&Release)->Result<()> { + use std::os::windows::process::CommandExt; + let folder=directory.join("updates").join(release.sequence.to_string()); + let helper=folder.join("swan-agent.exe"); + ensure!(digest(std::fs::read(&helper)?).eq_ignore_ascii_case(&release.agent_sha256),"Staged update helper hash mismatch"); + let mut agent_release=release.clone();agent_release.sha256=release.agent_sha256.clone();verify_publisher(&helper,&agent_release)?; + let log=std::fs::OpenOptions::new().create(true).append(true).open(folder.join("apply-update.log"))?; + std::process::Command::new(helper).arg("apply-update").env("SWAN_STATE_DIR",directory).env_remove("SWAN_TECHNICIAN_TOKEN") + .creation_flags(0x08000000).stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::from(log.try_clone()?)).stderr(std::process::Stdio::from(log)).spawn()?; + Ok(()) +} + impl AgentState { #[cfg(windows)] pub fn record_installation(&mut self,directory:&Path,envelope:&SignedEnvelope,repair:bool)->Result<()> { @@ -154,10 +168,90 @@ impl AgentState { self.last_release_sequence=release.sequence;self.save(directory)?; std::fs::remove_file(receipt_path)?;Ok(()) } + #[cfg(windows)] + pub fn resume_pending_update(&self,directory:&Path)->Result { + let activity=activity_file(directory)?;fs2::FileExt::try_lock_exclusive(&activity).context("Update recovery already running or sessions remain active")?; + let receipt_path=directory.join("pending-update.json"); + if !receipt_path.exists(){return Ok(false);} + ensure!(!directory.join("pending-install.json").exists(),"Company setup recovery must finish first"); + let latest=AgentState::load_for_refresh(directory)?; + let receipt:Receipt=serde_json::from_slice(&std::fs::read(&receipt_path)?)?; + let release:Release=receipt.release.verify(&public_key(&latest.bootstrap.release_public_key)?)?; + ensure!(receipt.phase=="installing","Unknown update recovery phase"); + release.validate(&latest.bootstrap.edition,receipt.previous_sequence,now().min(release.expires_at.saturating_sub(1)))?; + ensure!(latest.last_release_sequence==receipt.previous_sequence || latest.last_release_sequence==release.sequence,"Recovery sequence conflict"); + verify_compatibility(directory,&release)?; + launch_update_helper(directory,&release)?;Ok(true) + } + #[cfg(windows)] + pub fn apply_pending_update(&mut self,directory:&Path)->Result { + // This method performs blocking Windows installation and is called from + // spawn_blocking. A bounded wait lets the preparing process release its lock. + let activity=activity_file(directory)?; + let deadline=std::time::Instant::now()+std::time::Duration::from_secs(30); + loop { + match fs2::FileExt::try_lock_exclusive(&activity) { + Ok(())=>break, + Err(error)=>{ensure!(std::time::Instant::now()break, + Err(error) if matches!(error.raw_os_error(),Some(5|32|33)) && std::time::Instant::now()std::thread::sleep(std::time::Duration::from_millis(100)), + Err(error)=>return Err(error).context("Agent replacement failed; retain update receipt"), + } + } + } + } + verify_installed(directory,&release)?; + save_installed_metadata(directory,&receipt.release)?; + latest.last_release_sequence=release.sequence;latest.save(directory)?; + std::fs::remove_file(receipt_path)?;*self=latest;Ok(true) + } pub async fn update(&mut self,directory:&Path,technician_token:Option<&str>)->Result { #[cfg(not(windows))] {let _=(directory,technician_token);anyhow::bail!("Endpoint updates require Windows");} #[cfg(windows)] { ensure!(!directory.join("pending-install.json").exists(),"Company setup recovery is required before automatic updates"); + if directory.join("pending-update.json").exists(){return self.resume_pending_update(directory);} require_existing_application(&installed_target(directory,&self.bootstrap.edition)?)?; let profile=self.company_profile()?; if profile.updates_paused || !maintenance_open(profile.maintenance_start_utc,profile.maintenance_end_utc,now()){return Ok(false);} @@ -176,11 +270,15 @@ impl AgentState { let package=folder.join(format!("SwanRemoteSupport-install.{}",release.format)); download(&release.artifact_url,&release.sha256,&package).await?; verify_publisher(&package,&release)?; + let replacement_agent=folder.join("swan-agent.exe"); + download(&release.agent_url,&release.agent_sha256,&replacement_agent).await?; + let mut agent_release=release.clone();agent_release.sha256=release.agent_sha256.clone(); + verify_publisher(&replacement_agent,&agent_release)?; let activity=activity_file(directory)?; fs2::FileExt::try_lock_exclusive(&activity).context("Update deferred while a session or technician app is active")?; ensure!(!directory.join("pending-install.json").exists(),"Company setup started while the update was downloading"); // Re-read consent and enrollment immediately before installation. - let mut latest=AgentState::load(directory)?; + let latest=AgentState::load(directory)?; // Removal during download must not be turned into a fresh install. require_existing_application(&installed_target(directory,&latest.bootstrap.edition)?)?; validate_release(&latest,&envelope)?; @@ -188,23 +286,10 @@ impl AgentState { let mut options=std::fs::OpenOptions::new();options.create_new(true).write(true); use std::io::Write; let mut file=options.open(&receipt_path)?;file.write_all(&serde_json::to_vec(&receipt)?)?;file.sync_all()?;drop(file); - if latest.bootstrap.edition==Edition::Technician { - ensure!(release.format=="exe","Technician portable updates require EXE artifacts"); - let target=directory.join("SwanRemoteSupport-Technician.exe"); - if target.exists(){std::fs::copy(&target,directory.join("SwanRemoteSupport-Technician.previous.exe"))?;} - std::fs::copy(&package,&target)?; - }else{ - let package_for_install=package.clone();let format=release.format.clone(); - let status=tokio::task::spawn_blocking(move || ->Result{ - let mut command=if format=="msi" {let mut c=std::process::Command::new("msiexec.exe");c.arg("/i").arg(&package_for_install).args(["/qn","/norestart"]);c}else{let mut c=std::process::Command::new(package_for_install);c.args(["--silent-install","printer=0"]);c}; - Ok(command.status()?) - }).await??; - ensure!(matches!(status.code(),Some(0|3010)),"Installation failed; retain signed recovery receipt"); - } - verify_installed(directory,&release)?; - save_installed_metadata(directory,&receipt.release)?; - latest.last_release_sequence=release.sequence;latest.save(directory)?; - std::fs::remove_file(receipt_path)?;*self=latest;Ok(true) + // The signed replacement runs from staging. This caller must exit so + // Windows releases the old executable before it is overwritten. + launch_update_helper(directory,&release)?; + *self=latest;Ok(true) } } } From 02f730405d050a9a6752646a563186a6073f732f Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Fri, 2 Oct 2026 09:21:18 +0200 Subject: [PATCH 038/652] Validate recovery trust consistently and retain delayed retries after failure --- deployment/windows/Restart-Configuration.ps1 | 1 + docs/ACCEPTANCE.md | 5 +++ docs/RELEASE_PAYLOAD.md | 11 +++++ product/agent/src/lib.rs | 25 ++++++++++++ product/agent/src/main.rs | 16 +++++--- product/agent/src/update.rs | 42 ++++++++++++-------- 6 files changed, 78 insertions(+), 22 deletions(-) diff --git a/deployment/windows/Restart-Configuration.ps1 b/deployment/windows/Restart-Configuration.ps1 index 28f7495e058..3beb43259c5 100644 --- a/deployment/windows/Restart-Configuration.ps1 +++ b/deployment/windows/Restart-Configuration.ps1 @@ -12,5 +12,6 @@ do { if ([DateTime]::UtcNow -ge $deadline) { throw 'Previous configuration task has not exited.' } Start-Sleep -Milliseconds 100 } while ($true) +if ((Get-AuthenticodeSignature -LiteralPath $expectedAgent).Status -ne 'Valid') { throw 'Installed agent cannot be restarted until its signature is valid.' } $task | Enable-ScheduledTask | Out-Null $task | Start-ScheduledTask diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index b45cc976797..1e98356f242 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -133,3 +133,8 @@ including receipt-based retry and validation of the customer SYSTEM task before restart. Windows Rust compilation and existing 24 product tests pass; signed self-replacement, interrupted installer retry, scheduled-task restart and technician handoff require actual VM tests. Rollback remains incomplete. + +Recovery trust regression checks and shared validation pass with 25 Windows +product workspace tests. Durable retry delay and failed-helper task restart are +implemented in source and PowerShell parsing passes. These checks do not prove +signed installation, power-loss recovery or rollback on clean Windows guests. diff --git a/docs/RELEASE_PAYLOAD.md b/docs/RELEASE_PAYLOAD.md index 20cb731e707..7a560c58ec4 100644 --- a/docs/RELEASE_PAYLOAD.md +++ b/docs/RELEASE_PAYLOAD.md @@ -66,3 +66,14 @@ and previous agent/portable executable are retained in the protected update staging directory. Automatic rollback, damaged-stage re-download and graphical technician update coordination remain incomplete. Signed clean-machine tests are required before claiming interruption or self-update acceptance. + +Recovery validation is shared by completion, retry and helper execution. Its +regression tests reject wrong keys/editions, tampering, conflicting sequences +and unknown phases, including the case where a crash occurred after sequence +persistence. Automatic retries use a durable five-minute attempt delay. Explicit +`resume-update` requests retry immediately while retaining all trust and locking +checks. A failed helper requests restart of the matching customer task, provided +its installed agent still has a valid signature; pending receipts continue to +block sessions. Corrupt or missing canonical agents still require recovery from +a verified staged helper or explicit setup/repair. Actual scheduled-task and +power-loss behavior remain unverified. diff --git a/product/agent/src/lib.rs b/product/agent/src/lib.rs index 209c63e7d92..fdf3e47adcf 100644 --- a/product/agent/src/lib.rs +++ b/product/agent/src/lib.rs @@ -283,6 +283,31 @@ mod tests { let mut changed=original;changed.profile_public_key=STANDARD.encode(SigningKey::from_bytes(&[12;32]).verifying_key().as_bytes());assert!(state.validate_installer_bootstrap(&changed).is_err()); } #[test] + fn update_recovery_rejects_tampering_sequence_conflicts_and_new_install_replay(){ + let mut state=fixture();state.last_release_sequence=1; + let key=SigningKey::from_bytes(&[7;32]); + let mut release=Release{schema:1,product:PRODUCT.into(),version:"1.5.0".into(),sequence:2,edition:Edition::Customer,architecture:"x64".into(),channel:"test".into(),expires_at:now()+3600,artifact_url:"https://releases.example/package.exe".into(),sha256:"a".repeat(64),installed_sha256:"b".repeat(64),installed_files:vec![InstalledFile{path:"Swan Remote Support.exe".into(),sha256:"b".repeat(64)},InstalledFile{path:"librustdesk.dll".into(),sha256:"c".repeat(64)},InstalledFile{path:"flutter_windows.dll".into(),sha256:"d".repeat(64)}],agent_url:"https://releases.example/agent.exe".into(),agent_sha256:"c".repeat(64),publisher:"Example".into(),publisher_certificate_sha256:"d".repeat(64),windows_versions:vec!["windows_11".into()],source_url:"https://releases.example/source.tar.gz".into(),format:"exe".into()}; + let envelope=SignedEnvelope::sign(&release,&key).unwrap(); + assert!(update::validate_recovery_release(&state,&envelope,1,"installing").is_ok()); + assert!(update::validate_recovery_release(&state,&envelope,0,"installing").is_err()); + assert!(update::validate_recovery_release(&state,&envelope,2,"installing").is_err()); + assert!(update::validate_recovery_release(&state,&envelope,1,"unknown").is_err()); + let mut tampered=envelope.clone();tampered.signature=STANDARD.encode([0u8;64]); + assert!(update::validate_recovery_release(&state,&tampered,1,"installing").is_err()); + let foreign=SignedEnvelope::sign(&release,&SigningKey::from_bytes(&[9;32])).unwrap(); + assert!(update::validate_recovery_release(&state,&foreign,1,"installing").is_err()); + state.last_release_sequence=3; + assert!(update::validate_recovery_release(&state,&envelope,1,"installing").is_err()); + state.last_release_sequence=2; + assert!(update::validate_recovery_release(&state,&envelope,1,"installing").is_ok(),"Recovery can finish after the sequence was saved before a crash"); + state.last_release_sequence=1;release.expires_at=now()-1; + let expired=SignedEnvelope::sign(&release,&key).unwrap(); + assert!(update::validate_recovery_release(&state,&expired,1,"installing").is_ok(),"Previously selected interrupted work can finish"); + assert!(update::validate_release(&state,&expired).is_err(),"Expired metadata cannot start a new update"); + state.bootstrap.edition=Edition::Technician; + assert!(update::validate_recovery_release(&state,&expired,1,"installing").is_err()); + } + #[test] fn explicit_repair_accepts_only_exact_pinned_release_without_weakening_updates() { let mut state=fixture();let key=SigningKey::from_bytes(&[7;32]); let mut release=Release{schema:1,product:PRODUCT.into(),version:"1.5.0".into(),sequence:2,edition:Edition::Customer,architecture:"x64".into(),channel:"test".into(),expires_at:now()+3600,artifact_url:"https://releases.example/package.exe".into(),sha256:"a".repeat(64),installed_sha256:"b".repeat(64),installed_files:vec![InstalledFile{path:"Swan Remote Support.exe".into(),sha256:"b".repeat(64)},InstalledFile{path:"librustdesk.dll".into(),sha256:"c".repeat(64)},InstalledFile{path:"flutter_windows.dll".into(),sha256:"d".repeat(64)}],agent_url:"https://releases.example/agent.exe".into(),agent_sha256:"c".repeat(64),publisher:"Example".into(),publisher_certificate_sha256:"d".repeat(64),windows_versions:vec!["windows_11".into()],source_url:"https://releases.example/source.tar.gz".into(),format:"exe".into()}; diff --git a/product/agent/src/main.rs b/product/agent/src/main.rs index f90bc2ab67a..3eb02e07fba 100644 --- a/product/agent/src/main.rs +++ b/product/agent/src/main.rs @@ -53,22 +53,26 @@ async fn main()->Result<()> { #[cfg(windows)] "resume-update"=>{ let state=AgentState::load_for_refresh(&directory)?; - println!("Update recovery handed off: {}",state.resume_pending_update(&directory)?); + println!("Update recovery handed off: {}",state.resume_pending_update(&directory,true)?); } #[cfg(windows)] "apply-update"=>{ let update_directory=directory.clone(); - let completed=tokio::task::spawn_blocking(move ||->anyhow::Result{ + let customer=AgentState::load_for_refresh(&directory)?.bootstrap.edition==Edition::Customer; + let outcome=tokio::task::spawn_blocking(move ||->anyhow::Result{ let mut state=AgentState::load_for_refresh(&update_directory)?; state.apply_pending_update(&update_directory) - }).await??; - println!("Installed release verified: {completed}"); - if completed && AgentState::load_for_refresh(&directory)?.bootstrap.edition==Edition::Customer { + }).await.context("Update helper task failed").and_then(|result|result); + if customer && (matches!(&outcome,Ok(true)) || outcome.is_err()) { let script=directory.join("Restart-Configuration.ps1"); std::fs::write(&script,include_str!("../../../deployment/windows/Restart-Configuration.ps1"))?; let status=std::process::Command::new("powershell.exe").args(["-NoLogo","-NoProfile","-NonInteractive","-ExecutionPolicy","Bypass","-File"]).arg(script).arg("-Directory").arg(&directory).status()?; - ensure!(status.success(),"Update installed but configuration task restart failed"); + if !status.success(){ + if let Err(error)=&outcome {eprintln!("Update remains pending: {error:#}");} + bail!("Configuration task restart failed; inspect the protected update log and resume recovery"); + } } + println!("Installed release verified: {}",outcome?); } #[cfg(windows)] "record-installation"=>{ diff --git a/product/agent/src/update.rs b/product/agent/src/update.rs index 83ee8e77d85..b0b8aff2422 100644 --- a/product/agent/src/update.rs +++ b/product/agent/src/update.rs @@ -40,6 +40,15 @@ pub fn validate_repair_release(state:&AgentState,envelope:&SignedEnvelope,instal Ok(release) } +#[cfg(any(windows,test))] +pub(crate) fn validate_recovery_release(state:&AgentState,envelope:&SignedEnvelope,previous_sequence:u64,phase:&str)->Result { + ensure!(phase=="installing","Unknown update recovery phase"); + let release:Release=envelope.verify(&public_key(&state.bootstrap.release_public_key)?)?; + release.validate(&state.bootstrap.edition,previous_sequence,now().min(release.expires_at.saturating_sub(1)))?; + ensure!(state.last_release_sequence==previous_sequence || state.last_release_sequence==release.sequence,"Recovery sequence conflict"); + Ok(release) +} + #[cfg(windows)] fn installation_release(state:&AgentState,directory:&Path,envelope:&SignedEnvelope,repair:bool)->Result { ensure!(!directory.join("pending-update.json").exists(),"Resolve pending update recovery before setup or repair"); @@ -125,6 +134,11 @@ fn launch_update_helper(directory:&Path,release:&Release)->Result<()> { let helper=folder.join("swan-agent.exe"); ensure!(digest(std::fs::read(&helper)?).eq_ignore_ascii_case(&release.agent_sha256),"Staged update helper hash mismatch"); let mut agent_release=release.clone();agent_release.sha256=release.agent_sha256.clone();verify_publisher(&helper,&agent_release)?; + use std::io::Write; + let attempt=folder.join(format!("last-attempt-{}.tmp",random_token())); + let mut file=std::fs::OpenOptions::new().create_new(true).write(true).open(&attempt)?; + file.write_all(now().to_string().as_bytes())?;file.sync_all()?;drop(file); + crate::replace_state(&attempt,&folder.join("last-attempt.txt"))?; let log=std::fs::OpenOptions::new().create(true).append(true).open(folder.join("apply-update.log"))?; std::process::Command::new(helper).arg("apply-update").env("SWAN_STATE_DIR",directory).env_remove("SWAN_TECHNICIAN_TOKEN") .creation_flags(0x08000000).stdin(std::process::Stdio::null()) @@ -155,11 +169,7 @@ impl AgentState { let activity=activity_file(directory)?;fs2::FileExt::try_lock_exclusive(&activity).context("Recovery requires all sessions to close")?; let receipt_path=directory.join("pending-update.json"); let receipt:Receipt=serde_json::from_slice(&std::fs::read(&receipt_path)?)?; - let release:Release=receipt.release.verify(&public_key(&self.bootstrap.release_public_key)?)?; - ensure!(receipt.phase=="installing","Unknown update recovery phase"); - release.validate(&self.bootstrap.edition,receipt.previous_sequence,now().min(release.expires_at.saturating_sub(1)))?; - ensure!(release.edition==self.bootstrap.edition && release.product==PRODUCT && release.schema==SCHEMA,"Wrong recovery release"); - ensure!(self.last_release_sequence==receipt.previous_sequence || self.last_release_sequence==release.sequence,"Recovery sequence conflict"); + let release=validate_recovery_release(self,&receipt.release,receipt.previous_sequence,&receipt.phase)?; verify_compatibility(directory,&release)?; verify_installed(directory,&release)?; save_installed_metadata(directory,&receipt.release)?; @@ -169,18 +179,20 @@ impl AgentState { std::fs::remove_file(receipt_path)?;Ok(()) } #[cfg(windows)] - pub fn resume_pending_update(&self,directory:&Path)->Result { + pub fn resume_pending_update(&self,directory:&Path,retry_now:bool)->Result { let activity=activity_file(directory)?;fs2::FileExt::try_lock_exclusive(&activity).context("Update recovery already running or sessions remain active")?; let receipt_path=directory.join("pending-update.json"); if !receipt_path.exists(){return Ok(false);} ensure!(!directory.join("pending-install.json").exists(),"Company setup recovery must finish first"); let latest=AgentState::load_for_refresh(directory)?; let receipt:Receipt=serde_json::from_slice(&std::fs::read(&receipt_path)?)?; - let release:Release=receipt.release.verify(&public_key(&latest.bootstrap.release_public_key)?)?; - ensure!(receipt.phase=="installing","Unknown update recovery phase"); - release.validate(&latest.bootstrap.edition,receipt.previous_sequence,now().min(release.expires_at.saturating_sub(1)))?; - ensure!(latest.last_release_sequence==receipt.previous_sequence || latest.last_release_sequence==release.sequence,"Recovery sequence conflict"); + let release=validate_recovery_release(&latest,&receipt.release,receipt.previous_sequence,&receipt.phase)?; verify_compatibility(directory,&release)?; + let attempt=directory.join("updates").join(release.sequence.to_string()).join("last-attempt.txt"); + if !retry_now && attempt.exists(){ + let previous=std::fs::read_to_string(attempt)?.parse::().context("Invalid update retry timestamp")?; + if now().saturating_sub(previous)<300{return Ok(false);} + } launch_update_helper(directory,&release)?;Ok(true) } #[cfg(windows)] @@ -200,10 +212,7 @@ impl AgentState { ensure!(!directory.join("pending-install.json").exists(),"Company setup recovery must finish first"); let receipt:Receipt=serde_json::from_slice(&std::fs::read(&receipt_path)?)?; let mut latest=AgentState::load_for_refresh(directory)?; - let release:Release=receipt.release.verify(&public_key(&latest.bootstrap.release_public_key)?)?; - ensure!(receipt.phase=="installing","Unknown update recovery phase"); - release.validate(&latest.bootstrap.edition,receipt.previous_sequence,now().min(release.expires_at.saturating_sub(1)))?; - ensure!(latest.last_release_sequence==receipt.previous_sequence || latest.last_release_sequence==release.sequence,"Recovery sequence conflict"); + let release=validate_recovery_release(&latest,&receipt.release,receipt.previous_sequence,&receipt.phase)?; verify_compatibility(directory,&release)?; let helper=std::env::current_exe()?; let mut agent_release=release.clone();agent_release.sha256=release.agent_sha256.clone(); @@ -213,7 +222,8 @@ impl AgentState { let package=folder.join(format!("SwanRemoteSupport-install.{}",release.format)); ensure!(digest(std::fs::read(&package)?).eq_ignore_ascii_case(&release.sha256),"Staged installer hash mismatch"); verify_publisher(&package,&release)?; - if verify_installed(directory,&release).is_err() { + if let Err(error)=verify_installed(directory,&release) { + eprintln!("Installed payload requires recovery: {error:#}"); if latest.bootstrap.edition==Edition::Technician { ensure!(release.format=="exe" && release.sha256.eq_ignore_ascii_case(&release.installed_sha256),"Technician updates require a portable EXE"); let target=installed_target(directory,&latest.bootstrap.edition)?; @@ -251,7 +261,7 @@ impl AgentState { #[cfg(not(windows))] {let _=(directory,technician_token);anyhow::bail!("Endpoint updates require Windows");} #[cfg(windows)] { ensure!(!directory.join("pending-install.json").exists(),"Company setup recovery is required before automatic updates"); - if directory.join("pending-update.json").exists(){return self.resume_pending_update(directory);} + if directory.join("pending-update.json").exists(){return self.resume_pending_update(directory,false);} require_existing_application(&installed_target(directory,&self.bootstrap.edition)?)?; let profile=self.company_profile()?; if profile.updates_paused || !maintenance_open(profile.maintenance_start_utc,profile.maintenance_end_utc,now()){return Ok(false);} From 5e9845b7b987d82a630aafd85bb6bbacfd68d4e3 Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Fri, 2 Oct 2026 09:41:30 +0200 Subject: [PATCH 039/652] Build technician MSI packages and support configured MSI setup and updates --- .github/workflows/swan-windows-build.yml | 14 ++++ deployment/windows/Build-TechnicianMsi.ps1 | 33 +++++++++ deployment/windows/Expand-WixBuildTools.ps1 | 49 +++++++++++++ deployment/windows/Install-Company.ps1 | 22 ++++-- deployment/windows/TECHNICIAN-MSI.md | 76 +++++++++++++++++++++ deployment/windows/msi/Technician.wxs | 35 ++++++++++ docs/ACCEPTANCE.md | 7 ++ product/agent/src/update.rs | 9 ++- 8 files changed, 235 insertions(+), 10 deletions(-) create mode 100644 deployment/windows/Build-TechnicianMsi.ps1 create mode 100644 deployment/windows/Expand-WixBuildTools.ps1 create mode 100644 deployment/windows/TECHNICIAN-MSI.md create mode 100644 deployment/windows/msi/Technician.wxs diff --git a/.github/workflows/swan-windows-build.yml b/.github/workflows/swan-windows-build.yml index bbcdf6e53c0..ab5681fc75a 100644 --- a/.github/workflows/swan-windows-build.yml +++ b/.github/workflows/swan-windows-build.yml @@ -168,6 +168,18 @@ jobs: Copy-Item deployment/windows/Install-Company.ps1,deployment/windows/Open-Technician.ps1,deployment/windows/Verify-Package.ps1,deployment/windows/Get-WindowsCompatibility.ps1 artifacts/ Copy-Item LICENCE artifacts/ + - name: Build unsigned technician MSI with pinned WiX + 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 + $hash = (Get-FileHash -LiteralPath $output -Algorithm SHA256).Hash.ToLowerInvariant() + Add-Content artifacts/SHA256SUMS.txt ($hash+' '+[IO.Path]::GetFileName($output)) + - name: Upload unsigned installer and checksum uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: @@ -176,6 +188,8 @@ jobs: 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/*.msi.build.json artifacts/SHA256SUMS.txt artifacts/swan-*.exe artifacts/Install-Company.ps1 diff --git a/deployment/windows/Build-TechnicianMsi.ps1 b/deployment/windows/Build-TechnicianMsi.ps1 new file mode 100644 index 00000000000..bc59334ddef --- /dev/null +++ b/deployment/windows/Build-TechnicianMsi.ps1 @@ -0,0 +1,33 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory=$true)][string]$WixExe, + [Parameter(Mandatory=$true)][string]$TechnicianExe, + [Parameter(Mandatory=$true)][string]$AgentExe, + [Parameter(Mandatory=$true)][ValidatePattern('^\d+\.\d+\.\d+$')][string]$Version, + [Parameter(Mandatory=$true)][string]$Output, + [switch]$UnsignedTest +) +$ErrorActionPreference = 'Stop' +$WixExe = (Resolve-Path -LiteralPath $WixExe).Path +$TechnicianExe = (Resolve-Path -LiteralPath $TechnicianExe).Path +$AgentExe = (Resolve-Path -LiteralPath $AgentExe).Path +$Output = [IO.Path]::GetFullPath($Output) +if ($Output -notmatch '-unsigned\.msi$') { throw 'Build output must end in -unsigned.msi; sign and approve the package separately.' } +if (Test-Path -LiteralPath $Output) { throw 'Refusing to overwrite an existing installer.' } +$toolSignature = Get-AuthenticodeSignature -LiteralPath $WixExe +if ($toolSignature.Status -ne 'Valid' -or $toolSignature.SignerCertificate.Subject -notmatch 'CN=WiX Toolset \(\.NET Foundation\)') { throw 'WiX tool publisher verification failed.' } +$toolVersion = (& $WixExe --version | Out-String).Trim() +if ($LASTEXITCODE -ne 0 -or $toolVersion -notmatch '^5\.0\.2(?:\+|$)') { throw 'WiX 5.0.2 is required.' } +if (-not $UnsignedTest) { + $technicianSignature = Get-AuthenticodeSignature -LiteralPath $TechnicianExe + $agentSignature = Get-AuthenticodeSignature -LiteralPath $AgentExe + if ($technicianSignature.Status -ne 'Valid' -or $agentSignature.Status -ne 'Valid' -or $technicianSignature.SignerCertificate.Thumbprint -ne $agentSignature.SignerCertificate.Thumbprint) { throw 'Production payloads must have valid signatures from the same release publisher.' } +} +$launcher = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot 'Open-Technician.ps1')) +$license = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '../../LICENCE')) +$source = Join-Path $PSScriptRoot 'msi/Technician.wxs' +New-Item -ItemType Directory -Force ([IO.Path]::GetDirectoryName($Output)) | Out-Null +& $WixExe build $source -arch x64 -d ('Version='+$Version) -d ('TechnicianExe='+$TechnicianExe) -d ('AgentExe='+$AgentExe) -d ('Launcher='+$launcher) -d ('License='+$license) -o $Output +if ($LASTEXITCODE -ne 0 -or -not (Test-Path -LiteralPath $Output)) { throw 'Technician MSI build failed.' } +[pscustomobject]@{version=$Version;wix=$toolVersion;unsigned_test=[bool]$UnsignedTest;sha256=(Get-FileHash -LiteralPath $Output -Algorithm SHA256).Hash.ToLowerInvariant();technician_sha256=(Get-FileHash -LiteralPath $TechnicianExe -Algorithm SHA256).Hash.ToLowerInvariant();agent_sha256=(Get-FileHash -LiteralPath $AgentExe -Algorithm SHA256).Hash.ToLowerInvariant()} | ConvertTo-Json | Set-Content -LiteralPath ($Output+'.build.json') +Write-Host 'Built unsigned technician MSI. Production distribution requires signing, signed release metadata and clean-machine verification.' diff --git a/deployment/windows/Expand-WixBuildTools.ps1 b/deployment/windows/Expand-WixBuildTools.ps1 new file mode 100644 index 00000000000..d0e5c74ab64 --- /dev/null +++ b/deployment/windows/Expand-WixBuildTools.ps1 @@ -0,0 +1,49 @@ +[CmdletBinding()] +param([Parameter(Mandatory=$true)][string]$Package,[Parameter(Mandatory=$true)][string]$OutputDirectory,[string]$SevenZip='7z.exe') +$ErrorActionPreference = 'Stop' +$Package = (Resolve-Path -LiteralPath $Package).Path +$OutputDirectory = [IO.Path]::GetFullPath($OutputDirectory) +if (Test-Path -LiteralPath $OutputDirectory) { throw 'Tool output directory already exists.' } +if ((Get-FileHash -LiteralPath $Package -Algorithm SHA256).Hash -ne '097383B2773BDD3D76A6A2CD3F4DE6357BAE59172E1B6FA5AD87CE062074E8D0') { throw 'Pinned WiX 5.0.2 package hash mismatch.' } +$raw = $OutputDirectory + '.raw' +if (Test-Path -LiteralPath $raw) { throw 'Raw extraction directory already exists.' } +& $SevenZip x $Package ('-o'+$raw) -y | Out-Null +if ($LASTEXITCODE -ne 0) { throw 'WiX extraction failed.' } +# Read MSI tables; never execute the supplier installer or its custom actions. +$installer = New-Object -ComObject WindowsInstaller.Installer +$database = $installer.OpenDatabase($Package,0) +$directories = @{} +$components = @{} +$view = $database.OpenView('SELECT `Directory`,`Directory_Parent`,`DefaultDir` FROM `Directory`') +$view.Execute() +while ($row = $view.Fetch()) { $directories[$row.StringData(1)] = @($row.StringData(2),$row.StringData(3)) } +$view.Close() +$view = $database.OpenView('SELECT `Component`,`Directory_` FROM `Component`') +$view.Execute() +while ($row = $view.Fetch()) { $components[$row.StringData(1)] = $row.StringData(2) } +$view.Close() +$view = $database.OpenView('SELECT `File`,`Component_`,`FileName` FROM `File`') +$view.Execute() +while ($row = $view.Fetch()) { + $parts = [Collections.Generic.List[string]]::new() + $directory = $components[$row.StringData(2)] + $depth = 0 + while ($directory -ne 'INSTALLFOLDER' -and $directory -and $directories.ContainsKey($directory)) { + if (++$depth -gt 16) { throw 'Unexpected supplier directory depth.' } + $name = ($directories[$directory][1] -split '\|')[-1] + if ($name -ne '.') { $parts.Insert(0,$name) } + $directory = $directories[$directory][0] + } + # Extension packages installed to separate user storage are not needed here. + if ($directory -ne 'INSTALLFOLDER') { continue } + $parts.Add(($row.StringData(3) -split '\|')[-1]) + $destination = [IO.Path]::GetFullPath((Join-Path $OutputDirectory ($parts -join '\'))) + if (-not $destination.StartsWith($OutputDirectory+'\',[StringComparison]::OrdinalIgnoreCase)) { throw 'Supplier path escaped the tool directory.' } + New-Item -ItemType Directory -Force ([IO.Path]::GetDirectoryName($destination)) | Out-Null + Copy-Item -LiteralPath (Join-Path $raw $row.StringData(1)) -Destination $destination +} +$view.Close() +$tool = Join-Path $OutputDirectory 'bin/wix.exe' +$signature = Get-AuthenticodeSignature -LiteralPath $tool +if ($signature.Status -ne 'Valid' -or $signature.SignerCertificate.Subject -notmatch 'CN=WiX Toolset \(\.NET Foundation\)') { throw 'Extracted WiX publisher verification failed.' } +Write-Output $tool diff --git a/deployment/windows/Install-Company.ps1 b/deployment/windows/Install-Company.ps1 index 810ae18068a..9ec222294ff 100644 --- a/deployment/windows/Install-Company.ps1 +++ b/deployment/windows/Install-Company.ps1 @@ -1,14 +1,17 @@ [CmdletBinding()] -param([switch]$UnattendedConsent,[switch]$Repair) +param([switch]$UnattendedConsent,[switch]$Repair,[string]$BootstrapPath,[string]$ConfirmedCompanyDomain) $ErrorActionPreference = 'Stop' $root = $PSScriptRoot -$bootstrap = Get-Content -LiteralPath (Join-Path $root 'bootstrap.json') -Raw | ConvertFrom-Json +if (-not $BootstrapPath) { $BootstrapPath = Join-Path $root 'bootstrap.json' } +$BootstrapPath = [IO.Path]::GetFullPath($BootstrapPath) +$bootstrap = Get-Content -LiteralPath $BootstrapPath -Raw | ConvertFrom-Json if ($bootstrap.edition -notin @('customer','technician')) { throw 'Unknown package edition.' } $domain = [uri]$bootstrap.management_url if ($domain.Scheme -ne 'https' -or $domain.UserInfo -or -not $domain.Host) { throw 'A company HTTPS endpoint is required.' } Write-Host "Company server: $($domain.AbsoluteUri)" Write-Host 'Confirm this is the support company you intended to install. The company must approve your device before remote access.' -if ((Read-Host 'Type the company server hostname to confirm') -cne $domain.Host) { throw 'Company verification cancelled.' } +if (-not $ConfirmedCompanyDomain) { $ConfirmedCompanyDomain = Read-Host 'Type the company server hostname to confirm' } +if ($ConfirmedCompanyDomain -cne $domain.Host) { throw 'Company verification cancelled.' } if ($UnattendedConsent) { Write-Host 'Unattended access allows approved company technicians to connect while you are absent, including the sign-in screen.' if ((Read-Host 'Type ALLOW ONGOING SUPPORT to consent') -cne 'ALLOW ONGOING SUPPORT') { throw 'Ongoing-access consent not granted.' } @@ -29,10 +32,10 @@ if ($bootstrap.edition -eq 'customer') { } $env:SWAN_STATE_DIR = $editionDirectory if (-not (Test-Path -LiteralPath (Join-Path $editionDirectory 'managed-state.json'))) { - & $agent setup (Join-Path $root 'bootstrap.json') --accept-company + & $agent setup $BootstrapPath --accept-company if ($LASTEXITCODE -ne 0) { throw 'Company configuration verification failed.' } } -& $agent verify-bootstrap (Join-Path $root 'bootstrap.json') +& $agent verify-bootstrap $BootstrapPath if ($LASTEXITCODE -ne 0) { throw 'Installer does not match the configured company, edition or trust keys.' } $installers = @(Get-ChildItem -LiteralPath $root -File | Where-Object { $_.Name -match '^SwanRemoteSupport-install\.(exe|msi)$' }) if ($installers.Count -ne 1) { throw 'Expected one company installer.' } @@ -87,9 +90,14 @@ if ($bootstrap.edition -eq 'customer') { $releaseEnvelope = Get-Content -LiteralPath (Join-Path $root 'release.json') -Raw | ConvertFrom-Json # The agent already verified this signed metadata and installer before any copy. $release = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($releaseEnvelope.payload)) | ConvertFrom-Json - if ($release.format -ne 'exe' -or $release.sha256 -cne $release.installed_sha256) { throw 'Technician setup requires a portable EXE with matching installed identity.' } $portable = Join-Path $editionDirectory 'SwanRemoteSupport-Technician.exe' - Copy-Item -LiteralPath $installers[0].FullName -Destination $portable -Force + if ($release.format -eq 'msi') { + $process = Start-Process -FilePath 'msiexec.exe' -ArgumentList @('/i',('"' + $installers[0].FullName + '"'),'/passive','/norestart',('INSTALLFOLDER="' + $editionDirectory + '"')) -PassThru -Wait -WindowStyle Hidden + if ($process.ExitCode -notin @(0,3010)) { throw "Technician MSI installation failed ($($process.ExitCode))." } + if (-not (Test-Path -LiteralPath $portable)) { throw 'Technician MSI did not install the expected executable.' } + } elseif ($release.format -eq 'exe' -and $release.sha256 -ceq $release.installed_sha256) { + Copy-Item -LiteralPath $installers[0].FullName -Destination $portable -Force + } else { throw 'Technician setup requires an MSI or a portable EXE with matching installed identity.' } Copy-Item -LiteralPath $agent -Destination (Join-Path $editionDirectory 'swan-agent.exe') -Force } } finally { diff --git a/deployment/windows/TECHNICIAN-MSI.md b/deployment/windows/TECHNICIAN-MSI.md new file mode 100644 index 00000000000..74abde08615 --- /dev/null +++ b/deployment/windows/TECHNICIAN-MSI.md @@ -0,0 +1,76 @@ +# Technician MSI packaging + +`msi/Technician.wxs` defines the generic per-user x64 technician MSI. Its fixed +upgrade and component identifiers do not depend on a company display name. It +installs the packed technician executable, agent, graphical-launcher script and +license under `%LOCALAPPDATA%/SwanRemoteSupport-Technician`, with a Start menu +shortcut. Upgrade removal is scheduled inside the MSI transaction. Company +state is not an MSI-owned file and is preserved through major upgrades. + +Build with pinned WiX 5.0.2: + +```powershell +./deployment/windows/Build-TechnicianMsi.ps1 ` + -WixExe C:/BuildTools/WiX/bin/wix.exe ` + -TechnicianExe C:/Release/SwanRemoteSupport-Technician.exe ` + -AgentExe C:/Release/swan-agent.exe ` + -Version 1.5.0 -Output C:/Release/SwanRemoteSupport-Technician-unsigned.msi +``` + +Production inputs require valid signatures from the same release publisher. +The builder itself produces an unsigned MSI; sign that container separately, +then compute release hashes and publish signed release metadata. `-UnsignedTest` +allows clearly marked local/CI test payloads and does not bypass installation, +worker or updater trust checks. No project signing key is provided to a company. + +The official WiX package is pinned to SHA-256 +`097383b2773bdd3d76a6a2cd3f4de6357bae59172e1b6fa5ad87ce062074e8d0`. +`Expand-WixBuildTools.ps1` reads its MSI tables and extracts tools without running +the supplier installer. `Build-TechnicianMsi.ps1` checks the extracted executable +publisher and exact tool version before execution. Native CI includes this +recipe and uploads the unsigned MSI with its build-input hashes. + +A company worker consumes the approved unchanged MSI release and bundles it +with public company bootstrap data and canonical setup scripts. Technician +bundle downloads require company authentication. Run `Install-Company.ps1` to +validate, configure and record installation, rather than launching the bare MSI +and assuming it has enrolled or configured the app. + +For a managed deployment, an explicit public bootstrap file can be provided: + +```powershell +./Install-Company.ps1 -BootstrapPath C:/Deployment/company-bootstrap.json ` + -ConfirmedCompanyDomain support.example.com +``` + +This confirms the exact HTTPS hostname and retains signed endpoint/profile, +company ownership, hash, publisher and installed-identity checks. The bootstrap +contains no technician credentials. Without this explicit hostname, setup asks +for confirmation interactively. Unattended customer consent remains a separate +explicit action; this parameter cannot grant it. + +The technician updater supports both portable EXEs and MSI containers. MSI +artifact and installed executable hashes are distinct signed fields. MSI +installation uses the canonical per-user folder and requires full installed +application/agent verification before the pending receipt is cleared. + +## Evidence and remaining work + +An actual unsigned MSI was built locally with WiX `5.0.2+aa65968c` from the +verified native `31b13aa` artifact. The MSI tables contain the four expected files, +stable upgrade code, per-user location, major-upgrade behavior and Windows +launch conditions. A second build rehearsed the extraction/build scripts. +Packages and their hashes remain outside Git. No supplier installer or product +installer ran on this host during these builds. Rust product tests and +PowerShell parsing pass. + +Clean-machine installation, company setup, repair, versioned major upgrades, +uninstall cleanup, signed automatic MSI updates and interruption rollback remain +unverified. Customer MSI authoring and end-to-end company publication remain +incomplete. The bare MSI launch conditions do not replace the signed agent's +Windows compatibility preflight. Advertised Windows compatibility requires the +full acceptance matrix. + +Sources: [WiX 5.0.2](https://github.com/wixtoolset/wix/releases/tag/v5.0.2), +[package scope](https://docs.firegiant.com/wix/schema/wxs/package/) and +[major-upgrade transaction scheduling](https://docs.firegiant.com/wix/schema/wxs/majorupgrade/). diff --git a/deployment/windows/msi/Technician.wxs b/deployment/windows/msi/Technician.wxs new file mode 100644 index 00000000000..f088d4496e3 --- /dev/null +++ b/deployment/windows/msi/Technician.wxs @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index 1e98356f242..b5f2ac5caab 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -138,3 +138,10 @@ Recovery trust regression checks and shared validation pass with 25 Windows product workspace tests. Durable retry delay and failed-helper task restart are implemented in source and PowerShell parsing passes. These checks do not prove signed installation, power-loss recovery or rollback on clean Windows guests. + +Technician MSI authoring and a pinned WiX extraction/build recipe now produce +an actual unsigned package locally. MSI tables and build-input hashes were +inspected; no installer ran on the host. Technician setup/update support MSI +containers and explicit external bootstrap input. Clean-guest install/upgrade, +signing, customer MSI and complete uninstall cleanup remain acceptance gates; +see deployment/windows/TECHNICIAN-MSI.md. diff --git a/product/agent/src/update.rs b/product/agent/src/update.rs index b0b8aff2422..55466c9bdda 100644 --- a/product/agent/src/update.rs +++ b/product/agent/src/update.rs @@ -225,12 +225,15 @@ impl AgentState { if let Err(error)=verify_installed(directory,&release) { eprintln!("Installed payload requires recovery: {error:#}"); if latest.bootstrap.edition==Edition::Technician { - ensure!(release.format=="exe" && release.sha256.eq_ignore_ascii_case(&release.installed_sha256),"Technician updates require a portable EXE"); + ensure!(release.format=="msi" || (release.format=="exe" && release.sha256.eq_ignore_ascii_case(&release.installed_sha256)),"Technician updates require an MSI or a portable EXE"); let target=installed_target(directory,&latest.bootstrap.edition)?; require_existing_application(&target)?; let previous=folder.join("previous-technician.exe"); if !previous.exists(){std::fs::copy(&target,&previous)?;} - std::fs::copy(&package,&target)?; + if release.format=="msi" { + let status=std::process::Command::new("msiexec.exe").arg("/i").arg(&package).args(["/qn","/norestart"]).arg(format!("INSTALLFOLDER={}",directory.display())).status()?; + ensure!(matches!(status.code(),Some(0|3010)),"Technician MSI installation failed; retain signed recovery receipt"); + }else{std::fs::copy(&package,&target)?;} }else{ require_existing_application(&installed_target(directory,&latest.bootstrap.edition)?)?; let mut command=if release.format=="msi" {let mut c=std::process::Command::new("msiexec.exe");c.arg("/i").arg(&package).args(["/qn","/norestart"]);c}else{let mut c=std::process::Command::new(&package);c.args(["--silent-install","printer=0"]);c}; @@ -274,7 +277,7 @@ impl AgentState { if value.is_null(){return Ok(false);} let envelope:SignedEnvelope=serde_json::from_value(value)?; let release=validate_release(self,&envelope)?; - ensure!(release.edition!=Edition::Technician || (release.format=="exe" && release.installed_sha256.eq_ignore_ascii_case(&release.sha256)),"Technician updates require a portable EXE with matching installed identity"); + ensure!(release.edition!=Edition::Technician || (release.format=="msi" || (release.format=="exe" && release.installed_sha256.eq_ignore_ascii_case(&release.sha256))),"Technician updates require an MSI or a portable EXE with matching installed identity"); let folder=directory.join("updates").join(release.sequence.to_string());std::fs::create_dir_all(&folder)?; verify_compatibility(&folder,&release)?; let package=folder.join(format!("SwanRemoteSupport-install.{}",release.format)); From d4e01c2600f6c8bdb3a1a691922d51afd0484620 Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Fri, 2 Oct 2026 10:07:58 +0200 Subject: [PATCH 040/652] Build customer MSI with complete payload and stable service lifecycle --- .github/workflows/swan-windows-build.yml | 9 +- deployment/windows/Build-CustomerMsi.ps1 | 36 +++++++ deployment/windows/CUSTOMER-MSI.md | 63 +++++++++++ deployment/windows/generate-customer-msi.py | 109 ++++++++++++++++++++ deployment/windows/test_customer_msi.py | 70 +++++++++++++ docs/ACCEPTANCE.md | 8 ++ 6 files changed, 294 insertions(+), 1 deletion(-) create mode 100644 deployment/windows/Build-CustomerMsi.ps1 create mode 100644 deployment/windows/CUSTOMER-MSI.md create mode 100644 deployment/windows/generate-customer-msi.py create mode 100644 deployment/windows/test_customer_msi.py diff --git a/.github/workflows/swan-windows-build.yml b/.github/workflows/swan-windows-build.yml index ab5681fc75a..a88f913ceb5 100644 --- a/.github/workflows/swan-windows-build.yml +++ b/.github/workflows/swan-windows-build.yml @@ -157,6 +157,7 @@ jobs: 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 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 @@ -168,7 +169,7 @@ jobs: Copy-Item deployment/windows/Install-Company.ps1,deployment/windows/Open-Technician.ps1,deployment/windows/Verify-Package.ps1,deployment/windows/Get-WindowsCompatibility.ps1 artifacts/ Copy-Item LICENCE artifacts/ - - name: Build unsigned technician MSI with pinned WiX + - name: Build unsigned customer and technician MSIs with pinned WiX shell: pwsh run: | $toolPackage = Join-Path $env:RUNNER_TEMP 'wix-cli-x64-5.0.2.msi' @@ -179,6 +180,10 @@ jobs: ./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 $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 + $customerHash = (Get-FileHash -LiteralPath $customerOutput -Algorithm SHA256).Hash.ToLowerInvariant() + Add-Content artifacts/SHA256SUMS.txt ($customerHash+' '+[IO.Path]::GetFileName($customerOutput)) - name: Upload unsigned installer and checksum uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 @@ -189,7 +194,9 @@ jobs: 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/*.msi.build.json + artifacts/*.msi.installed-files.json artifacts/SHA256SUMS.txt artifacts/swan-*.exe artifacts/Install-Company.ps1 diff --git a/deployment/windows/Build-CustomerMsi.ps1 b/deployment/windows/Build-CustomerMsi.ps1 new file mode 100644 index 00000000000..2e38973ab87 --- /dev/null +++ b/deployment/windows/Build-CustomerMsi.ps1 @@ -0,0 +1,36 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory=$true)][string]$WixExe, + [Parameter(Mandatory=$true)][string]$SourceDirectory, + [Parameter(Mandatory=$true)][string]$AgentExe, + [Parameter(Mandatory=$true)][ValidatePattern('^\d+\.\d+\.\d+$')][string]$Version, + [Parameter(Mandatory=$true)][string]$Output, + [string]$PythonExe='python3', + [switch]$UnsignedTest +) +$ErrorActionPreference = 'Stop' +$WixExe = (Resolve-Path -LiteralPath $WixExe).Path +$SourceDirectory = (Resolve-Path -LiteralPath $SourceDirectory).Path +$AgentExe = (Resolve-Path -LiteralPath $AgentExe).Path +$Output = [IO.Path]::GetFullPath($Output) +if ($Output -notmatch '-unsigned\.msi$') { throw 'Build output must end in -unsigned.msi; sign and approve it separately.' } +if (Test-Path -LiteralPath $Output) { throw 'Refusing to overwrite an existing installer.' } +$toolSignature = Get-AuthenticodeSignature -LiteralPath $WixExe +if ($toolSignature.Status -ne 'Valid' -or $toolSignature.SignerCertificate.Subject -notmatch 'CN=WiX Toolset \(\.NET Foundation\)') { throw 'WiX tool publisher verification failed.' } +$toolVersion = (& $WixExe --version | Out-String).Trim() +if ($LASTEXITCODE -ne 0 -or $toolVersion -notmatch '^5\.0\.2(?:\+|$)') { throw 'WiX 5.0.2 is required.' } +$main = Join-Path $SourceDirectory 'rustdesk.exe' +if (-not $UnsignedTest) { + $mainSignature = Get-AuthenticodeSignature -LiteralPath $main + $agentSignature = Get-AuthenticodeSignature -LiteralPath $AgentExe + if ($mainSignature.Status -ne 'Valid' -or $agentSignature.Status -ne 'Valid' -or $mainSignature.SignerCertificate.Thumbprint -ne $agentSignature.SignerCertificate.Thumbprint) { throw 'Production executable inputs require valid signatures from the same release publisher.' } +} +New-Item -ItemType Directory -Force ([IO.Path]::GetDirectoryName($Output)) | Out-Null +$authoring = $Output + '.wxs' +$manifest = $Output + '.installed-files.json' +& $PythonExe (Join-Path $PSScriptRoot 'generate-customer-msi.py') --source $SourceDirectory --agent $AgentExe --version $Version --output $authoring --manifest-output $manifest +if ($LASTEXITCODE -ne 0) { throw 'Customer payload authoring failed.' } +& $WixExe build $authoring -arch x64 -o $Output +if ($LASTEXITCODE -ne 0 -or -not (Test-Path -LiteralPath $Output)) { throw 'Customer MSI build failed.' } +[pscustomobject]@{version=$Version;wix=$toolVersion;unsigned_test=[bool]$UnsignedTest;sha256=(Get-FileHash -LiteralPath $Output -Algorithm SHA256).Hash.ToLowerInvariant();installed_manifest_sha256=(Get-FileHash -LiteralPath $manifest -Algorithm SHA256).Hash.ToLowerInvariant();agent_sha256=(Get-FileHash -LiteralPath $AgentExe -Algorithm SHA256).Hash.ToLowerInvariant()} | ConvertTo-Json | Set-Content -LiteralPath ($Output+'.build.json') +Write-Host 'Built unsigned customer MSI. Signing, approved metadata and clean-machine verification remain required.' diff --git a/deployment/windows/CUSTOMER-MSI.md b/deployment/windows/CUSTOMER-MSI.md new file mode 100644 index 00000000000..cea23ae8764 --- /dev/null +++ b/deployment/windows/CUSTOMER-MSI.md @@ -0,0 +1,63 @@ +# Customer MSI packaging + +`generate-customer-msi.py` authors a per-machine x64 MSI from the complete +project Flutter payload. It uses stable project upgrade and component identifiers, +independent of company display names. The canonical application path is +`%ProgramFiles%/Swan Remote Support/Swan Remote Support.exe`; the agent installs +under `%ProgramData%/SwanRemoteSupport`. Company profiles, device identity and +consent are not MSI-owned files and remain separate from release files. + +The package includes all payload libraries/assets and a local AGPL license +notice. Its signed-release manifest covers those installed files. Windows +Installer owns service installation, start/stop/removal and transaction-based +major-upgrade removal. The service runs the project executable with `--service` +as LocalSystem. Windows Installer copies the local operating system's +RuntimeBroker into the application directory and removes that copy on uninstall; +it does not redistribute a project copy of Microsoft's binary. Runtime verification +compares its hash with the current Windows system binary. + +```powershell +./deployment/windows/Build-CustomerMsi.ps1 ` + -WixExe C:/BuildTools/WiX/bin/wix.exe -SourceDirectory C:/Release/swan-client ` + -AgentExe C:/Release/swan-agent.exe -Version 1.5.0 ` + -Output C:/Release/SwanRemoteSupport-Customer-unsigned.msi +``` + +The recipe uses pinned WiX 5.0.2 and verifies its tool publisher/version. +Production executable inputs need valid signatures from the same publisher. +The builder produces an unsigned MSI, generated authoring, installed-file +manifest and build-input hashes. Sign the MSI separately and publish signed +release metadata before production approval. `-UnsignedTest` permits clearly +marked test payloads; company workers/installers/updaters retain their signature +checks. Regenerate hashes and metadata after final signing. + +Companies consume the unchanged signed project MSI through their Windows +worker and distribute its public customer bundle. `Install-Company.ps1` confirms +the company HTTPS identity, verifies trust and the package, protects the state +directory, enrolls the device and enables configuration synchronization. +`-BootstrapPath` plus `-ConfirmedCompanyDomain` provides explicit managed +configuration input without embedding credentials. The bare MSI does not enroll +or silently select a company. Full deployment must use the company setup flow; +new devices stay unavailable until approved. + +## Evidence and remaining verification + +A real unsigned package was built from the hash-verified native `31b13aa` +artifact. Its complete 95-file payload was recovered without executing any +artifact binary; compressed-file checksums were verified. The final package +adds the project license and agent. Windows Installer tables and generated +manifests were inspected, and the CLI build recipe succeeded with pinned WiX. +Three authoring tests cover full asset/license manifests, service lifecycle, +stable component identities, unsafe names/versions and incomplete payloads. +The supplier tool was extracted through MSI-table reads, not installed on the +host. Packages, extraction records and hashes remain outside Git. + +No customer installer ran on this host. Signed clean-machine installation, +service startup, SYSTEM enrollment, consent, UAC/logon-screen behavior, MSI +repair/major upgrades, runtime-broker servicing, task cleanup, uninstall and +interrupted-install rollback remain unverified. Native build CI now includes +both MSI recipes; its exact results must be recorded separately. MSI service +rules and a successful build do not establish these acceptance conditions. + +References: [WiX service installation](https://docs.firegiant.com/wix/schema/wxs/serviceinstall/) +and [Windows-owned file copying](https://docs.firegiant.com/wix/schema/wxs/copyfile/). diff --git a/deployment/windows/generate-customer-msi.py b/deployment/windows/generate-customer-msi.py new file mode 100644 index 00000000000..b77b7060686 --- /dev/null +++ b/deployment/windows/generate-customer-msi.py @@ -0,0 +1,109 @@ +"""Author a generic customer MSI from the complete pinned Flutter payload. + +Company profiles are never executable build input. Sign the final MSI separately. +""" +import argparse +import hashlib +import importlib.util +import json +from pathlib import Path +import re +import uuid +import xml.etree.ElementTree as ET + +NAMESPACE = "http://wixtoolset.org/schemas/v4/wxs" +UPGRADE = uuid.UUID("32a585d7-9a78-4ad2-af72-d0266efc709d") +ET.register_namespace("", NAMESPACE) + + +def element(parent, tag, **attributes): + return ET.SubElement(parent, "{" + NAMESPACE + "}" + tag, attributes) + + +def identifier(kind, path): + return kind + hashlib.sha256(path.encode()).hexdigest()[:30] + + +def author(source, agent, version): + if not re.fullmatch(r"\d+\.\d+\.\d+", version): + raise ValueError("Numeric major.minor.patch version required") + source = source.resolve(strict=True) + agent = agent.resolve(strict=True) + spec = importlib.util.spec_from_file_location("payload", Path(__file__).with_name("generate-payload-manifest.py")) + payload = importlib.util.module_from_spec(spec) + spec.loader.exec_module(payload) + files = payload.manifest("customer", source) + root = ET.Element("{" + NAMESPACE + "}Wix") + package = element(root, "Package", Name="Swan Remote Support", Manufacturer="Swan Remote Support", Version=version, + UpgradeCode=str(UPGRADE).upper(), Scope="perMachine", InstallerVersion="500", Language="1033") + element(package, "MajorUpgrade", DowngradeErrorMessage="A newer Swan Remote Support version is already installed.", Schedule="afterInstallInitialize") + element(package, "MediaTemplate", EmbedCab="yes") + element(package, "Property", Id="ARPNOMODIFY", Value="1") + for prop, name, key in [("WINDOWSBUILD", "CurrentBuildNumber", "SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion"), + ("INSTALLATIONTYPE", "InstallationType", "SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion"), + ("OSARCH", "PROCESSOR_ARCHITECTURE", "SYSTEM\\CurrentControlSet\\Control\\Session Manager\\Environment")]: + property_element = element(package, "Property", Id=prop) + element(property_element, "RegistrySearch", Id=prop+"Search", Root="HKLM", Key=key, Name=name, Type="raw", Bitness="always64") + element(package, "Launch", Condition='Installed OR (VersionNT64 AND OSARCH = "AMD64" AND WINDOWSBUILD >= 10240 AND (INSTALLATIONTYPE = "Client" OR INSTALLATIONTYPE = "Server"))', + Message="Windows 10/11 x64 or Windows Server with Desktop Experience is required.") + programs = element(package, "StandardDirectory", Id="ProgramFiles64Folder") + install = element(programs, "Directory", Id="INSTALLFOLDER", Name="Swan Remote Support") + directories = {"": install} + feature = element(package, "Feature", Id="Customer", Title="Customer support application", Level="1") + for file in files: + path = file["path"] + parts = path.split("/") + if any(not re.fullmatch(r"[A-Za-z0-9 _\-.@+]+", part) or part in (".", "..") or part.endswith((".", " ")) for part in parts): + raise ValueError("Unsupported payload path") + parent = "" + for name in parts[:-1]: + current = parent+"/"+name if parent else name + if current not in directories: + directories[current] = element(directories[parent], "Directory", Id=identifier("D", current), Name=name) + parent = current + component_id = identifier("C", path) + component = element(directories[parent], "Component", Id=component_id, Guid=str(uuid.uuid5(UPGRADE, path)).upper(), Bitness="always64") + original = source / ("rustdesk.exe" if path == "Swan Remote Support.exe" else path) + file_element = element(component, "File", Id=identifier("F", path), Name=parts[-1], Source=str(original), KeyPath="yes", Checksum="yes") + if path == "Swan Remote Support.exe": + element(component, "ServiceInstall", Id="CustomerService", Name="Swan Remote Support", DisplayName="Swan Remote Support", Type="ownProcess", Start="auto", ErrorControl="normal", Arguments="--service", Account="LocalSystem", Vital="yes") + element(component, "ServiceControl", Id="CustomerServiceControl", Name="Swan Remote Support", Start="install", Stop="both", Remove="uninstall", Wait="yes") + element(component, "CopyFile", Id="WindowsRuntimeBroker", SourceDirectory="System64Folder", SourceName="RuntimeBroker.exe", DestinationDirectory="INSTALLFOLDER", DestinationName="RuntimeBroker_rustdesk.exe") + element(component, "RemoveFile", Id="RemoveWindowsRuntimeBroker", Name="RuntimeBroker_rustdesk.exe", On="uninstall") + element(file_element, "Shortcut", Id="CustomerShortcut", Directory="ProgramMenuFolder", Name="Swan Remote Support", Advertise="yes", WorkingDirectory="INSTALLFOLDER") + element(feature, "ComponentRef", Id=component_id) + license_path = Path(__file__).resolve().parents[2] / "LICENCE" + if not any(file["path"].lower() == "license.txt" for file in files): + component_id = identifier("C", "LICENSE.txt") + component = element(install, "Component", Id=component_id, Guid=str(uuid.uuid5(UPGRADE, "LICENSE.txt")).upper(), Bitness="always64") + element(component, "File", Id=identifier("F", "LICENSE.txt"), Name="LICENSE.txt", Source=str(license_path), KeyPath="yes") + element(feature, "ComponentRef", Id=component_id) + files.append({"path": "LICENSE.txt", "sha256": hashlib.sha256(license_path.read_bytes()).hexdigest()}) + data = element(package, "StandardDirectory", Id="CommonAppDataFolder") + state = element(data, "Directory", Id="STATEFOLDER", Name="SwanRemoteSupport") + component = element(state, "Component", Id="ConfigurationAgent", Guid=str(uuid.uuid5(UPGRADE, "configuration-agent")).upper(), Bitness="always64") + element(component, "File", Id="ConfigurationAgentExe", Name="swan-agent.exe", Source=str(agent), KeyPath="yes") + element(feature, "ComponentRef", Id="ConfigurationAgent") + return ET.ElementTree(root), files + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--source", type=Path, required=True) + parser.add_argument("--agent", type=Path, required=True) + parser.add_argument("--version", required=True) + parser.add_argument("--output", type=Path, required=True) + parser.add_argument("--manifest-output", type=Path) + args = parser.parse_args() + tree, files = author(args.source, args.agent, args.version) + ET.indent(tree, space=" ") + with args.output.open("xb") as stream: + tree.write(stream, encoding="utf-8", xml_declaration=True) + if args.manifest_output: + with args.manifest_output.open("x", encoding="utf-8") as stream: + json.dump(files, stream, indent=2) + stream.write("\n") + + +if __name__ == "__main__": + main() diff --git a/deployment/windows/test_customer_msi.py b/deployment/windows/test_customer_msi.py new file mode 100644 index 00000000000..34114667fa1 --- /dev/null +++ b/deployment/windows/test_customer_msi.py @@ -0,0 +1,70 @@ +import importlib.util +from pathlib import Path +import tempfile +import unittest + +spec = importlib.util.spec_from_file_location("customer_msi", Path(__file__).with_name("generate-customer-msi.py")) +msi = importlib.util.module_from_spec(spec) +spec.loader.exec_module(msi) + + +class CustomerMsiTests(unittest.TestCase): + def fixture(self, root): + source = root / "payload" + source.mkdir() + for name in ("rustdesk.exe", "librustdesk.dll", "flutter_windows.dll"): + (source / name).write_bytes(name.encode()) + assets = source / "data" / "flutter_assets" + assets.mkdir(parents=True) + (assets / "test.txt").write_bytes(b"asset") + agent = root / "agent.exe" + agent.write_bytes(b"agent") + return source, agent + + def test_full_manifest_license_and_service_lifecycle(self): + with tempfile.TemporaryDirectory() as folder: + source, agent = self.fixture(Path(folder)) + tree, files = msi.author(source, agent, "1.5.0") + paths = {file["path"] for file in files} + self.assertEqual(paths, {"Swan Remote Support.exe", "librustdesk.dll", "flutter_windows.dll", "data/flutter_assets/test.txt", "LICENSE.txt"}) + ns = {"w": msi.NAMESPACE} + service = tree.find(".//w:ServiceInstall", ns) + self.assertEqual(service.attrib["Name"], "Swan Remote Support") + self.assertEqual(service.attrib["Arguments"], "--service") + control = tree.find(".//w:ServiceControl", ns) + self.assertEqual(control.attrib["Stop"], "both") + self.assertEqual(control.attrib["Remove"], "uninstall") + copy = tree.find(".//w:CopyFile", ns) + self.assertEqual(copy.attrib["SourceDirectory"], "System64Folder") + self.assertEqual(copy.attrib["DestinationName"], "RuntimeBroker_rustdesk.exe") + installed_files = tree.findall(".//w:File", ns) + self.assertEqual(len(installed_files), len(files)+1) + + def test_component_identity_survives_version_and_payload_changes(self): + with tempfile.TemporaryDirectory() as folder: + source, agent = self.fixture(Path(folder)) + first, _ = msi.author(source, agent, "1.5.0") + (source / "rustdesk.exe").write_bytes(b"new release") + second, _ = msi.author(source, agent, "1.6.0") + tag = "{"+msi.NAMESPACE+"}Component" + first_ids = {item.attrib["Id"]: item.attrib["Guid"] for item in first.iter(tag)} + second_ids = {item.attrib["Id"]: item.attrib["Guid"] for item in second.iter(tag)} + self.assertEqual(first_ids, second_ids) + + def test_unsafe_names_versions_and_incomplete_payloads_are_rejected(self): + with tempfile.TemporaryDirectory() as folder: + source, agent = self.fixture(Path(folder)) + with self.assertRaises(ValueError): + msi.author(source, agent, "1.5.0;command") + bad = source / "bad'.dll" + bad.write_bytes(b"unsafe") + with self.assertRaises(ValueError): + msi.author(source, agent, "1.5.0") + bad.unlink() + (source / "librustdesk.dll").unlink() + with self.assertRaises(ValueError): + msi.author(source, agent, "1.5.0") + + +if __name__ == "__main__": + unittest.main() diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index b5f2ac5caab..afaf50d5b2a 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -145,3 +145,11 @@ inspected; no installer ran on the host. Technician setup/update support MSI containers and explicit external bootstrap input. Clean-guest install/upgrade, signing, customer MSI and complete uninstall cleanup remain acceptance gates; see deployment/windows/TECHNICIAN-MSI.md. + +Customer MSI authoring and the CLI recipe now build an actual unsigned package +from the verified native 31b13aa payload, without executing product installers. +The package includes the complete Flutter payload, local license and agent; +service and Windows-owned broker copy/removal tables were inspected. Three new +authoring tests and the existing three manifest tests pass. Clean-machine +installation, signing, service/task lifecycle and recovery remain unverified; +see deployment/windows/CUSTOMER-MSI.md. Native CI includes both MSI recipes. From 2cd7d21aca4229432ed0c9fff126dd2edc58640a Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Fri, 2 Oct 2026 10:11:01 +0200 Subject: [PATCH 041/652] Use pinned source for MSI repair and reject unsupported native architectures --- deployment/windows/Get-WindowsCompatibility.ps1 | 2 ++ deployment/windows/Install-Company.ps1 | 5 +++-- deployment/windows/msi/Technician.wxs | 3 ++- docs/ACCEPTANCE.md | 6 ++++++ docs/INSTALLER_REPAIR.md | 8 ++++++++ 5 files changed, 21 insertions(+), 3 deletions(-) diff --git a/deployment/windows/Get-WindowsCompatibility.ps1 b/deployment/windows/Get-WindowsCompatibility.ps1 index a9de16199b1..ee43a42dc40 100644 --- a/deployment/windows/Get-WindowsCompatibility.ps1 +++ b/deployment/windows/Get-WindowsCompatibility.ps1 @@ -1,5 +1,7 @@ $ErrorActionPreference = 'Stop' if (-not [Environment]::Is64BitOperatingSystem) { throw 'This release requires Windows x64.' } +$nativeArchitecture = (Get-ItemProperty -LiteralPath 'HKLM:/SYSTEM/CurrentControlSet/Control/Session Manager/Environment' -Name PROCESSOR_ARCHITECTURE).PROCESSOR_ARCHITECTURE +if ($nativeArchitecture -ne 'AMD64') { throw 'Native Windows x64 is required; ARM64 is not supported in this release.' } $version = Get-ItemProperty -LiteralPath 'HKLM:/SOFTWARE/Microsoft/Windows NT/CurrentVersion' $build = [int]$version.CurrentBuildNumber if ($version.InstallationType -eq 'Server Core') { throw 'Windows Server Core is not supported.' } diff --git a/deployment/windows/Install-Company.ps1 b/deployment/windows/Install-Company.ps1 index 9ec222294ff..a3a32b939f8 100644 --- a/deployment/windows/Install-Company.ps1 +++ b/deployment/windows/Install-Company.ps1 @@ -44,6 +44,7 @@ $recordArguments = @('record-installation',(Join-Path $root 'release.json')) if ($Repair) { $packageArguments += '--repair'; $recordArguments += '--repair' } & $agent @packageArguments if ($LASTEXITCODE -ne 0) { throw 'Installer metadata, hash or publisher validation failed.' } +$msiOperation = if ($Repair) { '/fvamus' } else { '/i' } $installationMarker = Join-Path $editionDirectory 'pending-install.json' $activityStream = [IO.File]::Open((Join-Path $editionDirectory 'activity.lock'),[IO.FileMode]::OpenOrCreate,[IO.FileAccess]::ReadWrite,[IO.FileShare]::ReadWrite) $activityLocked = $false @@ -73,7 +74,7 @@ if ($bootstrap.edition -eq 'customer') { } } if ($installers[0].Extension -eq '.msi') { - $process = Start-Process -FilePath 'msiexec.exe' -ArgumentList @('/i',('"' + $installers[0].FullName + '"'),'/passive','/norestart') -PassThru -Wait -WindowStyle Hidden + $process = Start-Process -FilePath 'msiexec.exe' -ArgumentList @($msiOperation,('"' + $installers[0].FullName + '"'),'/passive','/norestart') -PassThru -Wait -WindowStyle Hidden } else { $process = Start-Process -FilePath $installers[0].FullName -ArgumentList '--silent-install','printer=0' -PassThru -Wait -WindowStyle Hidden } @@ -92,7 +93,7 @@ if ($bootstrap.edition -eq 'customer') { $release = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($releaseEnvelope.payload)) | ConvertFrom-Json $portable = Join-Path $editionDirectory 'SwanRemoteSupport-Technician.exe' if ($release.format -eq 'msi') { - $process = Start-Process -FilePath 'msiexec.exe' -ArgumentList @('/i',('"' + $installers[0].FullName + '"'),'/passive','/norestart',('INSTALLFOLDER="' + $editionDirectory + '"')) -PassThru -Wait -WindowStyle Hidden + $process = Start-Process -FilePath 'msiexec.exe' -ArgumentList @($msiOperation,('"' + $installers[0].FullName + '"'),'/passive','/norestart',('INSTALLFOLDER="' + $editionDirectory + '"')) -PassThru -Wait -WindowStyle Hidden if ($process.ExitCode -notin @(0,3010)) { throw "Technician MSI installation failed ($($process.ExitCode))." } if (-not (Test-Path -LiteralPath $portable)) { throw 'Technician MSI did not install the expected executable.' } } elseif ($release.format -eq 'exe' -and $release.sha256 -ceq $release.installed_sha256) { diff --git a/deployment/windows/msi/Technician.wxs b/deployment/windows/msi/Technician.wxs index f088d4496e3..1759345bb7c 100644 --- a/deployment/windows/msi/Technician.wxs +++ b/deployment/windows/msi/Technician.wxs @@ -5,7 +5,8 @@ - + + diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index afaf50d5b2a..8da1ffd3747 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -153,3 +153,9 @@ service and Windows-owned broker copy/removal tables were inspected. Three new authoring tests and the existing three manifest tests pass. Clean-machine installation, signing, service/task lifecycle and recovery remain unverified; see deployment/windows/CUSTOMER-MSI.md. Native CI includes both MSI recipes. + +Explicit MSI repair now requests forced replacement and source re-caching +through `/fvamus`, while retaining the exact pinned-release trust gate. Native +x64 compatibility preflight and technician MSI launch conditions now reject +ARM64; the preflight identifies the current host as windows_11. Clean-machine +repair and ARM64 rejection still require runtime validation on those guests. diff --git a/docs/INSTALLER_REPAIR.md b/docs/INSTALLER_REPAIR.md index 7af31f6b89a..449124a5698 100644 --- a/docs/INSTALLER_REPAIR.md +++ b/docs/INSTALLER_REPAIR.md @@ -46,3 +46,11 @@ Rust session locks. Complete signed EXE/MSI repair, interruption recovery, scheduled-task behavior and clean-machine session coordination remain acceptance work. This document describes the development implementation, not tested production compatibility. + +For MSI bundles, explicit `-Repair` now invokes Windows Installer with +`/fvamus` against the already verified pinned MSI: run from source/re-cache, +force file replacement and restore registry entries/shortcuts. Normal setup +continues to use `/i`. This does not loosen metadata, publisher, hash, company +identity or session-lock checks. Actual clean-machine repair and interrupted +MSI recovery remain unverified. See Microsoft +[repair options](https://learn.microsoft.com/en-us/windows-server/administration/windows-commands/msiexec). From 92ff9ab5c54bdb643dc516030fdfcff9036cca0f Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Fri, 2 Oct 2026 11:55:00 +0200 Subject: [PATCH 042/652] Route customer uninstall through verified MSI product registration --- deployment/windows/generate-customer-msi.py | 4 ++ deployment/windows/test_customer_msi.py | 6 +++ docs/ACCEPTANCE.md | 8 +++ src/platform/windows.rs | 58 +++++++++++++++++++++ 4 files changed, 76 insertions(+) diff --git a/deployment/windows/generate-customer-msi.py b/deployment/windows/generate-customer-msi.py index b77b7060686..d4d7b3a4dc1 100644 --- a/deployment/windows/generate-customer-msi.py +++ b/deployment/windows/generate-customer-msi.py @@ -39,6 +39,7 @@ def author(source, agent, version): element(package, "MajorUpgrade", DowngradeErrorMessage="A newer Swan Remote Support version is already installed.", Schedule="afterInstallInitialize") element(package, "MediaTemplate", EmbedCab="yes") element(package, "Property", Id="ARPNOMODIFY", Value="1") + element(package, "SetProperty", Id="ARPINSTALLLOCATION", Value="[INSTALLFOLDER]", After="CostFinalize", Sequence="execute") for prop, name, key in [("WINDOWSBUILD", "CurrentBuildNumber", "SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion"), ("INSTALLATIONTYPE", "InstallationType", "SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion"), ("OSARCH", "PROCESSOR_ARCHITECTURE", "SYSTEM\\CurrentControlSet\\Control\\Session Manager\\Environment")]: @@ -79,6 +80,9 @@ def author(source, agent, version): element(component, "File", Id=identifier("F", "LICENSE.txt"), Name="LICENSE.txt", Source=str(license_path), KeyPath="yes") element(feature, "ComponentRef", Id=component_id) files.append({"path": "LICENSE.txt", "sha256": hashlib.sha256(license_path.read_bytes()).hexdigest()}) + registration = element(install, "Component", Id="CustomerMsiRegistration", Guid=str(uuid.uuid5(UPGRADE, "msi-registration")).upper(), Bitness="always64") + element(registration, "RegistryValue", Root="HKLM", Key="Software\\SwanRemoteSupport\\Customer", Name="ProductCode", Type="string", Value="[ProductCode]", KeyPath="yes") + element(feature, "ComponentRef", Id="CustomerMsiRegistration") data = element(package, "StandardDirectory", Id="CommonAppDataFolder") state = element(data, "Directory", Id="STATEFOLDER", Name="SwanRemoteSupport") component = element(state, "Component", Id="ConfigurationAgent", Guid=str(uuid.uuid5(UPGRADE, "configuration-agent")).upper(), Bitness="always64") diff --git a/deployment/windows/test_customer_msi.py b/deployment/windows/test_customer_msi.py index 34114667fa1..1ded23ecc76 100644 --- a/deployment/windows/test_customer_msi.py +++ b/deployment/windows/test_customer_msi.py @@ -37,6 +37,12 @@ def test_full_manifest_license_and_service_lifecycle(self): copy = tree.find(".//w:CopyFile", ns) self.assertEqual(copy.attrib["SourceDirectory"], "System64Folder") self.assertEqual(copy.attrib["DestinationName"], "RuntimeBroker_rustdesk.exe") + registration = tree.find(".//w:Component[@Id='CustomerMsiRegistration']/w:RegistryValue", ns) + self.assertEqual(registration.attrib["Root"], "HKLM") + self.assertEqual(registration.attrib["Value"], "[ProductCode]") + self.assertEqual(registration.attrib["Key"], "Software\\SwanRemoteSupport\\Customer") + location = tree.find(".//w:SetProperty[@Id='ARPINSTALLLOCATION']", ns) + self.assertEqual(location.attrib["Value"], "[INSTALLFOLDER]") installed_files = tree.findall(".//w:File", ns) self.assertEqual(len(installed_files), len(files)+1) diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index 8da1ffd3747..1a83ec1eb74 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -159,3 +159,11 @@ through `/fvamus`, while retaining the exact pinned-release trust gate. Native x64 compatibility preflight and technician MSI launch conditions now reject ARM64; the preflight identifies the current host as windows_11. Clean-machine repair and ARM64 rejection still require runtime validation on those guests. + +Customer MSI registration now records its current product code and installation +location. The customer app validates that registration against the installed +Windows Installer product before requesting removal, resolves the Windows-owned +msiexec executable directly, and refuses malformed registrations. Three MSI +authoring tests and Rust parsing pass. Native compilation and clean-guest +uninstall, service removal and complete scheduled-task/state cleanup remain +unverified or incomplete. diff --git a/src/platform/windows.rs b/src/platform/windows.rs index b6b5b39d9b8..bb6bf10333b 100644 --- a/src/platform/windows.rs +++ b/src/platform/windows.rs @@ -1286,7 +1286,34 @@ fn get_subkey(name: &str, wow: bool) -> String { } } +#[cfg(feature = "swan_custom")] +fn swan_msi_product_code() -> ResultType> { + if crate::get_app_name()!="Swan Remote Support" {return Ok(None);} + let machine=RegKey::predef(HKEY_LOCAL_MACHINE); + let registration=match machine.open_subkey(r"Software\SwanRemoteSupport\Customer") { + Ok(key)=>key, + Err(error) if error.kind()==std::io::ErrorKind::NotFound=>return Ok(None), + Err(error)=>return Err(error.into()), + }; + let code:String=registration.get_value("ProductCode")?; + let bytes=code.as_bytes(); + if bytes.len()!=38 || bytes[0]!=b'{' || bytes[37]!=b'}' || !bytes[1..37].iter().enumerate().all(|(index,byte)| { + if [8,13,18,23].contains(&index){*byte==b'-'}else{byte.is_ascii_hexdigit()} + }) {bail!("Invalid Swan MSI product identity");} + let uninstall=machine.open_subkey(format!(r"Software\Microsoft\Windows\CurrentVersion\Uninstall\{code}"))?; + let installer:u32=uninstall.get_value("WindowsInstaller")?; + let name:String=uninstall.get_value("DisplayName")?; + if installer!=1 || name!="Swan Remote Support" {bail!("Swan MSI identity does not match the installed product");} + Ok(Some(code)) +} + fn get_valid_subkey() -> String { + #[cfg(feature = "swan_custom")] + match swan_msi_product_code() { + Ok(Some(code))=>return get_subkey(&code,false), + Ok(None)=>{}, + Err(error)=>log::error!("Swan MSI registration could not be verified: {error}"), + } let subkey = get_subkey(IS1, false); if !get_reg_of(&subkey, "InstallLocation").is_empty() { return subkey; @@ -1798,6 +1825,15 @@ fn get_before_uninstall(kill_self: bool) -> String { /// is included in the generated uninstall script. If `uninstall_printer` is `false`, the printer /// related command is omitted from the script. fn get_uninstall(kill_self: bool, uninstall_printer: bool) -> String { + #[cfg(feature = "swan_custom")] + match swan_msi_product_code() { + Ok(Some(code))=>return format!("msiexec.exe /x {code} /passive /norestart"), + Ok(None)=>{}, + Err(error)=>{ + log::error!("Refusing uninstall with invalid Swan MSI registration: {error}"); + return "echo Swan MSI registration is invalid. Use Windows installed apps to remove the package.\r\nexit /b 1".into(); + } + } let reg_uninstall_string = get_reg("UninstallString"); if reg_uninstall_string.to_lowercase().contains("msiexec.exe") { return reg_uninstall_string; @@ -1833,6 +1869,28 @@ fn get_uninstall(kill_self: bool, uninstall_printer: bool) -> String { } pub fn uninstall_me(kill_self: bool) -> ResultType<()> { + #[cfg(feature = "swan_custom")] + if let Some(code) = swan_msi_product_code()? { + // Resolve the Windows-owned executable without PATH or environment lookup. + let mut directory = vec![0u16; 32768]; + let length = unsafe { + winapi::um::sysinfoapi::GetSystemDirectoryW( + directory.as_mut_ptr(), directory.len() as u32, + ) + } as usize; + if length == 0 || length >= directory.len() { + bail!("Unable to resolve the Windows Installer executable"); + } + let executable = PathBuf::from(OsString::from_wide(&directory[..length])) + .join("msiexec.exe"); + if !run_uac(&executable.to_string_lossy(), &format!("/x {code} /passive /norestart"))? { + bail!("Windows Installer was not started; uninstall was cancelled or failed"); + } + if kill_self { + std::process::exit(0); + } + return Ok(()); + } run_cmds(get_uninstall(kill_self, true), true, "uninstall") } From 00c229914e3e4e7bf0b2bc439ce5dad67947715c Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Fri, 2 Oct 2026 11:57:05 +0200 Subject: [PATCH 043/652] Keep WiX extraction output limited to the verified executable path --- deployment/windows/Expand-WixBuildTools.ps1 | 12 ++++++------ docs/ACCEPTANCE.md | 5 +++++ 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/deployment/windows/Expand-WixBuildTools.ps1 b/deployment/windows/Expand-WixBuildTools.ps1 index d0e5c74ab64..a10fc56b655 100644 --- a/deployment/windows/Expand-WixBuildTools.ps1 +++ b/deployment/windows/Expand-WixBuildTools.ps1 @@ -15,15 +15,15 @@ $database = $installer.OpenDatabase($Package,0) $directories = @{} $components = @{} $view = $database.OpenView('SELECT `Directory`,`Directory_Parent`,`DefaultDir` FROM `Directory`') -$view.Execute() +$null = $view.Execute() while ($row = $view.Fetch()) { $directories[$row.StringData(1)] = @($row.StringData(2),$row.StringData(3)) } -$view.Close() +$null = $view.Close() $view = $database.OpenView('SELECT `Component`,`Directory_` FROM `Component`') -$view.Execute() +$null = $view.Execute() while ($row = $view.Fetch()) { $components[$row.StringData(1)] = $row.StringData(2) } -$view.Close() +$null = $view.Close() $view = $database.OpenView('SELECT `File`,`Component_`,`FileName` FROM `File`') -$view.Execute() +$null = $view.Execute() while ($row = $view.Fetch()) { $parts = [Collections.Generic.List[string]]::new() $directory = $components[$row.StringData(2)] @@ -42,7 +42,7 @@ while ($row = $view.Fetch()) { New-Item -ItemType Directory -Force ([IO.Path]::GetDirectoryName($destination)) | Out-Null Copy-Item -LiteralPath (Join-Path $raw $row.StringData(1)) -Destination $destination } -$view.Close() +$null = $view.Close() $tool = Join-Path $OutputDirectory 'bin/wix.exe' $signature = Get-AuthenticodeSignature -LiteralPath $tool if ($signature.Status -ne 'Valid' -or $signature.SignerCertificate.Subject -notmatch 'CN=WiX Toolset \(\.NET Foundation\)') { throw 'Extracted WiX publisher verification failed.' } diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index 1a83ec1eb74..d3983b2014c 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -167,3 +167,8 @@ msiexec executable directly, and refuses malformed registrations. Three MSI authoring tests and Rust parsing pass. Native compilation and clean-guest uninstall, service removal and complete scheduled-task/state cleanup remain unverified or incomplete. + +Native CI run 36982373150 compiled the apps but failed MSI packaging because +the WiX extractor emitted COM return values alongside its executable path. +Those values are now suppressed. A real pinned-package extraction verifies +exactly one string result and WiX version 5.0.2; the CI rerun remains pending. From e31cf0d5a78645b11e79ec3a2fa7f06ba3a0f059 Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Fri, 2 Oct 2026 11:59:27 +0200 Subject: [PATCH 044/652] Reject weak public keys at shared configuration and session trust boundary --- docs/ACCEPTANCE.md | 6 ++++++ product/protocol/src/lib.rs | 13 ++++++++++++- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index d3983b2014c..c9b87e65225 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -172,3 +172,9 @@ Native CI run 36982373150 compiled the apps but failed MSI packaging because the WiX extractor emitted COM return values alongside its executable path. Those values are now suppressed. A real pinned-package extraction verifies exactly one string result and WiX version 5.0.2; the CI rerun remains pending. + +All 25 existing product workspace tests passed on Windows after the MSI +registration change. Shared key parsing additionally rejects weak Ed25519 +public keys; six protocol tests pass, including rejection of identity and +zero encodings and acceptance of a generated key. Native packaging-fix CI +run 36992817681 was confirmed queued; completion is not yet established. diff --git a/product/protocol/src/lib.rs b/product/protocol/src/lib.rs index 05e40ccaf67..645bdf81d5c 100644 --- a/product/protocol/src/lib.rs +++ b/product/protocol/src/lib.rs @@ -254,7 +254,9 @@ pub fn transport_socket(value:&str,default_port:u16)->Result<(String,u16)> { } pub fn public_key(value: &str) -> Result { let bytes: [u8; 32] = STANDARD.decode(value)?.try_into().map_err(|_| anyhow::anyhow!("Expected 32-byte key"))?; - Ok(VerifyingKey::from_bytes(&bytes)?) + let key = VerifyingKey::from_bytes(&bytes)?; + ensure!(!key.is_weak(), "Weak configuration or session proof key"); + Ok(key) } pub fn random_token() -> String { use rand::RngCore; @@ -279,6 +281,15 @@ pub fn maintenance_open(start: u8, end: u8, now: u64) -> bool { mod tests { use super::*; #[test] + fn public_keys_reject_small_order_points() { + let mut identity = [0u8; 32]; + identity[0] = 1; + assert!(public_key(&STANDARD.encode(identity)).is_err()); + assert!(public_key(&STANDARD.encode([0u8; 32])).is_err()); + let signing = SigningKey::from_bytes(&[42u8; 32]); + assert!(public_key(&STANDARD.encode(signing.verifying_key().to_bytes())).is_ok()); + } + #[test] fn signed_payload_rejects_tampering_and_wrong_key() { let key = SigningKey::from_bytes(&[7;32]); let mut envelope = SignedEnvelope::sign(&"company", &key).unwrap(); From c2a2101c3a2db08269180795b396b97f88aca0ea Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Fri, 2 Oct 2026 12:01:33 +0200 Subject: [PATCH 045/652] Select forced MSI repair when recovering an already installed update --- deployment/windows/Get-MsiInstallMode.ps1 | 17 +++++++++++++++++ docs/ACCEPTANCE.md | 9 +++++++++ product/agent/src/update.rs | 19 +++++++++++++++++-- 3 files changed, 43 insertions(+), 2 deletions(-) create mode 100644 deployment/windows/Get-MsiInstallMode.ps1 diff --git a/deployment/windows/Get-MsiInstallMode.ps1 b/deployment/windows/Get-MsiInstallMode.ps1 new file mode 100644 index 00000000000..d71e002be99 --- /dev/null +++ b/deployment/windows/Get-MsiInstallMode.ps1 @@ -0,0 +1,17 @@ +[CmdletBinding()] +param([Parameter(Mandatory=$true)][string]$Package) +$ErrorActionPreference = 'Stop' +# Read only: no installer actions or custom actions are executed. +$installer = New-Object -ComObject WindowsInstaller.Installer +$database = $installer.OpenDatabase((Resolve-Path -LiteralPath $Package).Path, 0) +$view = $database.OpenView('SELECT `Value` FROM `Property` WHERE `Property` = ''ProductCode''') +$null = $view.Execute() +$row = $view.Fetch() +if ($null -eq $row) { throw 'MSI has no product identity.' } +$code = $row.StringData(1) +$null = $view.Close() +if ($code -notmatch '^\{[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}\}$') { throw 'Invalid MSI product identity.' } +$state = $installer.ProductState($code) +if ($state -eq 5) { Write-Output '/fvamus' } +elseif ($state -in @(-1, 1)) { Write-Output '/i' } +else { throw "MSI product registration cannot be safely recovered (state $state)." } diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index c9b87e65225..ccdb2ba054c 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -178,3 +178,12 @@ registration change. Shared key parsing additionally rejects weak Ed25519 public keys; six protocol tests pass, including rejection of identity and zero encodings and acceptance of a generated key. Native packaging-fix CI run 36992817681 was confirmed queued; completion is not yet established. + +MSI update recovery now reads the verified staged package's product identity +and queries Windows Installer registration before choosing installation or +forced repair. An installed product uses `/fvamus`; an unknown or advertised +product uses `/i`. Other-user and corrupt registrations fail closed. The real +uninstalled lab MSI read-only probe returns `/i`, and all 26 product workspace +tests pass. Installed-product repair, interruption recovery and rollback still +require clean-guest tests. Product state meanings follow +[Microsoft's Installer API](https://learn.microsoft.com/en-us/windows/win32/msi/installer-productstate-property). diff --git a/product/agent/src/update.rs b/product/agent/src/update.rs index 55466c9bdda..3f13ab471a7 100644 --- a/product/agent/src/update.rs +++ b/product/agent/src/update.rs @@ -80,6 +80,21 @@ fn verify_publisher(path:&Path,release:&Release)->Result<()> { ensure!(status.success(),"Package publisher verification failed");Ok(()) } +#[cfg(windows)] +fn msi_install_command(package:&Path)->Result { + let script=package.parent().context("Missing update directory")?.join("Get-MsiInstallMode.ps1"); + std::fs::write(&script,include_str!("../../../deployment/windows/Get-MsiInstallMode.ps1"))?; + let output=std::process::Command::new("powershell.exe") + .args(["-NoLogo","-NoProfile","-NonInteractive","-ExecutionPolicy","Bypass","-File"]) + .arg(script).arg("-Package").arg(package).output()?; + ensure!(output.status.success(),"Unable to determine MSI installation or repair mode"); + let mode=std::str::from_utf8(&output.stdout)?.trim(); + ensure!(matches!(mode,"/i"|"/fvamus"),"Unexpected MSI installation mode"); + let mut command=std::process::Command::new("msiexec.exe"); + command.arg(mode).arg(package).args(["/qn","/norestart"]); + Ok(command) +} + #[cfg(windows)] pub fn verify_package(state:&AgentState,directory:&Path,envelope:&SignedEnvelope,path:&Path)->Result<()> { let release=installation_release(state,directory,envelope,false)?; @@ -231,12 +246,12 @@ impl AgentState { let previous=folder.join("previous-technician.exe"); if !previous.exists(){std::fs::copy(&target,&previous)?;} if release.format=="msi" { - let status=std::process::Command::new("msiexec.exe").arg("/i").arg(&package).args(["/qn","/norestart"]).arg(format!("INSTALLFOLDER={}",directory.display())).status()?; + let status=msi_install_command(&package)?.arg(format!("INSTALLFOLDER={}",directory.display())).status()?; ensure!(matches!(status.code(),Some(0|3010)),"Technician MSI installation failed; retain signed recovery receipt"); }else{std::fs::copy(&package,&target)?;} }else{ require_existing_application(&installed_target(directory,&latest.bootstrap.edition)?)?; - let mut command=if release.format=="msi" {let mut c=std::process::Command::new("msiexec.exe");c.arg("/i").arg(&package).args(["/qn","/norestart"]);c}else{let mut c=std::process::Command::new(&package);c.args(["--silent-install","printer=0"]);c}; + let mut command=if release.format=="msi" {msi_install_command(&package)?}else{let mut c=std::process::Command::new(&package);c.args(["--silent-install","printer=0"]);c}; let status=command.status()?; ensure!(matches!(status.code(),Some(0|3010)),"Installation failed; retain signed recovery receipt"); } From f6a272a2b44b16c9c1d43aa0938ae85466572afb Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Fri, 2 Oct 2026 12:03:23 +0200 Subject: [PATCH 046/652] Keep installer worker polling after individual job processing failures --- docs/ACCEPTANCE.md | 7 +++++++ product/worker/src/main.rs | 19 +++++++++++++------ 2 files changed, 20 insertions(+), 6 deletions(-) diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index ccdb2ba054c..f6eab97693b 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -187,3 +187,10 @@ uninstalled lab MSI read-only probe returns `/i`, and all 26 product workspace tests pass. Installed-product repair, interruption recovery and rollback still require clean-guest tests. Product state meanings follow [Microsoft's Installer API](https://learn.microsoft.com/en-us/windows/win32/msi/installer-productstate-property). + +The installer worker now isolates claim parsing, build, upload and completion +errors from its polling loop. Transient job failures are logged without exiting +the worker; existing server claim expiry retains recovery. Windows cargo check +passes. Immediate upload retry, idempotent completion and a real signed worker +job with network fault injection remain incomplete or unverified. Native runs +36992580109 and 36992817681 were confirmed in progress and queued respectively. diff --git a/product/worker/src/main.rs b/product/worker/src/main.rs index de70b788435..18ec7371748 100644 --- a/product/worker/src/main.rs +++ b/product/worker/src/main.rs @@ -18,6 +18,18 @@ async fn main()->Result<()> { let response=client.post(format!("{}/api/v1/worker/claim",server.trim_end_matches('/'))).bearer_auth(&token).send().await; match response { Ok(response) if response.status().is_success()=>{ + if let Err(error)=process_claim(response,&client,&server,&token,&output,&release_key,&company_key).await { + eprintln!("Worker job processing failed: {error:#}. Continuing polling; the server retains the job for recovery."); + } + } + Ok(response)=>eprintln!("Worker polling failed: {}",response.status()), + Err(_)=>eprintln!("Worker management endpoint unavailable; retrying"), + } + tokio::time::sleep(std::time::Duration::from_secs(15)).await; + } +} + +async fn process_claim(response:reqwest::Response,client:&reqwest::Client,server:&str,token:&str,output:&Path,release_key:&str,company_key:&str)->Result<()> { let value:Value=response.json().await?; if !value.is_null(){ let id=value["id"].as_str().context("Missing build identity")?; @@ -34,12 +46,7 @@ async fn main()->Result<()> { }; client.put(format!("{}/api/v1/worker/builds/{id}",server.trim_end_matches('/'))).bearer_auth(&token).json(&result).send().await?.error_for_status()?; } - } - Ok(response)=>eprintln!("Worker polling failed: {}",response.status()), - Err(_)=>eprintln!("Worker management endpoint unavailable; retrying"), - } - tokio::time::sleep(std::time::Duration::from_secs(15)).await; - } + Ok(()) } async fn build(_client:&reqwest::Client,job:&Value,output:&Path,id:&str,release_key:&str,company_key:&str)->Result<(String,String)> { From 8c36ce1a22f210f706649ceb209a4e18ad00c873 Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Fri, 2 Oct 2026 12:06:15 +0200 Subject: [PATCH 047/652] Make worker completion reports idempotent with bounded transient retries --- docs/ACCEPTANCE.md | 8 +++++ product/management/src/service.rs | 49 +++++++++++++++++++++++++++---- product/worker/src/main.rs | 19 +++++++++++- 3 files changed, 70 insertions(+), 6 deletions(-) diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index f6eab97693b..8b68c7e7e68 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -194,3 +194,11 @@ the worker; existing server claim expiry retains recovery. Windows cargo check passes. Immediate upload retry, idempotent completion and a real signed worker job with network fault injection remain incomplete or unverified. Native runs 36992580109 and 36992817681 were confirmed in progress and queued respectively. + +Worker completion reporting now permits exact repeated terminal results from +the original active worker and refuses conflicting results or revoked workers. +A real management-handler regression test passes for initial completion, lost +response retry, conflict and revocation, with saved-result integrity checked. +The worker retries completion reporting up to three times for transport errors, +server failures or rate limits; Windows compilation passes. Signed upload retry +and full network-fault recovery still require implementation and verification. diff --git a/product/management/src/service.rs b/product/management/src/service.rs index b1a1550b4a4..8b2350e6d3a 100644 --- a/product/management/src/service.rs +++ b/product/management/src/service.rs @@ -466,22 +466,61 @@ async fn claim_build(State(s):State,headers:HeaderMap)->ApiResult { } #[derive(Deserialize)] struct BuildResult { success:bool, artifact_url:String,sha256:String,log:String } + +#[cfg(test)] +mod worker_completion_tests { + use super::*; + #[tokio::test] + async fn lost_completion_response_retries_without_changing_terminal_result() { + let directory=std::env::temp_dir().join(format!("swan-worker-retry-{}",random_token())); + let store=Arc::new(Store::open(&directory).unwrap()); + let credential=random_token();let worker=digest(&credential);let id=Uuid::new_v4().to_string(); + { + let db=store.db.lock().unwrap(); + db.execute("INSERT INTO workers(id,token_hash) VALUES('test',?1)",[&worker]).unwrap(); + db.execute("INSERT INTO builds(id,body,state,worker) VALUES(?1,'{}','running',?2)",params![id,worker]).unwrap(); + } + let mut headers=HeaderMap::new();headers.insert(header::AUTHORIZATION,format!("Bearer {credential}").parse().unwrap()); + let result=||BuildResult{success:false,artifact_url:String::new(),sha256:String::new(),log:"Rejected invalid package".into()}; + assert!(finish_build(State(store.clone()),headers.clone(),Path(id.clone()),Json(result())).await.is_ok()); + assert!(finish_build(State(store.clone()),headers.clone(),Path(id.clone()),Json(result())).await.is_ok()); + let mut changed=result();changed.log="Different outcome".into(); + assert!(finish_build(State(store.clone()),headers.clone(),Path(id.clone()),Json(changed)).await.is_err()); + store.db.lock().unwrap().execute("UPDATE workers SET disabled=1",[]).unwrap(); + assert!(finish_build(State(store.clone()),headers,Path(id.clone()),Json(result())).await.is_err()); + let saved:String=store.db.lock().unwrap().query_row("SELECT result FROM builds WHERE id=?1",[id],|r|r.get(0)).unwrap(); + assert_eq!(serde_json::from_str::(&saved).unwrap()["log"],"Rejected invalid package"); + drop(store);std::fs::remove_dir_all(directory).unwrap(); + } +} async fn finish_build(State(s):State,headers:HeaderMap,Path(id):Path,Json(input):Json)->ApiResult { s.worker(&headers)?; if Uuid::parse_str(&id).is_err(){return Err(bad("Invalid build identity"));} let worker=digest(token(&headers)?); - let owned:bool=s.db.lock().unwrap().query_row("SELECT EXISTS(SELECT 1 FROM builds WHERE id=?1 AND worker=?2 AND state IN ('running','uploaded'))",params![id,worker],|r|r.get(0))?; - if !owned{return Err(denied());} if input.log.len()>8192 || (input.success && (https_url(&input.artifact_url).is_err() || input.sha256.len()!=64 || !input.sha256.bytes().all(|b|b.is_ascii_hexdigit()))) {return Err(bad("Invalid build result"));} + let result=json!({"artifact_url":input.artifact_url,"sha256":input.sha256,"log":input.log}); + let terminal=if input.success{"completed"}else{"failed"}; + let previous:Option<(String,String)>=s.db.lock().unwrap().query_row( + "SELECT state,result FROM builds WHERE id=?1 AND worker=?2",params![id,worker], + |r|Ok((r.get(0)?,r.get(1)?))).optional()?; + match previous { + Some((state,saved)) if state==terminal && serde_json::from_str::(&saved)?==result=>return Ok(Json(json!({"ok":true}))), + Some((state,_)) if state=="running" || state=="uploaded"=>{}, + _=>return Err(denied()), + } if input.success { let expected=format!("{}/api/v1/downloads/{id}",s.profile()?.management_url.trim_end_matches('/')); if input.artifact_url!=expected{return Err(bad("Artifact URL must use this company server"));} let bytes=tokio::fs::read(s.artifact_dir.join(format!("{id}.zip"))).await.map_err(|_|bad("Verified artifact upload required"))?; if !digest(bytes).eq_ignore_ascii_case(&input.sha256){return Err(bad("Uploaded artifact hash mismatch"));} } - let result=json!({"artifact_url":input.artifact_url,"sha256":input.sha256,"log":input.log}); - let count=s.db.lock().unwrap().execute("UPDATE builds SET state=?1,result=?2 WHERE id=?3 AND worker=?4 AND (state='uploaded' OR (state='running' AND ?5=0))",params![if input.success{"completed"}else{"failed"},result.to_string(),id,worker,input.success])?; - if count!=1 {return Err(denied());}Ok(Json(json!({"ok":true}))) + let db=s.db.lock().unwrap(); + let count=db.execute("UPDATE builds SET state=?1,result=?2 WHERE id=?3 AND worker=?4 AND (state='uploaded' OR (state='running' AND ?5=0))",params![terminal,result.to_string(),id,worker,input.success])?; + if count!=1 { + let saved:Option=db.query_row("SELECT result FROM builds WHERE id=?1 AND worker=?2 AND state=?3",params![id,worker,terminal],|r|r.get(0)).optional()?; + if saved.map(|value|serde_json::from_str::(&value)).transpose()?.as_ref()!=Some(&result){return Err(denied());} + } + Ok(Json(json!({"ok":true}))) } async fn upload_artifact(State(s):State,Path(id):Path,request:axum::extract::Request)->ApiResult { diff --git a/product/worker/src/main.rs b/product/worker/src/main.rs index 18ec7371748..8808d412be4 100644 --- a/product/worker/src/main.rs +++ b/product/worker/src/main.rs @@ -44,11 +44,28 @@ async fn process_claim(response:reqwest::Response,client:&reqwest::Client,server }, Err(error)=>{eprintln!("Build {id} failed: {error}");json!({"success":false,"artifact_url":"","sha256":"","log":"Build failed; see the protected worker log. No package was published."})} }; - client.put(format!("{}/api/v1/worker/builds/{id}",server.trim_end_matches('/'))).bearer_auth(&token).json(&result).send().await?.error_for_status()?; + report_completion(client,server,token,id,&result).await?; } Ok(()) } +async fn report_completion(client:&reqwest::Client,server:&str,token:&str,id:&str,result:&Value)->Result<()> { + for attempt in 0..3 { + let response=client.put(format!("{}/api/v1/worker/builds/{id}",server.trim_end_matches('/'))) + .bearer_auth(token).json(result).send().await; + match response { + Ok(response) if response.status().is_success()=>return Ok(()), + Ok(response) if !response.status().is_server_error() && response.status()!=reqwest::StatusCode::TOO_MANY_REQUESTS=>{ + response.error_for_status()?; + anyhow::bail!("Unexpected completion response"); + }, + _=>{}, + } + if attempt<2 {tokio::time::sleep(std::time::Duration::from_secs(5*(attempt+1))).await;} + } + anyhow::bail!("Completion report unavailable after bounded retries; server retains job recovery") +} + async fn build(_client:&reqwest::Client,job:&Value,output:&Path,id:&str,release_key:&str,company_key:&str)->Result<(String,String)> { ensure!(job["release_public_key"].as_str()==Some(release_key) && job["profile_public_key"].as_str()==Some(company_key),"Worker trust pins do not match the company job"); let release_envelope:SignedEnvelope=serde_json::from_value(job["release"].clone())?; From 283cb958565e184f6e19fae4ed73cfe1d1a20742 Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Fri, 2 Oct 2026 12:08:48 +0200 Subject: [PATCH 048/652] Retry identical worker artifact uploads without replacing verified bundles --- docs/ACCEPTANCE.md | 8 +++++++ product/management/src/service.rs | 40 +++++++++++++++++++++++++------ product/worker/src/main.rs | 11 +++++---- 3 files changed, 48 insertions(+), 11 deletions(-) diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index 8b68c7e7e68..fa295590e03 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -202,3 +202,11 @@ response retry, conflict and revocation, with saved-result integrity checked. The worker retries completion reporting up to three times for transport errors, server failures or rate limits; Windows compilation passes. Signed upload retry and full network-fault recovery still require implementation and verification. + +Artifact upload persistence now accepts an identical retry from the owning +worker while the job is uploaded, and refuses changed bytes, another worker +or uploads after completion. The storage regression test checks preserved +bytes and state; all 28 product workspace tests pass. The worker uses bounded +transient retries with a cloned buffered request body. This is storage-level +evidence, not a signed end-to-end worker fault test. QEMU reports running but +a fresh Server 2025 screenshot still shows boot; clean installation is unproven. diff --git a/product/management/src/service.rs b/product/management/src/service.rs index 8b2350e6d3a..b751eceab94 100644 --- a/product/management/src/service.rs +++ b/product/management/src/service.rs @@ -470,6 +470,20 @@ struct BuildResult { success:bool, artifact_url:String,sha256:String,log:String #[cfg(test)] mod worker_completion_tests { use super::*; + #[test] + fn uploaded_artifact_retry_preserves_bytes_and_owner() { + let directory=std::env::temp_dir().join(format!("swan-upload-retry-{}",random_token())); + let store=Store::open(&directory).unwrap();let id=Uuid::new_v4().to_string(); + store.db.lock().unwrap().execute("INSERT INTO builds(id,body,state,worker) VALUES(?1,'{}','running','owner')",[&id]).unwrap(); + assert!(persist_verified_artifact(&store,&id,"owner",b"validated bundle").is_ok()); + assert!(persist_verified_artifact(&store,&id,"owner",b"validated bundle").is_ok()); + assert!(persist_verified_artifact(&store,&id,"owner",b"different bundle").is_err()); + assert!(persist_verified_artifact(&store,&id,"other-worker",b"validated bundle").is_err()); + assert_eq!(std::fs::read(store.artifact_dir.join(format!("{id}.zip"))).unwrap(),b"validated bundle"); + store.db.lock().unwrap().execute("UPDATE builds SET state='completed' WHERE id=?1",[&id]).unwrap(); + assert!(persist_verified_artifact(&store,&id,"owner",b"validated bundle").is_err()); + drop(store);std::fs::remove_dir_all(directory).unwrap(); + } #[tokio::test] async fn lost_completion_response_retries_without_changing_terminal_result() { let directory=std::env::temp_dir().join(format!("swan-worker-retry-{}",random_token())); @@ -527,7 +541,7 @@ async fn upload_artifact(State(s):State,Path(id):Path,request:ax s.worker(request.headers())?; if id.len()!=36 || !id.bytes().all(|c|c.is_ascii_hexdigit() || c==b'-'){return Err(bad("Invalid build identity"));} let worker=digest(token(request.headers())?); - let job:String=s.db.lock().unwrap().query_row("SELECT body FROM builds WHERE id=?1 AND worker=?2 AND state='running'",params![id,worker],|r|r.get(0)).optional()?.ok_or_else(denied)?; + let job:String=s.db.lock().unwrap().query_row("SELECT body FROM builds WHERE id=?1 AND worker=?2 AND state IN ('running','uploaded')",params![id,worker],|r|r.get(0)).optional()?.ok_or_else(denied)?; let bytes=axum::body::to_bytes(request.into_body(),512*1024*1024).await.map_err(|_|bad("Artifact too large"))?; let job:Value=serde_json::from_str(&job)?; let release_envelope:SignedEnvelope=serde_json::from_value(job["release"].clone())?; @@ -560,21 +574,33 @@ async fn upload_artifact(State(s):State,Path(id):Path,request:ax if bootstrap.company_id!=p.company_id || bootstrap.edition!=release.edition || bootstrap.management_url!=p.management_url || bootstrap.profile_public_key!=STANDARD.encode(s.key.verifying_key().as_bytes()) || bootstrap.release_public_key!=trust {return Err(bad("Bootstrap trust mismatch"));} drop(read);drop(zip); let hash=digest(&bytes); - tokio::task::spawn_blocking(move || ->Result<(),ApiError>{ + tokio::task::spawn_blocking(move || persist_verified_artifact(&s,&id,&worker,&bytes)) + .await.map_err(|_|bad("Artifact persistence failed"))??; + Ok(Json(json!({"sha256":hash}))) +} + +// Called only after bundle validation, under a blocking task. +fn persist_verified_artifact(s:&Store,id:&str,worker:&str,bytes:&[u8])->Result<(),ApiError> { let mut db=s.db.lock().unwrap();let tx=db.transaction()?; - let owned:bool=tx.query_row("SELECT EXISTS(SELECT 1 FROM builds WHERE id=?1 AND worker=?2 AND state='running')",params![id,worker],|r|r.get(0))?; - if !owned{return Err(denied());} + let state:Option=tx.query_row("SELECT state FROM builds WHERE id=?1 AND worker=?2",params![id,worker],|r|r.get(0)).optional()?; + let target=s.artifact_dir.join(format!("{id}.zip")); + match state.as_deref() { + Some("uploaded")=>{ + let saved=std::fs::read(&target).map_err(|_|bad("Uploaded artifact missing"))?; + if digest(saved)!=digest(bytes){return Err(bad("Conflicting artifact retry"));} + return Ok(()); + }, + Some("running")=>{}, + _=>return Err(denied()), + } let temporary=s.artifact_dir.join(format!("{id}.partial")); use std::io::Write; let mut artifact=std::fs::File::create(&temporary).map_err(|_|bad("Cannot save artifact"))?; artifact.write_all(&bytes).map_err(|_|bad("Cannot write artifact"))?; artifact.sync_all().map_err(|_|bad("Cannot flush artifact"))?;drop(artifact); - let target=s.artifact_dir.join(format!("{id}.zip")); if target.exists(){std::fs::remove_file(&target).map_err(|_|bad("Cannot replace retry artifact"))?;} std::fs::rename(&temporary,target).map_err(|_|bad("Cannot publish artifact"))?; tx.execute("UPDATE builds SET state='uploaded' WHERE id=?1",[&id])?;tx.commit()?;Ok(()) - }).await.map_err(|_|bad("Artifact persistence failed"))??; - Ok(Json(json!({"sha256":hash}))) } async fn download_artifact(State(s):State,Path(id):Path,headers:HeaderMap)->Result { if id.len()!=36 || !id.bytes().all(|c|c.is_ascii_hexdigit() || c==b'-'){return Err(bad("Invalid build identity"));} diff --git a/product/worker/src/main.rs b/product/worker/src/main.rs index 8808d412be4..556b05faa0f 100644 --- a/product/worker/src/main.rs +++ b/product/worker/src/main.rs @@ -39,7 +39,7 @@ async fn process_claim(response:reqwest::Response,client:&reqwest::Client,server let result=match outcome { Ok((name,hash))=>{ let bytes=tokio::fs::read(output.join(name)).await?; - client.put(format!("{}/api/v1/worker/builds/{id}/artifact",server.trim_end_matches('/'))).bearer_auth(&token).header("Content-Type","application/zip").body(bytes).send().await?.error_for_status()?; + send_idempotent(client.put(format!("{}/api/v1/worker/builds/{id}/artifact",server.trim_end_matches('/'))).bearer_auth(&token).header("Content-Type","application/zip").body(bytes)).await?; json!({"success":true,"artifact_url":format!("{}/api/v1/downloads/{id}",server.trim_end_matches('/')),"sha256":hash,"log":"Verified signed inputs and uploaded company bundle."}) }, Err(error)=>{eprintln!("Build {id} failed: {error}");json!({"success":false,"artifact_url":"","sha256":"","log":"Build failed; see the protected worker log. No package was published."})} @@ -50,9 +50,12 @@ async fn process_claim(response:reqwest::Response,client:&reqwest::Client,server } async fn report_completion(client:&reqwest::Client,server:&str,token:&str,id:&str,result:&Value)->Result<()> { + send_idempotent(client.put(format!("{}/api/v1/worker/builds/{id}",server.trim_end_matches('/'))).bearer_auth(token).json(result)).await +} + +async fn send_idempotent(request:reqwest::RequestBuilder)->Result<()> { for attempt in 0..3 { - let response=client.put(format!("{}/api/v1/worker/builds/{id}",server.trim_end_matches('/'))) - .bearer_auth(token).json(result).send().await; + let response=request.try_clone().context("Worker retry requires a buffered request")?.send().await; match response { Ok(response) if response.status().is_success()=>return Ok(()), Ok(response) if !response.status().is_server_error() && response.status()!=reqwest::StatusCode::TOO_MANY_REQUESTS=>{ @@ -63,7 +66,7 @@ async fn report_completion(client:&reqwest::Client,server:&str,token:&str,id:&st } if attempt<2 {tokio::time::sleep(std::time::Duration::from_secs(5*(attempt+1))).await;} } - anyhow::bail!("Completion report unavailable after bounded retries; server retains job recovery") + anyhow::bail!("Worker upload or report unavailable after bounded retries; server retains job recovery") } async fn build(_client:&reqwest::Client,job:&Value,output:&Path,id:&str,release_key:&str,company_key:&str)->Result<(String,String)> { From a6d4cb63ced02d32f40e66f55d6aa184b9ec90bf Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Fri, 2 Oct 2026 12:10:00 +0200 Subject: [PATCH 049/652] Refresh product documentation with installer and recovery evidence and gates --- docs/ACCEPTANCE.md | 7 ++++--- product/README.md | 6 ++++-- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index fa295590e03..1a76a0ec8e5 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -1,6 +1,6 @@ # Configurable product acceptance report -Development status, 1 October 2026. **The product is not complete or approved for +Development status, 2 October 2026. **The product is not complete or approved for production publication.** A passing component test is not evidence of a working native support session or a clean-machine installation. @@ -8,7 +8,7 @@ native support session or a clean-machine installation. - Public baseline tag `swan-single-company-baseline-1.4.9` preserves the original Swan deployment and build instructions. Its source was not replaced. -- Twenty-three product tests pass on the local Windows development host, including +- Twenty-eight product tests pass on the local Windows development host, including enrollment/trust preservation across stale writes, trusted key rotation, refusal to automatically reinstall a removed endpoint, and explicit consent changes surviving older refreshes. Consent API tests also reject delayed enable requests and older revocation retries; same-revision revocation takes precedence. The preceding eleven-test component source passed tests and release compilation on [Windows 2022 and Ubuntu 24.04 CI](https://github.com/rijff24/SwanRemoteSupport/actions/runs/36856716860) @@ -32,7 +32,8 @@ native support session or a clean-machine installation. The [build at `31b13aa`](https://github.com/rijff24/SwanRemoteSupport/actions/runs/36967791061) also passed and produces a distinct technician portable artifact. It predates subsequent native file/audio capability guards, consent ordering, complete - backup and installation/repair changes. A new build at `7e589ba` is queued. + backup and installation/repair changes. These builds are historical evidence; + their results do not establish acceptance of later changes. - The unsigned Windows artifact from `31b13aa` downloaded successfully. Its digest matches GitHub's artifact digest and both endpoint package checksums match `SHA256SUMS.txt`. Both endpoint packages and all three product-component diff --git a/product/README.md b/product/README.md index 8b942de55f4..fcf16abd591 100644 --- a/product/README.md +++ b/product/README.md @@ -27,12 +27,14 @@ Installers contain public bootstrap information only. The worker pins both profi The native Flutter technician home now includes company branding, MFA login, authorized inventory, attended/unattended connection requests and session history. Its Rust broker retains tokens and proof keys in memory and consumes the selected peer's ticket during the transport challenge. The graphical flow requires native build and end-to-end validation before it is considered delivered. The launcher opens this interface; automatic technician update handoff while closing the running executable remains incomplete. -The updater writes `pending-update.json` before installation and advances the sequence only after verifying the installed executable. `swan-agent recover-update` completes recovery only if signed installed identity matches; partial failures remain blocked for repair. Automatic repair/retry, rollback and updating the configuration agent itself remain incomplete. +The updater writes `pending-update.json` before installation and advances the sequence only after verifying the complete signed installed payload and agent. A staged, publisher-verified replacement agent performs installation and replaces the configuration agent while retaining session exclusion. Pending receipts support delayed retries; an already registered MSI uses forced repair. `swan-agent recover-update` completes recovery only when the signed installed identity matches. These paths compile and have component trust tests; actual signed self-replacement, interrupted installation and scheduled-task restart remain unverified. Full rollback and automatic technician GUI shutdown/handoff remain incomplete. ## Deployment and release gates See `deployment/windows/LOCAL-TESTING.md` for this machine's isolated test process. Windows service installation is in `deployment/windows/Install-Server.ps1`; Linux container definitions are in `deployment/linux/`. Management must sit behind trusted HTTPS before endpoint use. Companies supply DNS and publicly reachable transport or their own relay. Local tests do not prove internet reachability, NAT traversal or relay fallback. -Still required: verify native graphical technician login/inventory/history and live logo/color branding, equivalent first-run deployment wizards and reachability diagnostics, executable/MSI packaging integration and repair, company signing integration, full transport-key backup, migrations with rollback, full update recovery and agent replacement, pinned native dependency artifacts, native builds and end-to-end sessions, and clean-machine installation/upgrade tests across every advertised Windows version. Windows 10/11 x64 and Windows Server 2016/2019/2022/2025 Desktop Experience remain intended targets, not verified compatibility claims. See `deployment/windows/VM-TESTING.md` for the evaluation guest acceptance lab. +The pinned WiX recipes build real unsigned customer and technician MSIs locally. The customer package includes the full Flutter payload, service, Windows-owned runtime broker copy, license and configuration agent. Company bootstrap remains an explicit setup input. Build and table inspection do not prove installation or service behavior. + +Still required: verify native graphical technician login/inventory/history and installed branding; complete equivalent deployment wizards, external reachability, NAT and relay tests; validate package installation, repair, upgrades and complete uninstall cleanup; deliver company signing integration; verify live transport and complete certificate-store restore, post-backup reconciliation and migration rollback; complete signed update recovery, rollback and technician handoff; and run clean-machine tests across every advertised Windows version. Windows 10/11 x64 and Windows Server 2016/2019/2022/2025 Desktop Experience remain intended targets, not verified compatibility claims. See `docs/ACCEPTANCE.md` for exact evidence and `deployment/windows/VM-TESTING.md` for the evaluation guest acceptance lab. Never publish this development build as production. Signing-provider approval, valid production signatures, exact corresponding AGPL source and all acceptance checks are mandatory release gates. Retain RustDesk attribution and license notices when distributing changed binaries or hosting modified server code. From ffa4e1070553646f9ce588e83d0dc331cb4938ff Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Fri, 2 Oct 2026 12:13:23 +0200 Subject: [PATCH 050/652] Expose update channel and maintenance windows in company administration --- docs/ACCEPTANCE.md | 6 ++++++ product/management/web/app.js | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index 1a76a0ec8e5..787b6a24fc8 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -211,3 +211,9 @@ bytes and state; all 28 product workspace tests pass. The worker uses bounded transient retries with a cloned buffered request body. This is storage-level evidence, not a signed end-to-end worker fault test. QEMU reports running but a fresh Server 2025 screenshot still shows boot; clean installation is unproven. + +The administrator branding/policy form now exposes stable/test channel and UTC +maintenance start/end controls, saving through the existing authenticated signed +profile API. Equal-hour and overnight descriptions match protocol semantics. +JavaScript syntax and diff checks pass. Rendered form interaction and installed +client rollout behavior remain unverified. diff --git a/product/management/web/app.js b/product/management/web/app.js index 0fef32d6d03..6be6f79fb68 100644 --- a/product/management/web/app.js +++ b/product/management/web/app.js @@ -59,7 +59,7 @@ const screens={ 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=`

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

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)}
${field('rollout','Rollout percentage','number',p.rollout_percent)}Maintenance window: ${p.maintenance_start_utc}:00โ€“${p.maintenance_end_utc}:00 UTC. Full transport and update settings are available through the authenticated profile API.
`;bindForm('branding',async 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('logo');p[edition].consent_text=v.get('consent');}p.allow_unattended=v.has('unattended');p.updates_paused=v.has('paused');p.rollout_percent=Number(v.get('rollout'));await api('profile','PUT',p);notice.textContent='Company profile published.';}); + 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=`

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

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)}
${field('rollout','Rollout percentage','number',p.rollout_percent)}
${field('maintenance_start','Maintenance start hour (UTC, 0โ€“23)','number',p.maintenance_start_utc)}${field('maintenance_end','Maintenance end hour (UTC, 0โ€“23)','number',p.maintenance_end_utc)}
Windows can cross midnight. Equal start and end hours allow updates throughout the day. Releases still require administrator approval.
`;bindForm('branding',async 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('logo');p[edition].consent_text=v.get('consent');}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'));await api('profile','PUT',p);notice.textContent='Company profile published.';}); }, async users(){title('Technicians and access','Every technician needs MFA and explicit device-group access.');const users=await api('users');content.innerHTML=`
${users.map(u=>``).join('')}
UsernameRoleIdentifier
${escapeText(u.username)}${u.disabled?' (disabled)':''}${escapeText(u.role)}${escapeText(u.id)}

Add technician

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

Grant group access

${field('user_id','Technician identifier')}${field('group','Device group','text','default')}
`;bindForm('user',async v=>{const result=await api('users','POST',{username:v.get('username'),password:v.get('password'),role:'technician'});document.querySelector('#secret').textContent='Transfer privately: '+JSON.stringify(result);});bindForm('access',async v=>{await api('groups/'+encodeURIComponent(v.get('group'))+'/users/'+encodeURIComponent(v.get('user_id')),'PUT');notice.textContent='Group access granted.';}); }, From d399fe7f4011937f4f7e5ad49c0e87be90147074 Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Fri, 2 Oct 2026 12:16:39 +0200 Subject: [PATCH 051/652] Expose technician revocation and permanently close affected session grants --- docs/ACCEPTANCE.md | 9 +++++++++ product/management/src/service.rs | 20 ++++++++++++++++++-- product/management/src/tests.rs | 17 +++++++++++++++++ product/management/web/app.js | 2 +- 4 files changed, 45 insertions(+), 3 deletions(-) diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index 787b6a24fc8..e7daef22cd8 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -217,3 +217,12 @@ maintenance start/end controls, saving through the existing authenticated signed profile API. Equal-hour and overnight descriptions match protocol semantics. JavaScript syntax and diff checks pass. Rendered form interaction and installed client rollout behavior remain unverified. + +Administrator account controls now expose technician disable and group-access +revocation. Group removal closes affected grants transactionally; re-granting +access cannot revive them. Account disable removes login sessions and closes +all account grants in the same audited transaction. Expanded real API lifecycle +checks pass for administrator-only removal, inventory/grant/renewal denial, +re-grant non-revival and disabled-token rejection. JavaScript syntax passes. +Native active-session termination on lease expiry and rendered controls remain +unverified; no live company account was changed by these tests. diff --git a/product/management/src/service.rs b/product/management/src/service.rs index b751eceab94..a8d03910dfd 100644 --- a/product/management/src/service.rs +++ b/product/management/src/service.rs @@ -135,7 +135,7 @@ pub fn router(store:Shared)->Router { .route("/api/v1/device/consent",put(consent)) .route("/api/v1/device/status",get(device_status)) .route("/api/v1/groups/{group}/permissions",get(group_permissions).put(set_group_permissions)) - .route("/api/v1/groups/{group}/users/{user}",put(group_access)) + .route("/api/v1/groups/{group}/users/{user}",put(group_access).delete(revoke_group_access)) .route("/api/v1/grants",post(grant)) .route("/api/v1/grants/claim",post(claim)) .route("/api/v1/grants/{id}/renew",post(renew)) @@ -253,7 +253,12 @@ async fn users(State(s):State,headers:HeaderMap)->ApiResult { } async fn disable_user(State(s):State,headers:HeaderMap,Path(id):Path)->ApiResult { let actor=s.user(&headers,true)?;if actor==id {return Err(bad("Cannot disable your own administrator account"));} - s.db.lock().unwrap().execute("UPDATE users SET disabled=1 WHERE id=?1",[&id])?;s.audit(&actor,"user.disabled",&id)?;Ok(Json(json!({"ok":true}))) + let mut db=s.db.lock().unwrap();let tx=db.transaction()?; + tx.execute("UPDATE users SET disabled=1 WHERE id=?1",[&id])?; + tx.execute("DELETE FROM sessions WHERE user_id=?1",[&id])?; + tx.execute("UPDATE grants SET closed=1,lease_until=0 WHERE user_id=?1",[&id])?; + tx.execute("INSERT INTO audit(at,actor,event,target) VALUES(?1,?2,'user.disabled',?3)",params![now(),actor,id])?; + tx.commit()?;Ok(Json(json!({"ok":true}))) } #[derive(Deserialize)] struct Enrollment { name:String,rustdesk_id:String,unattended_consent:bool } @@ -507,6 +512,17 @@ mod worker_completion_tests { drop(store);std::fs::remove_dir_all(directory).unwrap(); } } +async fn revoke_group_access(State(s):State,headers:HeaderMap,Path((group,user)):Path<(String,String)>)->ApiResult { + let actor=s.user(&headers,true)?; + if group.len()>64 || group.is_empty(){return Err(bad("Invalid group"));} + let mut db=s.db.lock().unwrap();let tx=db.transaction()?; + tx.execute("DELETE FROM group_access WHERE group_id=?1 AND user_id=?2",params![group,user])?; + // Re-granting access must not revive authorization issued before revocation. + tx.execute("UPDATE grants SET closed=1,lease_until=0 WHERE user_id=?1 AND device_id IN (SELECT id FROM devices WHERE group_id=?2)",params![user,group])?; + tx.execute("INSERT INTO audit(at,actor,event,target) VALUES(?1,?2,'group.access_revoked',?3)",params![now(),actor,format!("{group}/{user}")])?; + tx.commit()?; + Ok(Json(json!({"ok":true}))) +} async fn finish_build(State(s):State,headers:HeaderMap,Path(id):Path,Json(input):Json)->ApiResult { s.worker(&headers)?; if Uuid::parse_str(&id).is_err(){return Err(bad("Invalid build identity"));} diff --git a/product/management/src/tests.rs b/product/management/src/tests.rs index 5acac8fa69e..80d353f413a 100644 --- a/product/management/src/tests.rs +++ b/product/management/src/tests.rs @@ -85,6 +85,23 @@ async fn enrollment_mfa_and_grants_fail_closed() { let (_,devices)=request(&app,"/api/v1/devices","GET",Some(technician),Value::Null).await;assert_eq!(devices.as_array().unwrap().len(),0); assert_eq!(request(&app,&format!("/api/v1/groups/customers/users/{}",new_user["id"].as_str().unwrap()),"PUT",Some(admin),Value::Null).await.0,StatusCode::OK); let (_,devices)=request(&app,"/api/v1/devices","GET",Some(technician),Value::Null).await;assert_eq!(devices.as_array().unwrap().len(),1); + let access_path=format!("/api/v1/groups/customers/users/{}",new_user["id"].as_str().unwrap()); + let tech_request=json!({"device_id":device_id,"proof_public_key":STANDARD.encode(proof.verifying_key().as_bytes()),"unattended":false}); + let (status,tech_envelope)=request(&app,"/api/v1/grants","POST",Some(technician),tech_request.clone()).await;assert_eq!(status,StatusCode::OK); + let signed:SignedEnvelope=serde_json::from_value(tech_envelope.clone()).unwrap(); + let tech_grant:SessionGrant=serde_json::from_slice(&STANDARD.decode(&signed.payload).unwrap()).unwrap(); + assert_eq!(request(&app,"/api/v1/grants/claim","POST",Some(credential),json!({"grant":tech_envelope})).await.0,StatusCode::OK); + let renew_path=format!("/api/v1/grants/{}/renew",tech_grant.grant_id); + assert_eq!(request(&app,&access_path,"DELETE",Some(technician),Value::Null).await.0,StatusCode::UNAUTHORIZED); + assert_eq!(request(&app,&access_path,"DELETE",Some(admin),Value::Null).await.0,StatusCode::OK); + assert_eq!(request(&app,&renew_path,"POST",Some(credential),Value::Null).await.0,StatusCode::FORBIDDEN); + assert_eq!(request(&app,"/api/v1/grants","POST",Some(technician),tech_request.clone()).await.0,StatusCode::FORBIDDEN); + let (_,devices)=request(&app,"/api/v1/devices","GET",Some(technician),Value::Null).await;assert_eq!(devices.as_array().unwrap().len(),0); + assert_eq!(request(&app,&access_path,"PUT",Some(admin),Value::Null).await.0,StatusCode::OK); + assert_eq!(request(&app,&renew_path,"POST",Some(credential),Value::Null).await.0,StatusCode::FORBIDDEN,"Restoring group access cannot revive the old grant"); + assert_eq!(request(&app,"/api/v1/grants","POST",Some(technician),tech_request).await.0,StatusCode::OK); + assert_eq!(request(&app,&format!("/api/v1/users/{}/disable",new_user["id"].as_str().unwrap()),"POST",Some(admin),Value::Null).await.0,StatusCode::OK); + assert_eq!(request(&app,"/api/v1/devices","GET",Some(technician),Value::Null).await.0,StatusCode::UNAUTHORIZED,"Disabling an account invalidates its existing session"); assert_eq!(request(&app,&format!("/api/v1/devices/{device_id}/state"),"PUT",Some(admin),json!({"state":"revoked","group":"customers"})).await.0,StatusCode::OK); assert_eq!(request(&app,"/api/v1/device/status","GET",Some(credential),Value::Null).await.0,StatusCode::UNAUTHORIZED,"Repair cannot restore a revoked device"); assert_eq!(request(&app,"/api/v1/grants","POST",Some(admin),json!({"device_id":device_id,"proof_public_key":STANDARD.encode(proof.verifying_key().as_bytes()),"unattended":false})).await.0,StatusCode::FORBIDDEN); diff --git a/product/management/web/app.js b/product/management/web/app.js index 6be6f79fb68..0de97f4f3ea 100644 --- a/product/management/web/app.js +++ b/product/management/web/app.js @@ -61,7 +61,7 @@ const screens={ }, 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=`

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

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)}
${field('rollout','Rollout percentage','number',p.rollout_percent)}
${field('maintenance_start','Maintenance start hour (UTC, 0โ€“23)','number',p.maintenance_start_utc)}${field('maintenance_end','Maintenance end hour (UTC, 0โ€“23)','number',p.maintenance_end_utc)}
Windows can cross midnight. Equal start and end hours allow updates throughout the day. Releases still require administrator approval.
`;bindForm('branding',async 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('logo');p[edition].consent_text=v.get('consent');}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'));await api('profile','PUT',p);notice.textContent='Company profile published.';}); }, - async users(){title('Technicians and access','Every technician needs MFA and explicit device-group access.');const users=await api('users');content.innerHTML=`
${users.map(u=>``).join('')}
UsernameRoleIdentifier
${escapeText(u.username)}${u.disabled?' (disabled)':''}${escapeText(u.role)}${escapeText(u.id)}

Add technician

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

Grant group access

${field('user_id','Technician identifier')}${field('group','Device group','text','default')}
`;bindForm('user',async v=>{const result=await api('users','POST',{username:v.get('username'),password:v.get('password'),role:'technician'});document.querySelector('#secret').textContent='Transfer privately: '+JSON.stringify(result);});bindForm('access',async v=>{await api('groups/'+encodeURIComponent(v.get('group'))+'/users/'+encodeURIComponent(v.get('user_id')),'PUT');notice.textContent='Group access granted.';}); + async users(){title('Technicians and access','Every technician needs MFA and explicit device-group access.');const users=await api('users');content.innerHTML=`
${users.map(u=>``).join('')}
UsernameRoleIdentifierAccount access
${escapeText(u.username)}${u.disabled?' (disabled)':''}${escapeText(u.role)}${escapeText(u.id)}${u.disabled?'Disabled':``}

Add technician

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

Manage group access

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

Import signed release

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

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

`).join('')||'No releases imported.'}
`;bindForm('release',async v=>{await api('releases','POST',{public_key:v.get('public_key'),envelope:JSON.parse(v.get('envelope'))});await view('releases');});for(const button of document.querySelectorAll('[data-release]'))button.onclick=async()=>{try{await api('releases/'+encodeURIComponent(button.dataset.release)+'/approve','POST');await view('releases');}catch(error){showError(error);}}; }, From 186a15884de00078a062d60c6fa5bfc50df73f40 Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Fri, 2 Oct 2026 12:18:16 +0200 Subject: [PATCH 052/652] Preserve independent customer and technician branding in administration --- docs/ACCEPTANCE.md | 7 +++++++ product/management/web/app.js | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index e7daef22cd8..81a01c58203 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -226,3 +226,10 @@ checks pass for administrator-only removal, inventory/grant/renewal denial, re-grant non-revival and disabled-token rejection. JavaScript syntax passes. Native active-session termination on lease expiry and rendered controls remain unverified; no live company account was changed by these tests. + +Customer and technician branding forms now edit their logo and consent text +independently. Saving no longer overwrites technician values with customer +values. Both use the existing signed, validated edition profile fields; +JavaScript syntax passes. Rendered interaction and installed edition branding +synchronization remain unverified. Native runs 36992580109 and 36992817681 are +still confirmed in progress; no completion result is inferred. diff --git a/product/management/web/app.js b/product/management/web/app.js index 0de97f4f3ea..ffadb6b443f 100644 --- a/product/management/web/app.js +++ b/product/management/web/app.js @@ -59,7 +59,7 @@ const screens={ 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=`

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

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)}
${field('rollout','Rollout percentage','number',p.rollout_percent)}
${field('maintenance_start','Maintenance start hour (UTC, 0โ€“23)','number',p.maintenance_start_utc)}${field('maintenance_end','Maintenance end hour (UTC, 0โ€“23)','number',p.maintenance_end_utc)}
Windows can cross midnight. Equal start and end hours allow updates throughout the day. Releases still require administrator approval.
`;bindForm('branding',async 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('logo');p[edition].consent_text=v.get('consent');}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'));await api('profile','PUT',p);notice.textContent='Company profile published.';}); + 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=`

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

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)}
${field('rollout','Rollout percentage','number',p.rollout_percent)}
${field('maintenance_start','Maintenance start hour (UTC, 0โ€“23)','number',p.maintenance_start_utc)}${field('maintenance_end','Maintenance end hour (UTC, 0โ€“23)','number',p.maintenance_end_utc)}
Windows can cross midnight. Equal start and end hours allow updates throughout the day. Releases still require administrator approval.
`;bindForm('branding',async 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.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'));await api('profile','PUT',p);notice.textContent='Company profile published.';}); }, async users(){title('Technicians and access','Every technician needs MFA and explicit device-group access.');const users=await api('users');content.innerHTML=`
${users.map(u=>``).join('')}
UsernameRoleIdentifierAccount access
${escapeText(u.username)}${u.disabled?' (disabled)':''}${escapeText(u.role)}${escapeText(u.id)}${u.disabled?'Disabled':``}

Add technician

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

Manage group access

${field('user_id','Technician identifier')}${field('group','Device group','text','default')}
`;bindForm('user',async v=>{const result=await api('users','POST',{username:v.get('username'),password:v.get('password'),role:'technician'});document.querySelector('#secret').textContent='Transfer privately: '+JSON.stringify(result);});bindForm('access',async v=>{await api('groups/'+encodeURIComponent(v.get('group'))+'/users/'+encodeURIComponent(v.get('user_id')),v.get('access_action'));notice.textContent=v.get('access_action')==='DELETE'?'Group access revoked. Existing sessions cannot renew authorization.':'Group access granted.';});for(const button of document.querySelectorAll('[data-disable-user]'))button.onclick=async()=>{button.disabled=true;try{await api('users/'+encodeURIComponent(button.dataset.disableUser)+'/disable','POST');await view('users');notice.textContent='Account disabled. New access and authorization renewals are denied.';}catch(error){showError(error);button.disabled=false;}}; }, From 5be190d9f49684bf9fec34537c2489a486195108 Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Fri, 2 Oct 2026 12:20:18 +0200 Subject: [PATCH 053/652] Extend HTTPS lifecycle rehearsal with group revocation and non-revival checks --- deployment/windows/test-company-lifecycle.js | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/deployment/windows/test-company-lifecycle.js b/deployment/windows/test-company-lifecycle.js index 1e68c9dac1d..7ac82f25a2d 100644 --- a/deployment/windows/test-company-lifecycle.js +++ b/deployment/windows/test-company-lifecycle.js @@ -102,6 +102,13 @@ async function main(){ policy.file=false;assert.equal((await api('groups/test-customers/permissions','PUT',admin,policy)).status,200); assert.equal((await api(`grants/${issued.grant_id}/renew`,'POST',customer.device_token)).status,403,'Reduced policy renewed older permissions'); const history=JSON.parse(runAgent(techDir,['history'],auth));assert.equal(history.length,1);assert.equal(history[0].claimed,true);assert.equal(history[0].device_id,customer.device_id); + const accessPath=`groups/test-customers/users/${technician.id}`; + assert.equal((await api(accessPath,'DELETE',technicianLogin.token)).status,401,'Technician revoked group access'); + assert.equal((await api(accessPath,'DELETE',admin)).status,200); + assert.equal(JSON.parse(runAgent(techDir,['devices'],auth)).length,0,'Revoked group remained visible'); + assert.equal((await api('grants','POST',technicianLogin.token,ticket)).status,403,'Revoked group issued a grant'); + assert.equal((await api(accessPath,'PUT',admin,{})).status,200); + assert.equal((await api(`grants/${issued.grant_id}/renew`,'POST',customer.device_token)).status,403,'Re-grant revived revoked session'); current.customer.display_name='Changed Local Test Branding';assert.equal((await api('profile','PUT',admin,current)).status,200); runAgent(customerDir,['sync']);const updated=JSON.parse(Buffer.from(state(customerDir).profile.payload,'base64'));assert.equal(updated.customer.display_name,'Changed Local Test Branding');assert.equal(updated.revision,2); assert.equal((await api(`devices/${customer.device_id}/state`,'PUT',admin,{state:'revoked',group:'test-customers'})).status,200); @@ -110,6 +117,6 @@ async function main(){ const source=spawnSync('git',['rev-parse','HEAD'],{cwd:root,encoding:'utf8'}); const dirty=spawnSync('git',['status','--porcelain'],{cwd:root,encoding:'utf8'}); fs.writeFileSync(path.join(data,'lifecycle-result.json'),JSON.stringify({passed:true,at:new Date().toISOString(),source_commit:source.status===0?source.stdout.trim():'unknown',source_dirty:dirty.status===0?dirty.stdout.trim().length>0:null,agent_sha256:crypto.createHash('sha256').update(fs.readFileSync(agent)).digest('hex'),management_sha256:crypto.createHash('sha256').update(fs.readFileSync(path.join(binaryDirectory,'swan-management.exe'))).digest('hex'),offline_revocation_recovery:settings.SWAN_TEST_FAULTS==='1',checks:['HTTPS certificate validation','fresh company setup','built agent bootstrap and enrollment','pending approval denial','technician MFA login and group inventory','technician session history','logout revokes credentials','unattended consent denial','grant replay denial','signed branding sync','device revocation']},null,2)); - console.log('PASS: real local HTTPS company lifecycle and built-agent checks. Native sessions and installers remain separate tests.'); + console.log('PASS: real local HTTPS company lifecycle, group revocation and built-agent checks. Native sessions and installers remain separate tests.'); } main().catch(error=>{console.error(error.message);process.exitCode=1;}); From e0a7d17790cc11552eafb323563c1492f7307d3a Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Fri, 2 Oct 2026 12:25:56 +0200 Subject: [PATCH 054/652] Record fresh built-agent HTTPS revocation rehearsal and release build evidence --- docs/ACCEPTANCE.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index 81a01c58203..5faba93e1e2 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -233,3 +233,13 @@ values. Both use the existing signed, validated edition profile fields; JavaScript syntax passes. Rendered interaction and installed edition branding synchronization remain unverified. Native runs 36992580109 and 36992817681 are still confirmed in progress; no completion result is inferred. + +At clean source 5be190d, the Windows GNU release workspace build succeeds. +A release-profile local-CA rehearsal stops at the expected TLS trust check: +release binaries ignore the debug-only test CA, and that protection was retained. +A separately built development rehearsal profile then passes the actual isolated +HTTPS lifecycle with fresh data and built agent/server executables, including +group removal, inventory and new-grant denial, non-revival after re-grant, prior +MFA/enrollment/branding checks and offline consent recovery. Exact binary hashes +and private result records remain in ignored test data. This does not verify +production certificate deployment, native sessions, signed installers or VMs. From 706b8dded5ec9641e6dd765b2d70ec8d034704f2 Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Fri, 2 Oct 2026 12:29:56 +0200 Subject: [PATCH 055/652] Prevent revoked device and unattended grants from reviving after reauthorization --- docs/ACCEPTANCE.md | 10 ++++++++++ product/management/src/service.rs | 22 +++++++++++++++++++--- product/management/src/tests.rs | 16 ++++++++++++++++ 3 files changed, 45 insertions(+), 3 deletions(-) diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index 5faba93e1e2..549e787ba46 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -243,3 +243,13 @@ group removal, inventory and new-grant denial, non-revival after re-grant, prior MFA/enrollment/branding checks and offline consent recovery. Exact binary hashes and private result records remain in ignored test data. This does not verify production certificate deployment, native sessions, signed installers or VMs. + +Device revocation/pending transitions and device group moves now close existing +grants in the same audited transaction, preventing re-approval or moving back +from reviving them. Unattended consent revocation permanently closes unattended +grants while preserving attended grants. All 28 workspace tests pass, and the +expanded API lifecycle additionally verifies re-consent non-revival, attended +authorization preservation, device re-approval denial and group move/back denial +including administrator grants. Native session shutdown on lease expiry remains +unverified. Current native CI was confirmed building the branded client, with +no terminal result yet. diff --git a/product/management/src/service.rs b/product/management/src/service.rs index a8d03910dfd..f758ca4d9fb 100644 --- a/product/management/src/service.rs +++ b/product/management/src/service.rs @@ -283,9 +283,15 @@ struct DeviceState { state:String,group:String } async fn device_state(State(s):State,headers:HeaderMap,Path(id):Path,Json(input):Json)->ApiResult { let actor=s.user(&headers,true)?; if !["approved","revoked","pending"].contains(&input.state.as_str()) || input.group.is_empty() || input.group.len()>64 {return Err(bad("Invalid state or group"));} - let count=s.db.lock().unwrap().execute("UPDATE devices SET state=?1,group_id=?2 WHERE id=?3",params![input.state,input.group,id])?; - if count==0 {return Err(ApiError(StatusCode::NOT_FOUND,"Device not found"));} - s.audit(&actor,&format!("device.{}",input.state),&id)?;Ok(Json(json!({"ok":true}))) + let mut db=s.db.lock().unwrap();let tx=db.transaction()?; + let previous_group:Option=tx.query_row("SELECT group_id FROM devices WHERE id=?1",[&id],|row|row.get(0)).optional()?; + let previous_group=previous_group.ok_or(ApiError(StatusCode::NOT_FOUND,"Device not found"))?; + tx.execute("UPDATE devices SET state=?1,group_id=?2 WHERE id=?3",params![input.state,input.group,id])?; + if input.state!="approved" || input.group!=previous_group { + tx.execute("UPDATE grants SET closed=1,lease_until=0 WHERE device_id=?1",[&id])?; + } + tx.execute("INSERT INTO audit(at,actor,event,target) VALUES(?1,?2,?3,?4)",params![now(),actor,format!("device.{}",input.state),id])?; + tx.commit()?;Ok(Json(json!({"ok":true}))) } #[derive(Deserialize)] struct Consent { unattended:bool,revision:u64 } @@ -308,6 +314,16 @@ async fn consent(State(s):State,headers:HeaderMap,Json(input):Json(0)?,row.get::<_,String>(1)?)))?; + let mut ids=Vec::new(); + for row in rows {let (id,body)=row?;let grant:SessionGrant=serde_json::from_str(&body)?;if grant.unattended {ids.push(id);}} + ids + }; + for id in ids {transaction.execute("UPDATE grants SET closed=1,lease_until=0 WHERE id=?1",[id])?;} + } transaction.execute("INSERT INTO device_consent_revisions(device_id,revision) VALUES(?1,?2) ON CONFLICT(device_id) DO UPDATE SET revision=excluded.revision",params![device,input.revision])?; transaction.commit()?; } diff --git a/product/management/src/tests.rs b/product/management/src/tests.rs index 80d353f413a..caad0eeac76 100644 --- a/product/management/src/tests.rs +++ b/product/management/src/tests.rs @@ -67,10 +67,16 @@ async fn enrollment_mfa_and_grants_fail_closed() { assert_eq!(history[0]["claimed"],true); assert_eq!(request(&app,"/api/v1/grants","POST",Some(admin),json!({"device_id":device_id,"proof_public_key":STANDARD.encode(proof.verifying_key().as_bytes()),"unattended":true})).await.0,StatusCode::FORBIDDEN,"Unattended access needs device consent"); assert_eq!(request(&app,"/api/v1/device/consent","PUT",Some(credential),json!({"unattended":true,"revision":1})).await.0,StatusCode::OK); + let (_,unattended_envelope)=request(&app,"/api/v1/grants","POST",Some(admin),json!({"device_id":device_id,"proof_public_key":STANDARD.encode(proof.verifying_key().as_bytes()),"unattended":true})).await; + let signed:SignedEnvelope=serde_json::from_value(unattended_envelope.clone()).unwrap(); + let unattended_grant:SessionGrant=serde_json::from_slice(&STANDARD.decode(&signed.payload).unwrap()).unwrap(); + assert_eq!(request(&app,"/api/v1/grants/claim","POST",Some(credential),json!({"grant":unattended_envelope})).await.0,StatusCode::OK); assert_eq!(request(&app,"/api/v1/device/consent","PUT",Some(credential),json!({"unattended":false,"revision":2})).await.0,StatusCode::OK); assert_eq!(request(&app,"/api/v1/device/consent","PUT",Some(credential),json!({"unattended":true,"revision":1})).await.0,StatusCode::CONFLICT,"Delayed enable cannot undo revocation"); assert_eq!(request(&app,"/api/v1/device/consent","PUT",Some(credential),json!({"unattended":true,"revision":2})).await.0,StatusCode::CONFLICT,"Revocation wins same-revision races"); assert_eq!(request(&app,"/api/v1/device/consent","PUT",Some(credential),json!({"unattended":true,"revision":3})).await.0,StatusCode::OK); + assert_eq!(request(&app,&format!("/api/v1/grants/{}/renew",unattended_grant.grant_id),"POST",Some(credential),Value::Null).await.0,StatusCode::FORBIDDEN,"New consent cannot revive old unattended authorization"); + assert_eq!(request(&app,&format!("/api/v1/grants/{}/renew",issued.grant_id),"POST",Some(credential),Value::Null).await.0,StatusCode::OK,"Unattended revocation must preserve attended authorization"); assert_eq!(request(&app,"/api/v1/device/consent","PUT",Some(credential),json!({"unattended":false,"revision":2})).await.0,StatusCode::CONFLICT,"Old retry cannot undo newer explicit consent"); assert_eq!(request(&app,"/api/v1/device/consent","PUT",Some(credential),json!({"unattended":false,"revision":4})).await.0,StatusCode::OK); assert_eq!(request(&app,"/api/v1/device/consent","PUT",Some(credential),json!({"unattended":false,"revision":4})).await.0,StatusCode::OK,"Revocation retries are idempotent"); @@ -105,6 +111,16 @@ async fn enrollment_mfa_and_grants_fail_closed() { assert_eq!(request(&app,&format!("/api/v1/devices/{device_id}/state"),"PUT",Some(admin),json!({"state":"revoked","group":"customers"})).await.0,StatusCode::OK); assert_eq!(request(&app,"/api/v1/device/status","GET",Some(credential),Value::Null).await.0,StatusCode::UNAUTHORIZED,"Repair cannot restore a revoked device"); assert_eq!(request(&app,"/api/v1/grants","POST",Some(admin),json!({"device_id":device_id,"proof_public_key":STANDARD.encode(proof.verifying_key().as_bytes()),"unattended":false})).await.0,StatusCode::FORBIDDEN); + assert_eq!(request(&app,&format!("/api/v1/devices/{device_id}/state"),"PUT",Some(admin),json!({"state":"approved","group":"customers"})).await.0,StatusCode::OK); + assert_eq!(request(&app,&format!("/api/v1/grants/{}/renew",issued.grant_id),"POST",Some(credential),Value::Null).await.0,StatusCode::FORBIDDEN,"Re-approval cannot revive authorization from before device revocation"); + let (_,moved_envelope)=request(&app,"/api/v1/grants","POST",Some(admin),json!({"device_id":device_id,"proof_public_key":STANDARD.encode(proof.verifying_key().as_bytes()),"unattended":false})).await; + let signed:SignedEnvelope=serde_json::from_value(moved_envelope.clone()).unwrap(); + let moved_grant:SessionGrant=serde_json::from_slice(&STANDARD.decode(&signed.payload).unwrap()).unwrap(); + assert_eq!(request(&app,"/api/v1/grants/claim","POST",Some(credential),json!({"grant":moved_envelope})).await.0,StatusCode::OK); + for group in ["another-group","customers"] { + assert_eq!(request(&app,&format!("/api/v1/devices/{device_id}/state"),"PUT",Some(admin),json!({"state":"approved","group":group})).await.0,StatusCode::OK); + assert_eq!(request(&app,&format!("/api/v1/grants/{}/renew",moved_grant.grant_id),"POST",Some(credential),Value::Null).await.0,StatusCode::FORBIDDEN,"Group moves must invalidate old grants, including administrator grants"); + } drop(app);drop(store);std::fs::remove_dir_all(directory).unwrap(); } From 2ecf80c801bde852471bccd04841230cb832fdca Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Fri, 2 Oct 2026 12:33:11 +0200 Subject: [PATCH 056/652] Close withdrawn capability grants and serialize grant permission snapshots --- docs/ACCEPTANCE.md | 8 ++++++++ product/management/src/service.rs | 24 ++++++++++++++++++------ product/management/src/tests.rs | 7 ++++++- 3 files changed, 32 insertions(+), 7 deletions(-) diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index 549e787ba46..744451a5d79 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -253,3 +253,11 @@ authorization preservation, device re-approval denial and group move/back denial including administrator grants. Native session shutdown on lease expiry remains unverified. Current native CI was confirmed building the branded client, with no terminal result yet. + +Group capability reductions now close incompatible grants transactionally. +Restoring capabilities does not revive withdrawn authorization. Grant creation +checks device/user/group access, snapshots permissions and inserts its grant +and audit event in one transaction, avoiding a stale permission insertion +after reduction. The expanded API lifecycle test passes, including restored +policy non-revival and separate attended-consent preservation. Concurrent fault +injection and native session termination remain unverified. diff --git a/product/management/src/service.rs b/product/management/src/service.rs index f758ca4d9fb..9fc107c5266 100644 --- a/product/management/src/service.rs +++ b/product/management/src/service.rs @@ -347,19 +347,31 @@ async fn group_permissions(State(s):State,headers:HeaderMap,Path(group): } async fn set_group_permissions(State(s):State,headers:HeaderMap,Path(group):Path,Json(policy):Json)->ApiResult { let actor=s.user(&headers,true)?;if group.is_empty() || group.len()>64 {return Err(bad("Invalid group"));} - s.db.lock().unwrap().execute("INSERT INTO group_permissions(group_id,body) VALUES(?1,?2) ON CONFLICT(group_id) DO UPDATE SET body=excluded.body",params![group,serde_json::to_string(&policy)?])?; - s.audit(&actor,"group.permissions_changed",&group)?;Ok(Json(json!({"ok":true}))) + let mut db=s.db.lock().unwrap();let tx=db.transaction()?; + tx.execute("INSERT INTO group_permissions(group_id,body) VALUES(?1,?2) ON CONFLICT(group_id) DO UPDATE SET body=excluded.body",params![group,serde_json::to_string(&policy)?])?; + let invalid={ + let mut statement=tx.prepare("SELECT g.id,g.body FROM grants g JOIN devices d ON d.id=g.device_id WHERE d.group_id=?1 AND g.closed=0")?; + let rows=statement.query_map([&group],|row|Ok((row.get::<_,String>(0)?,row.get::<_,String>(1)?)))?; + let mut ids=Vec::new(); + for row in rows {let (id,body)=row?;let grant:SessionGrant=serde_json::from_str(&body)?;if !policy.allows(&grant.permissions){ids.push(id);}} + ids + }; + for id in invalid {tx.execute("UPDATE grants SET closed=1,lease_until=0 WHERE id=?1",[id])?;} + tx.execute("INSERT INTO audit(at,actor,event,target) VALUES(?1,?2,'group.permissions_changed',?3)",params![now(),actor,group])?; + tx.commit()?;Ok(Json(json!({"ok":true}))) } async fn grant(State(s):State,headers:HeaderMap,Json(input):Json)->ApiResult { let user=s.user(&headers,false)?;s.rate(&format!("grant:{user}"),30)?; public_key(&input.proof_public_key).map_err(|_|bad("Invalid proof key"))?; let profile=s.profile()?; - let peer:Option=s.db.lock().unwrap().query_row("SELECT d.rustdesk_id FROM devices d JOIN users u ON u.id=?2 WHERE d.id=?1 AND d.state='approved' AND u.disabled=0 AND (?3=0 OR d.unattended=1) AND (u.role='admin' OR EXISTS(SELECT 1 FROM group_access g WHERE g.group_id=d.group_id AND g.user_id=u.id))",params![input.device_id,user,input.unattended],|r|r.get(0)).optional()?; + let mut db=s.db.lock().unwrap();let tx=db.transaction()?; + let peer:Option=tx.query_row("SELECT d.rustdesk_id FROM devices d JOIN users u ON u.id=?2 WHERE d.id=?1 AND d.state='approved' AND u.disabled=0 AND (?3=0 OR d.unattended=1) AND (u.role='admin' OR EXISTS(SELECT 1 FROM group_access g WHERE g.group_id=d.group_id AND g.user_id=u.id))",params![input.device_id,user,input.unattended],|r|r.get(0)).optional()?; let peer=peer.ok_or_else(denied)?; if input.unattended && !profile.allow_unattended {return Err(denied());} - let grant=SessionGrant { schema:SCHEMA,company_id:profile.company_id,grant_id:Uuid::new_v4().to_string(),technician_id:user.clone(),device_id:input.device_id.clone(),rustdesk_id:peer,proof_public_key:input.proof_public_key,unattended:input.unattended,permissions:device_permissions(&s.db.lock().unwrap(),&input.device_id)?,issued_at:now(),expires_at:now()+60 }; - s.db.lock().unwrap().execute("INSERT INTO grants(id,user_id,device_id,body,expires_at) VALUES(?1,?2,?3,?4,?5)",params![grant.grant_id,user,input.device_id,serde_json::to_string(&grant)?,grant.expires_at])?; - s.audit(&user,"session.requested",&grant.grant_id)?; + let grant=SessionGrant { schema:SCHEMA,company_id:profile.company_id,grant_id:Uuid::new_v4().to_string(),technician_id:user.clone(),device_id:input.device_id.clone(),rustdesk_id:peer,proof_public_key:input.proof_public_key,unattended:input.unattended,permissions:device_permissions(&tx,&input.device_id)?,issued_at:now(),expires_at:now()+60 }; + tx.execute("INSERT INTO grants(id,user_id,device_id,body,expires_at) VALUES(?1,?2,?3,?4,?5)",params![grant.grant_id,user,input.device_id,serde_json::to_string(&grant)?,grant.expires_at])?; + tx.execute("INSERT INTO audit(at,actor,event,target) VALUES(?1,?2,'session.requested',?3)",params![now(),user,grant.grant_id])?; + tx.commit()?; Ok(Json(serde_json::to_value(SignedEnvelope::sign(&grant,&s.key)?)?)) } #[derive(Deserialize)] diff --git a/product/management/src/tests.rs b/product/management/src/tests.rs index caad0eeac76..6d5755a89e3 100644 --- a/product/management/src/tests.rs +++ b/product/management/src/tests.rs @@ -61,12 +61,17 @@ async fn enrollment_mfa_and_grants_fail_closed() { assert_eq!(request(&app,&format!("/api/v1/grants/{}/renew",issued.grant_id),"POST",Some(credential),Value::Null).await.0,StatusCode::FORBIDDEN,"Reduced capabilities invalidate existing authorization leases"); assert_eq!(request(&app,"/api/v1/groups/customers/permissions","PUT",None,serde_json::to_value(&reduced).unwrap()).await.0,StatusCode::UNAUTHORIZED); assert_eq!(request(&app,"/api/v1/groups/customers/permissions","PUT",Some(admin),serde_json::to_value(SessionPermissions::support_default()).unwrap()).await.0,StatusCode::OK); + assert_eq!(request(&app,&format!("/api/v1/grants/{}/renew",issued.grant_id),"POST",Some(credential),Value::Null).await.0,StatusCode::FORBIDDEN,"Restored capabilities cannot revive withdrawn authorization"); assert_eq!(request(&app,"/api/v1/grants/claim","POST",Some(credential),claim).await.0,StatusCode::FORBIDDEN,"Session ticket is single-use"); let (_,history)=request(&app,"/api/v1/sessions","GET",Some(admin),Value::Null).await; assert_eq!(history.as_array().unwrap().len(),1); assert_eq!(history[0]["claimed"],true); assert_eq!(request(&app,"/api/v1/grants","POST",Some(admin),json!({"device_id":device_id,"proof_public_key":STANDARD.encode(proof.verifying_key().as_bytes()),"unattended":true})).await.0,StatusCode::FORBIDDEN,"Unattended access needs device consent"); assert_eq!(request(&app,"/api/v1/device/consent","PUT",Some(credential),json!({"unattended":true,"revision":1})).await.0,StatusCode::OK); + let (_,attended_envelope)=request(&app,"/api/v1/grants","POST",Some(admin),json!({"device_id":device_id,"proof_public_key":STANDARD.encode(proof.verifying_key().as_bytes()),"unattended":false})).await; + let signed:SignedEnvelope=serde_json::from_value(attended_envelope.clone()).unwrap(); + let attended_grant:SessionGrant=serde_json::from_slice(&STANDARD.decode(&signed.payload).unwrap()).unwrap(); + assert_eq!(request(&app,"/api/v1/grants/claim","POST",Some(credential),json!({"grant":attended_envelope})).await.0,StatusCode::OK); let (_,unattended_envelope)=request(&app,"/api/v1/grants","POST",Some(admin),json!({"device_id":device_id,"proof_public_key":STANDARD.encode(proof.verifying_key().as_bytes()),"unattended":true})).await; let signed:SignedEnvelope=serde_json::from_value(unattended_envelope.clone()).unwrap(); let unattended_grant:SessionGrant=serde_json::from_slice(&STANDARD.decode(&signed.payload).unwrap()).unwrap(); @@ -76,7 +81,7 @@ async fn enrollment_mfa_and_grants_fail_closed() { assert_eq!(request(&app,"/api/v1/device/consent","PUT",Some(credential),json!({"unattended":true,"revision":2})).await.0,StatusCode::CONFLICT,"Revocation wins same-revision races"); assert_eq!(request(&app,"/api/v1/device/consent","PUT",Some(credential),json!({"unattended":true,"revision":3})).await.0,StatusCode::OK); assert_eq!(request(&app,&format!("/api/v1/grants/{}/renew",unattended_grant.grant_id),"POST",Some(credential),Value::Null).await.0,StatusCode::FORBIDDEN,"New consent cannot revive old unattended authorization"); - assert_eq!(request(&app,&format!("/api/v1/grants/{}/renew",issued.grant_id),"POST",Some(credential),Value::Null).await.0,StatusCode::OK,"Unattended revocation must preserve attended authorization"); + assert_eq!(request(&app,&format!("/api/v1/grants/{}/renew",attended_grant.grant_id),"POST",Some(credential),Value::Null).await.0,StatusCode::OK,"Unattended revocation must preserve attended authorization"); assert_eq!(request(&app,"/api/v1/device/consent","PUT",Some(credential),json!({"unattended":false,"revision":2})).await.0,StatusCode::CONFLICT,"Old retry cannot undo newer explicit consent"); assert_eq!(request(&app,"/api/v1/device/consent","PUT",Some(credential),json!({"unattended":false,"revision":4})).await.0,StatusCode::OK); assert_eq!(request(&app,"/api/v1/device/consent","PUT",Some(credential),json!({"unattended":false,"revision":4})).await.0,StatusCode::OK,"Revocation retries are idempotent"); From dff4448bf6bb4c1a39602f36ff620e56db6bd092 Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Fri, 2 Oct 2026 12:35:13 +0200 Subject: [PATCH 057/652] Invalidate unattended grants when company policy disables access --- docs/ACCEPTANCE.md | 8 ++++++++ product/management/src/service.rs | 24 +++++++++++++++--------- product/management/src/tests.rs | 10 ++++++++++ 3 files changed, 33 insertions(+), 9 deletions(-) diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index 744451a5d79..8b5a9214d8d 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -261,3 +261,11 @@ and audit event in one transaction, avoiding a stale permission insertion after reduction. The expanded API lifecycle test passes, including restored policy non-revival and separate attended-consent preservation. Concurrent fault injection and native session termination remain unverified. + +Disabling company-wide unattended support now closes all unattended grants in +the profile-update transaction. Restoring that setting cannot revive them, and +attended authorization is preserved. Grant creation reads company policy within +the same transaction as access checks and permission snapshots. Expanded API +lifecycle tests pass for global disable/restore non-revival, attended renewal +and a fresh grant followed by local consent revocation. Concurrent fault tests +and native termination behavior remain unverified. diff --git a/product/management/src/service.rs b/product/management/src/service.rs index 9fc107c5266..684e98485d6 100644 --- a/product/management/src/service.rs +++ b/product/management/src/service.rs @@ -232,6 +232,7 @@ async fn save_profile(State(s):State,headers:HeaderMap,Json(mut input):J let raw:String=tx.query_row("SELECT body FROM profile WHERE id=1",[],|r|r.get(0))?; if serde_json::from_str::(&raw)?.revision!=current.revision {return Err(ApiError(StatusCode::CONFLICT,"Profile changed; reload"));} tx.execute("UPDATE profile SET body=?1 WHERE id=1",[serde_json::to_string(&input)?])?; + if !input.allow_unattended {close_unattended_grants(&tx,None)?;} tx.execute("INSERT INTO audit(at,actor,event,target) VALUES(?1,?2,'profile.updated',?3)",params![now(),user,input.revision.to_string()])?; tx.commit()?;Ok(Json(json!({"revision":input.revision}))) } @@ -295,6 +296,17 @@ async fn device_state(State(s):State,headers:HeaderMap,Path(id):Path)->Result<(),ApiError> { + let ids={ + let mut statement=db.prepare("SELECT id,body FROM grants WHERE closed=0 AND (?1 IS NULL OR device_id=?1)")?; + let rows=statement.query_map([device],|row|Ok((row.get::<_,String>(0)?,row.get::<_,String>(1)?)))?; + let mut ids=Vec::new(); + for row in rows {let (id,body)=row?;let grant:SessionGrant=serde_json::from_str(&body)?;if grant.unattended {ids.push(id);}} + ids + }; + for id in ids {db.execute("UPDATE grants SET closed=1,lease_until=0 WHERE id=?1",[id])?;} + Ok(()) +} async fn device_status(State(s):State,headers:HeaderMap)->ApiResult { // Pending devices may inspect only their own identity, never session APIs. let device:String=s.db.lock().unwrap().query_row("SELECT id FROM devices WHERE token_hash=?1 AND state IN ('pending','approved')",[digest(token(&headers)?)],|row|row.get(0)).optional()?.ok_or_else(unauthorized)?; @@ -315,14 +327,7 @@ async fn consent(State(s):State,headers:HeaderMap,Json(input):Json(0)?,row.get::<_,String>(1)?)))?; - let mut ids=Vec::new(); - for row in rows {let (id,body)=row?;let grant:SessionGrant=serde_json::from_str(&body)?;if grant.unattended {ids.push(id);}} - ids - }; - for id in ids {transaction.execute("UPDATE grants SET closed=1,lease_until=0 WHERE id=?1",[id])?;} + close_unattended_grants(&transaction,Some(&device))?; } transaction.execute("INSERT INTO device_consent_revisions(device_id,revision) VALUES(?1,?2) ON CONFLICT(device_id) DO UPDATE SET revision=excluded.revision",params![device,input.revision])?; transaction.commit()?; @@ -363,8 +368,9 @@ async fn set_group_permissions(State(s):State,headers:HeaderMap,Path(gro async fn grant(State(s):State,headers:HeaderMap,Json(input):Json)->ApiResult { let user=s.user(&headers,false)?;s.rate(&format!("grant:{user}"),30)?; public_key(&input.proof_public_key).map_err(|_|bad("Invalid proof key"))?; - let profile=s.profile()?; let mut db=s.db.lock().unwrap();let tx=db.transaction()?; + let raw:String=tx.query_row("SELECT body FROM profile WHERE id=1",[],|row|row.get(0))?; + let profile:CompanyProfile=serde_json::from_str(&raw)?; let peer:Option=tx.query_row("SELECT d.rustdesk_id FROM devices d JOIN users u ON u.id=?2 WHERE d.id=?1 AND d.state='approved' AND u.disabled=0 AND (?3=0 OR d.unattended=1) AND (u.role='admin' OR EXISTS(SELECT 1 FROM group_access g WHERE g.group_id=d.group_id AND g.user_id=u.id))",params![input.device_id,user,input.unattended],|r|r.get(0)).optional()?; let peer=peer.ok_or_else(denied)?; if input.unattended && !profile.allow_unattended {return Err(denied());} diff --git a/product/management/src/tests.rs b/product/management/src/tests.rs index 6d5755a89e3..f8da69fc73b 100644 --- a/product/management/src/tests.rs +++ b/product/management/src/tests.rs @@ -76,6 +76,16 @@ async fn enrollment_mfa_and_grants_fail_closed() { let signed:SignedEnvelope=serde_json::from_value(unattended_envelope.clone()).unwrap(); let unattended_grant:SessionGrant=serde_json::from_slice(&STANDARD.decode(&signed.payload).unwrap()).unwrap(); assert_eq!(request(&app,"/api/v1/grants/claim","POST",Some(credential),json!({"grant":unattended_envelope})).await.0,StatusCode::OK); + let mut company_policy=profile_fixture();company_policy.allow_unattended=false; + assert_eq!(request(&app,"/api/v1/profile","PUT",Some(admin),serde_json::to_value(&company_policy).unwrap()).await.0,StatusCode::OK); + company_policy.allow_unattended=true; + assert_eq!(request(&app,"/api/v1/profile","PUT",Some(admin),serde_json::to_value(&company_policy).unwrap()).await.0,StatusCode::OK); + assert_eq!(request(&app,&format!("/api/v1/grants/{}/renew",unattended_grant.grant_id),"POST",Some(credential),Value::Null).await.0,StatusCode::FORBIDDEN,"Company policy restoration cannot revive old unattended grants"); + assert_eq!(request(&app,&format!("/api/v1/grants/{}/renew",attended_grant.grant_id),"POST",Some(credential),Value::Null).await.0,StatusCode::OK,"Company unattended policy must preserve attended sessions"); + let (_,local_envelope)=request(&app,"/api/v1/grants","POST",Some(admin),json!({"device_id":device_id,"proof_public_key":STANDARD.encode(proof.verifying_key().as_bytes()),"unattended":true})).await; + let signed:SignedEnvelope=serde_json::from_value(local_envelope.clone()).unwrap(); + let unattended_grant:SessionGrant=serde_json::from_slice(&STANDARD.decode(&signed.payload).unwrap()).unwrap(); + assert_eq!(request(&app,"/api/v1/grants/claim","POST",Some(credential),json!({"grant":local_envelope})).await.0,StatusCode::OK); assert_eq!(request(&app,"/api/v1/device/consent","PUT",Some(credential),json!({"unattended":false,"revision":2})).await.0,StatusCode::OK); assert_eq!(request(&app,"/api/v1/device/consent","PUT",Some(credential),json!({"unattended":true,"revision":1})).await.0,StatusCode::CONFLICT,"Delayed enable cannot undo revocation"); assert_eq!(request(&app,"/api/v1/device/consent","PUT",Some(credential),json!({"unattended":true,"revision":2})).await.0,StatusCode::CONFLICT,"Revocation wins same-revision races"); From e42276191fb69a42cdc18c5f489e4ef5a7177f9f Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Fri, 2 Oct 2026 12:40:27 +0200 Subject: [PATCH 058/652] Add signed company support contacts and safe website shortcuts to both editions --- docs/ACCEPTANCE.md | 11 +++++++ .../desktop/pages/company_contact_links.dart | 33 +++++++++++++++++++ .../pages/company_technician_page.dart | 2 ++ .../lib/desktop/pages/desktop_home_page.dart | 11 ++----- product/management/web/app.js | 3 +- product/protocol/src/lib.rs | 32 +++++++++++++++++- src/managed.rs | 2 +- 7 files changed, 82 insertions(+), 12 deletions(-) create mode 100644 flutter/lib/desktop/pages/company_contact_links.dart diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index 8b5a9214d8d..ed0c5d33e15 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -269,3 +269,14 @@ the same transaction as access checks and permission snapshots. Expanded API lifecycle tests pass for global disable/restore non-revival, attended renewal and a fresh grant followed by local consent revocation. Concurrent fault tests and native termination behavior remain unverified. + +Signed edition branding now supports public support-contact text and up to eight +named HTTPS website shortcuts. Administration edits each edition independently; +both native endpoint pages use a shared contact/link component. Shortcuts cannot +execute shell commands, local files or non-HTTPS schemes. Empty fields preserve +existing serialized branding, and old profiles deserialize with defaults. All +29 workspace tests pass, including credential-bearing/unsafe URLs, invalid +labels, excessive links and control characters. JavaScript syntax and native +Rust parsing pass. Flutter compilation, rendering and installed synchronization +remain unverified. Upgrade clients to compatible builds before publishing new +nonempty branding fields; older strict clients will reject unfamiliar fields. diff --git a/flutter/lib/desktop/pages/company_contact_links.dart b/flutter/lib/desktop/pages/company_contact_links.dart new file mode 100644 index 00000000000..3cae951da1f --- /dev/null +++ b/flutter/lib/desktop/pages/company_contact_links.dart @@ -0,0 +1,33 @@ +import 'package:flutter/material.dart'; +import 'package:url_launcher/url_launcher.dart'; + +/// Display only public, signed company information. Links never execute commands. +class CompanyContactLinks extends StatelessWidget { + final Map company; + const CompanyContactLinks({super.key, required this.company}); + + Widget _link(String label, String address) => TextButton.icon( + onPressed: () async { + final url = Uri.tryParse(address); + if (url != null && url.scheme == 'https' && url.host.isNotEmpty && url.userInfo.isEmpty) { + await launchUrl(url); + } + }, + icon: const Icon(Icons.open_in_new), + label: Text(label), + ); + + @override + Widget build(BuildContext context) { + final contacts = company['support_contacts'] as String? ?? ''; + final support = company['support_url'] as String? ?? ''; + final shortcuts = company['shortcuts'] is List ? company['shortcuts'] as List : const []; + return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + if (contacts.isNotEmpty) SelectableText(contacts), + if (support.isNotEmpty) _link('Contact support', support), + for (final entry in shortcuts.whereType()) + if (entry['label'] is String && entry['url'] is String) + _link(entry['label'] as String, entry['url'] as String), + ]); + } +} diff --git a/flutter/lib/desktop/pages/company_technician_page.dart b/flutter/lib/desktop/pages/company_technician_page.dart index 63ceb1616c9..b72c5abbe62 100644 --- a/flutter/lib/desktop/pages/company_technician_page.dart +++ b/flutter/lib/desktop/pages/company_technician_page.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'package:flutter_hbb/common.dart'; import 'package:flutter_hbb/models/platform_model.dart'; +import 'company_contact_links.dart'; /// Company authorization lives in Rust and on the receiver. This view never /// receives a bearer token, signing key or reusable device credential. @@ -113,6 +114,7 @@ class _CompanyTechnicianPageState extends State { Text(_company['display_name'] as String? ?? 'Swan Remote Support Technician', style: Theme.of(context).textTheme.headlineSmall?.copyWith(color: brandColor)), Text(_company['domain'] as String? ?? 'Company setup required'), + CompanyContactLinks(company: _company), const SizedBox(height: 16), if (_busy) const LinearProgressIndicator(), if (_error.isNotEmpty) Padding(padding: const EdgeInsets.symmetric(vertical: 12), diff --git a/flutter/lib/desktop/pages/desktop_home_page.dart b/flutter/lib/desktop/pages/desktop_home_page.dart index 5d56cc384d0..93b2ab38a53 100644 --- a/flutter/lib/desktop/pages/desktop_home_page.dart +++ b/flutter/lib/desktop/pages/desktop_home_page.dart @@ -1,3 +1,4 @@ +import 'company_contact_links.dart'; import 'dart:async'; import 'dart:io'; import 'dart:convert'; @@ -276,15 +277,7 @@ class _DesktopHomePageState extends State icon: Icon(svcStopped.value ? Icons.play_arrow : Icons.stop_circle_outlined), label: Text(svcStopped.value ? 'Resume support' : 'Stop support'), )), - if ((company['support_url'] as String? ?? '').isNotEmpty) - TextButton.icon( - onPressed: () async { - final url = Uri.tryParse(company['support_url'] as String); - if (url != null && url.scheme == 'https' && url.host.isNotEmpty && url.userInfo.isEmpty) await launchUrl(url); - }, - icon: const Icon(Icons.support_agent), - label: const Text('Contact support'), - ), + CompanyContactLinks(company: company), if (company['unattended'] == true) TextButton.icon( onPressed: () async { diff --git a/product/management/web/app.js b/product/management/web/app.js index ffadb6b443f..3e1919c4bd4 100644 --- a/product/management/web/app.js +++ b/product/management/web/app.js @@ -1,3 +1,4 @@ +function parseShortcuts(value){return (value||'').split(/\r?\n/).filter(line=>line.trim()).map(line=>{const separator=line.indexOf('|');if(separator<1)throw new Error('Each shortcut needs a name followed by | and an HTTPS URL.');return {label:line.slice(0,separator).trim(),url:line.slice(separator+1).trim()};});} 'use strict'; let accessToken=''; const content=document.querySelector('#content'); @@ -59,7 +60,7 @@ const screens={ 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=`

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

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)}
${field('rollout','Rollout percentage','number',p.rollout_percent)}
${field('maintenance_start','Maintenance start hour (UTC, 0โ€“23)','number',p.maintenance_start_utc)}${field('maintenance_end','Maintenance end hour (UTC, 0โ€“23)','number',p.maintenance_end_utc)}
Windows can cross midnight. Equal start and end hours allow updates throughout the day. Releases still require administrator approval.
`;bindForm('branding',async 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.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'));await api('profile','PUT',p);notice.textContent='Company profile published.';}); + 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=`

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

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)}
${field('rollout','Rollout percentage','number',p.rollout_percent)}
${field('maintenance_start','Maintenance start hour (UTC, 0โ€“23)','number',p.maintenance_start_utc)}${field('maintenance_end','Maintenance end hour (UTC, 0โ€“23)','number',p.maintenance_end_utc)}
Windows can cross midnight. Equal start and end hours allow updates throughout the day. Releases still require administrator approval.
`;bindForm('branding',async 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=parseShortcuts(v.get(edition+'_shortcuts'));}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'));await api('profile','PUT',p);notice.textContent='Company profile published.';}); }, async users(){title('Technicians and access','Every technician needs MFA and explicit device-group access.');const users=await api('users');content.innerHTML=`
${users.map(u=>``).join('')}
UsernameRoleIdentifierAccount access
${escapeText(u.username)}${u.disabled?' (disabled)':''}${escapeText(u.role)}${escapeText(u.id)}${u.disabled?'Disabled':``}

Add technician

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

Manage group access

${field('user_id','Technician identifier')}${field('group','Device group','text','default')}
`;bindForm('user',async v=>{const result=await api('users','POST',{username:v.get('username'),password:v.get('password'),role:'technician'});document.querySelector('#secret').textContent='Transfer privately: '+JSON.stringify(result);});bindForm('access',async v=>{await api('groups/'+encodeURIComponent(v.get('group'))+'/users/'+encodeURIComponent(v.get('user_id')),v.get('access_action'));notice.textContent=v.get('access_action')==='DELETE'?'Group access revoked. Existing sessions cannot renew authorization.':'Group access granted.';});for(const button of document.querySelectorAll('[data-disable-user]'))button.onclick=async()=>{button.disabled=true;try{await api('users/'+encodeURIComponent(button.dataset.disableUser)+'/disable','POST');await view('users');notice.textContent='Account disabled. New access and authorization renewals are denied.';}catch(error){showError(error);button.disabled=false;}}; }, diff --git a/product/protocol/src/lib.rs b/product/protocol/src/lib.rs index 645bdf81d5c..63866b7e8a1 100644 --- a/product/protocol/src/lib.rs +++ b/product/protocol/src/lib.rs @@ -14,6 +14,10 @@ pub const WIRE_PREFIX: &[u8] = b"SWAN1:"; #[serde(rename_all = "snake_case")] pub enum Edition { Customer, Technician } +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct BrandingShortcut { pub label:String, pub url:String } + #[derive(Clone, Debug, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct Branding { @@ -22,12 +26,16 @@ pub struct Branding { pub logo_svg: String, pub support_url: String, pub consent_text: String, + #[serde(default,skip_serializing_if="String::is_empty")] + pub support_contacts: String, + #[serde(default,skip_serializing_if="Vec::is_empty")] + pub shortcuts: Vec, } impl Default for Branding { fn default() -> Self { Self { display_name: PRODUCT.into(), primary_color: "#007F82".into(), logo_svg: include_str!("../../../branding/swan-support-mark.svg").into(), - support_url: String::new(), + support_url: String::new(), support_contacts:String::new(), shortcuts:Vec::new(), consent_text: "Only allow remote support from a technician you trust. You can stop support or revoke ongoing access at any time.".into() } } } @@ -43,6 +51,13 @@ impl Branding { ensure!(!svg.contains(forbidden), "Unsupported SVG content"); } if !self.support_url.is_empty() { https_url(&self.support_url)?; } + ensure!(self.support_contacts.len()<=2000 && self.support_contacts.chars().all(|c|!c.is_control() || matches!(c,'\n'|'\r'|'\t')),"Invalid support contacts"); + ensure!(self.shortcuts.len()<=8,"Too many company shortcuts"); + for shortcut in &self.shortcuts { + ensure!(!shortcut.label.trim().is_empty() && shortcut.label.len()<=100 && !shortcut.label.chars().any(char::is_control),"Invalid shortcut label"); + ensure!(shortcut.url.len()<=2048,"Shortcut URL too long"); + https_url(&shortcut.url)?; + } Ok(()) } } @@ -281,6 +296,21 @@ pub fn maintenance_open(start: u8, end: u8, now: u64) -> bool { mod tests { use super::*; #[test] + fn company_contact_shortcuts_validate_and_old_profiles_keep_defaults() { + let value=serde_json::to_value(Branding::default()).unwrap(); + assert!(value.get("support_contacts").is_none());assert!(value.get("shortcuts").is_none()); + let mut brand:Branding=serde_json::from_value(value).unwrap(); + brand.support_contacts="Support: help@example.com\nPhone: +27 10 123 4567".into(); + brand.shortcuts=vec![BrandingShortcut{label:"Help centre".into(),url:"https://example.com/help".into()}]; + assert!(brand.validate().is_ok()); + for url in ["http://example.com","https://user:password@example.com","file:///C:/Windows","javascript:alert(1)"] { + brand.shortcuts[0].url=url.into();assert!(brand.validate().is_err()); + } + brand.shortcuts[0].url="https://example.com/help".into();brand.shortcuts[0].label="\n".into();assert!(brand.validate().is_err()); + brand.shortcuts[0].label="Help".into();brand.shortcuts=vec![brand.shortcuts[0].clone();9];assert!(brand.validate().is_err()); + brand.shortcuts.clear();brand.support_contacts="Hidden\0contact".into();assert!(brand.validate().is_err()); + } + #[test] fn public_keys_reject_small_order_points() { let mut identity = [0u8; 32]; identity[0] = 1; diff --git a/src/managed.rs b/src/managed.rs index b72d4dbf43b..0bc22fc3bf9 100644 --- a/src/managed.rs +++ b/src/managed.rs @@ -168,7 +168,7 @@ pub fn overview()->String { Ok(state)=>match state.display_profile(){ Ok(profile)=>{ let brand=if state.bootstrap.edition==Edition::Technician{&profile.technician}else{&profile.customer}; - serde_json::json!({"configured":true,"profile_valid":state.company_profile().is_ok(),"edition":state.bootstrap.edition,"enrolled":state.device_id.is_some(),"unattended":state.unattended_consent,"allow_unattended":profile.allow_unattended,"display_name":brand.display_name,"primary_color":brand.primary_color,"logo_svg":brand.logo_svg,"support_url":brand.support_url,"consent_text":brand.consent_text,"domain":profile.management_url}).to_string() + serde_json::json!({"configured":true,"profile_valid":state.company_profile().is_ok(),"edition":state.bootstrap.edition,"enrolled":state.device_id.is_some(),"unattended":state.unattended_consent,"allow_unattended":profile.allow_unattended,"display_name":brand.display_name,"primary_color":brand.primary_color,"logo_svg":brand.logo_svg,"support_url":brand.support_url,"support_contacts":brand.support_contacts,"shortcuts":brand.shortcuts,"consent_text":brand.consent_text,"domain":profile.management_url}).to_string() } Err(_)=>"{\"configured\":false}".into() }, From 33be56b272b14a7d3b7863024eb8c4fbd1d6d5d7 Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Fri, 2 Oct 2026 12:46:03 +0200 Subject: [PATCH 059/652] Add local company certificate signing integration with pinned artifact inputs --- deployment/windows/COMPANY-SIGNING.md | 47 +++++++++++++++++++ deployment/windows/Sign-CompanyArtifact.ps1 | 50 +++++++++++++++++++++ docs/ACCEPTANCE.md | 9 ++++ 3 files changed, 106 insertions(+) create mode 100644 deployment/windows/COMPANY-SIGNING.md create mode 100644 deployment/windows/Sign-CompanyArtifact.ps1 diff --git a/deployment/windows/COMPANY-SIGNING.md b/deployment/windows/COMPANY-SIGNING.md new file mode 100644 index 00000000000..b295a27274d --- /dev/null +++ b/deployment/windows/COMPANY-SIGNING.md @@ -0,0 +1,47 @@ +# Optional company signing + +Company signing is a local operator-controlled integration. The project does +not provide certificates, approval, shared signing infrastructure or private +keys. Default distribution consumes unchanged project-signed artifacts. + +`Sign-CompanyArtifact.ps1` signs a new copy of one locally prepared EXE or MSI. +Supply its independently verified input SHA-256, a new output path, the exact +company publisher, a certificate thumbprint in the local Personal store, a +Microsoft-signed Windows SDK SignTool path and the company's chosen HTTPS +RFC3161 timestamp endpoint. Select `LocalMachine` only if that is where the +worker account's certificate is configured. The script does not import or +export keys, install trust roots, accept remote scripts or modify its input. + +The certificate must have a private-key provider available to that account, +permit code signing and be current. Hardware providers may require their own +operator interaction. Provider-specific cloud signing is not implemented. +SignTool uses SHA-256 file and timestamp digests; subsequent verification pins +the trusted Authenticode publisher and certificate SHA-256 and requires a +timestamp. A mismatched input is rejected before signing. + +The output `.signing.json` records the original and signed hashes, publisher, +certificate fingerprint and timestamp endpoint. It is evidence for preparing +release metadata, not a signed release manifest or administrator approval. +Sign installed endpoint/agent binaries before regenerating installed-file +manifests and building packages, then sign the final package. Regenerate every +affected hash after signing. Embedded company icons or metadata require a +company build and corresponding source; signing alone does not customize them. + +For a new company using company-authored release metadata, explicitly provision +its chosen release-verification public key in the server/worker and endpoint +bootstrap. Set the signed release's publisher and certificate fingerprint to +the company values and include exact corresponding source. Import and approve +that release through management before the worker or updater consumes it. +Private release-signing keys remain on the company's release-authoring system. +Changing an existing deployment's pinned release issuer requires a separate +trusted migration; changing an environment variable does not authorize clients +to accept another issuer. Automated issuer migration remains incomplete. + +No actual company signature has been produced in this test environment. +PowerShell parsing and wrong-input-hash rejection were checked against a real +unsigned agent; its bytes stayed unchanged and no output was created. Valid +certificate/provider signing, timestamp verification, worker consumption and +signed install/update acceptance remain to be tested. + +See [Microsoft SignTool documentation](https://learn.microsoft.com/en-us/windows/win32/seccrypto/signtool) +for certificate-store selection and RFC3161 signing options. diff --git a/deployment/windows/Sign-CompanyArtifact.ps1 b/deployment/windows/Sign-CompanyArtifact.ps1 new file mode 100644 index 00000000000..6f50b842f13 --- /dev/null +++ b/deployment/windows/Sign-CompanyArtifact.ps1 @@ -0,0 +1,50 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory=$true)][string]$ArtifactPath, + [Parameter(Mandatory=$true)][string]$ExpectedSha256, + [Parameter(Mandatory=$true)][string]$OutputPath, + [Parameter(Mandatory=$true)][string]$CertificateThumbprint, + [ValidateSet('CurrentUser','LocalMachine')][string]$CertificateStore='CurrentUser', + [Parameter(Mandatory=$true)][string]$Publisher, + [Parameter(Mandatory=$true)][string]$SignToolPath, + [Parameter(Mandatory=$true)][string]$TimestampUrl +) +$ErrorActionPreference='Stop' +if ($ExpectedSha256 -notmatch '^[0-9a-fA-F]{64}$' -or $CertificateThumbprint -notmatch '^[0-9a-fA-F]{40}$') { throw 'Invalid artifact hash or certificate thumbprint.' } +$source=(Resolve-Path -LiteralPath $ArtifactPath).Path +$extension=[IO.Path]::GetExtension($source).ToLowerInvariant() +if ($extension -notin @('.exe','.msi')) { throw 'Only locally prepared EXE or MSI artifacts can be signed.' } +if ((Get-FileHash -LiteralPath $source -Algorithm SHA256).Hash -ine $ExpectedSha256) { throw 'Input artifact hash mismatch.' } +$destination=[IO.Path]::GetFullPath($OutputPath) +if ([IO.Path]::GetExtension($destination).ToLowerInvariant() -ne $extension -or (Test-Path -LiteralPath $destination) -or (Test-Path -LiteralPath ($destination+'.signing.json'))) { throw 'Use a new output filename with the same artifact extension.' } +if (-not (Test-Path -LiteralPath ([IO.Path]::GetDirectoryName($destination)) -PathType Container)) { throw 'Output directory must already exist.' } +$timestamp=[Uri]$TimestampUrl +if (-not $timestamp.IsAbsoluteUri -or $timestamp.Scheme -ne 'https' -or -not $timestamp.Host -or $timestamp.UserInfo -or $timestamp.Fragment) { throw 'Use an HTTPS RFC3161 timestamp endpoint without embedded credentials.' } +$tool=(Resolve-Path -LiteralPath $SignToolPath).Path +$toolSignature=Get-AuthenticodeSignature -LiteralPath $tool +if ($toolSignature.Status -ne 'Valid' -or $toolSignature.SignerCertificate.GetNameInfo([Security.Cryptography.X509Certificates.X509NameType]::SimpleName,$false) -ne 'Microsoft Corporation') { throw 'SignTool must be a trusted Microsoft-signed Windows SDK executable.' } +$certificate=Get-Item -LiteralPath ('Cert:\'+$CertificateStore+'\My\'+$CertificateThumbprint) +if (-not $certificate.HasPrivateKey -or $certificate.NotBefore.ToUniversalTime() -gt [DateTime]::UtcNow -or $certificate.NotAfter.ToUniversalTime() -le [DateTime]::UtcNow) { throw 'An active company certificate with a local private-key provider is required.' } +if (-not ($certificate.EnhancedKeyUsageList | Where-Object {$_.ObjectId -eq '1.3.6.1.5.5.7.3.3'})) { throw 'Certificate must permit code signing.' } +if ($certificate.GetNameInfo([Security.Cryptography.X509Certificates.X509NameType]::SimpleName,$false) -cne $Publisher) { throw 'Selected certificate does not match the explicit company publisher.' } +$hasher=[Security.Cryptography.SHA256]::Create() +try {$certificateHash=[BitConverter]::ToString($hasher.ComputeHash($certificate.RawData)).Replace('-','')} finally {$hasher.Dispose()} +$staging=$destination+'.'+[Guid]::NewGuid().ToString('N')+'.partial'+$extension +try { + [IO.File]::Copy($source,$staging,$false) + if ((Get-FileHash -LiteralPath $staging -Algorithm SHA256).Hash -ine $ExpectedSha256) { throw 'Staged artifact differs from the pinned input.' } + $arguments=@('sign','/sha1',$CertificateThumbprint,'/fd','SHA256','/tr',$timestamp.AbsoluteUri,'/td','SHA256') + if ($CertificateStore -eq 'LocalMachine') {$arguments+='/sm'} + & $tool @arguments $staging + if ($LASTEXITCODE -ne 0) { throw 'Company signing failed; no output artifact was published.' } + $null=& (Join-Path $PSScriptRoot 'Verify-Package.ps1') -Path $staging -Publisher $Publisher -CertificateSha256 $certificateHash + if (-not (Get-AuthenticodeSignature -LiteralPath $staging).TimeStamperCertificate) { throw 'Company signature requires a timestamp.' } + $hash=(Get-FileHash -LiteralPath $staging -Algorithm SHA256).Hash + [IO.File]::Move($staging,$destination) + $record=@{schema=1;input_sha256=$ExpectedSha256.ToLowerInvariant();sha256=$hash.ToLowerInvariant();publisher=$Publisher;publisher_certificate_sha256=$certificateHash.ToLowerInvariant();timestamp_url=$timestamp.AbsoluteUri;artifact=[IO.Path]::GetFileName($destination)} + [IO.File]::WriteAllText($destination+'.signing.json',($record | ConvertTo-Json)) + Write-Output ('Verified company-signed artifact: '+$destination) +} catch { + if (Test-Path -LiteralPath $staging) {Remove-Item -LiteralPath $staging -Force} + throw +} diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index ed0c5d33e15..3d55e58ee98 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -280,3 +280,12 @@ labels, excessive links and control characters. JavaScript syntax and native Rust parsing pass. Flutter compilation, rendering and installed synchronization remain unverified. Upgrade clients to compatible builds before publishing new nonempty branding fields; older strict clients will reject unfamiliar fields. + +Optional local company artifact signing now has an explicit pinned-input helper +using a company certificate's local private-key provider and Microsoft-signed +SDK SignTool. It signs separate copies, requires trusted publisher/certificate +verification and a timestamp, and records final hashes. PowerShell parsing and +wrong-input-hash rejection pass against a real unsigned agent with no source +change or output. No actual signature was made. Provider/certificate signing, +worker consumption, signed installs/updates and release-issuer migration remain +unverified or incomplete; see deployment/windows/COMPANY-SIGNING.md. From ca15b29d59a35ee95a2a44315c4361256fa080e1 Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Fri, 2 Oct 2026 12:55:31 +0200 Subject: [PATCH 060/652] Expose authenticated approved updates in technician edition --- docs/ACCEPTANCE.md | 9 ++++++++ .../pages/company_technician_page.dart | 4 ++++ product/agent/src/update.rs | 22 +++++++++++++++---- src/managed.rs | 8 +++++++ 4 files changed, 39 insertions(+), 4 deletions(-) diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index 3d55e58ee98..247d35e43e8 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -289,3 +289,12 @@ wrong-input-hash rejection pass against a real unsigned agent with no source change or output. No actual signature was made. Provider/certificate signing, worker consumption, signed installs/updates and release-issuer migration remain unverified or incomplete; see deployment/windows/COMPANY-SIGNING.md. + +Technician approved-release discovery now uses its authenticated Rust session +and shares signature, edition, channel, expiry and sequence validation with the +installer. The GUI receives only public release identity; bearer credentials +remain in Rust. Installed sequences are not offered again, and pause/maintenance +policy applies. All 29 workspace tests and native Rust parsing pass. Flutter +compilation and end-to-end authenticated discovery remain unverified. This is +discovery only: automatic GUI shutdown, session-safe installation handoff and +full interrupted-update recovery/rollback remain incomplete. diff --git a/flutter/lib/desktop/pages/company_technician_page.dart b/flutter/lib/desktop/pages/company_technician_page.dart index b72c5abbe62..b2d48bd1dab 100644 --- a/flutter/lib/desktop/pages/company_technician_page.dart +++ b/flutter/lib/desktop/pages/company_technician_page.dart @@ -24,6 +24,7 @@ class _CompanyTechnicianPageState extends State { bool _loggedIn = false; bool _busy = false; String _error = ''; + String? _approvedUpdate; Timer? _refreshTimer; Future _request(Map input) async { @@ -54,8 +55,10 @@ class _CompanyTechnicianPageState extends State { final loggedIn = status['logged_in'] == true; final devices = loggedIn ? await _request({'action': 'devices'}) as List : []; final history = loggedIn ? await _request({'action': 'history'}) as List : []; + final update = loggedIn ? await _request({'action': 'update-status'}) : null; if (mounted) setState(() { _company = company; _loggedIn = loggedIn; _devices = devices; _history = history; + _approvedUpdate = update is Map ? update['version'] as String? : null; }); } @@ -115,6 +118,7 @@ class _CompanyTechnicianPageState extends State { style: Theme.of(context).textTheme.headlineSmall?.copyWith(color: brandColor)), Text(_company['domain'] as String? ?? 'Company setup required'), CompanyContactLinks(company: _company), + if (_approvedUpdate != null) Text('Company-approved update available: $_approvedUpdate'), const SizedBox(height: 16), if (_busy) const LinearProgressIndicator(), if (_error.isNotEmpty) Padding(padding: const EdgeInsets.symmetric(vertical: 12), diff --git a/product/agent/src/update.rs b/product/agent/src/update.rs index 3f13ab471a7..6954de5905e 100644 --- a/product/agent/src/update.rs +++ b/product/agent/src/update.rs @@ -162,6 +162,23 @@ fn launch_update_helper(directory:&Path,release:&Release)->Result<()> { } impl AgentState { + pub async fn approved_update(&self,technician_token:Option<&str>)->Result> { + let profile=self.company_profile()?; + if profile.updates_paused || !maintenance_open(profile.maintenance_start_utc,profile.maintenance_end_utc,now()){return Ok(None);} + let (path,token)=if self.bootstrap.edition==Edition::Customer { + ("device/update",self.device_token.as_deref().context("Not enrolled")?) + }else{("user/update",technician_token.context("Technician authentication required")?)}; + let value:Value=self.client()?.get(self.endpoint(path)?).bearer_auth(token).send().await?.error_for_status()?.json().await?; + if value.is_null(){return Ok(None);} + let envelope:SignedEnvelope=serde_json::from_value(value)?; + let release:Release=envelope.verify(&public_key(&self.bootstrap.release_public_key)?)?; + release.validate(&self.bootstrap.edition,0,now())?; + ensure!(release.channel==profile.update_channel,"Wrong update channel"); + // An already installed release is not an installation request. + if release.sequence<=self.last_release_sequence{return Ok(None);} + validate_release(self,&envelope)?; + Ok(Some(envelope)) + } #[cfg(windows)] pub fn record_installation(&mut self,directory:&Path,envelope:&SignedEnvelope,repair:bool)->Result<()> { let activity=activity_file(directory)?;fs2::FileExt::try_lock_exclusive(&activity).context("Installation verification requires all sessions to close")?; @@ -287,10 +304,7 @@ impl AgentState { // An interrupted installation is deliberately not guessed successful. // Keep the signed receipt so recovery can verify the installed build. ensure!(!receipt_path.exists(),"Interrupted update requires recovery before another installation"); - let (path,token)=if self.bootstrap.edition==Edition::Customer {("device/update",self.device_token.as_deref().context("Not enrolled")?)}else{("user/update",technician_token.context("Technician authentication required")?)}; - let value:Value=self.client()?.get(self.endpoint(path)?).bearer_auth(token).send().await?.error_for_status()?.json().await?; - if value.is_null(){return Ok(false);} - let envelope:SignedEnvelope=serde_json::from_value(value)?; + let Some(envelope)=self.approved_update(technician_token).await? else{return Ok(false);}; let release=validate_release(self,&envelope)?; ensure!(release.edition!=Edition::Technician || (release.format=="msi" || (release.format=="exe" && release.installed_sha256.eq_ignore_ascii_case(&release.sha256))),"Technician updates require an MSI or a portable EXE with matching installed identity"); let folder=directory.join("updates").join(release.sequence.to_string());std::fs::create_dir_all(&folder)?; diff --git a/src/managed.rs b/src/managed.rs index 0bc22fc3bf9..6691a655db8 100644 --- a/src/managed.rs +++ b/src/managed.rs @@ -66,6 +66,14 @@ async fn technician_request_inner(request:&str)->ResultType { } if action=="devices" {return Ok(state.technician_inventory(&token).await?);} if action=="history" {return Ok(state.technician_history(&token).await?);} + if action=="update-status" { + let envelope=state.approved_update(Some(&token)).await?; + let memory=TECHNICIAN.lock().unwrap(); + if memory.generation!=generation || memory.login.is_none(){bail!("Login changed during update discovery");} + let Some(envelope)=envelope else{return Ok(serde_json::Value::Null);}; + let release:swan_agent::protocol::Release=envelope.verify(&protocol::public_key(&state.bootstrap.release_public_key)?)?; + return Ok(json!({"version":release.version,"sequence":release.sequence,"channel":release.channel})); + } if action=="connect" { let device=input["device_id"].as_str().ok_or_else(||anyhow!("Missing device"))?; let key=protocol::signing_key_from_hex(&protocol::random_token())?; From 2eec0089fb8100eec1ec55c065b8d685c86b11b9 Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Fri, 2 Oct 2026 12:58:10 +0200 Subject: [PATCH 061/652] Scope technician update exclusion to connection lifetimes --- docs/ACCEPTANCE.md | 9 +++++++++ product/agent/src/update.rs | 18 +++++++++++++++++- src/client/io_loop.rs | 11 +++++++++++ src/managed.rs | 9 +++------ 4 files changed, 40 insertions(+), 7 deletions(-) diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index 247d35e43e8..cda040f12e2 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -298,3 +298,12 @@ policy applies. All 29 workspace tests and native Rust parsing pass. Flutter compilation and end-to-end authenticated discovery remain unverified. This is discovery only: automatic GUI shutdown, session-safe installation handoff and full interrupted-update recovery/rollback remain incomplete. + +Outgoing managed connection loops now own their activity locks from before +transport establishment until loop exit. Closing one window does not permit an +update while another connection remains active, and the last disconnected +connection no longer keeps a process-wide lock forever. Proof generation also +checks the installation guard before consuming its ticket. All 30 workspace +tests pass, including simultaneous independent locks and Windows installer byte +lock exclusion. Native Rust parsing passes. Native connection lifecycle and +GUI shutdown/restart during signed installation still require end-to-end tests. diff --git a/product/agent/src/update.rs b/product/agent/src/update.rs index 6954de5905e..f61de179ad6 100644 --- a/product/agent/src/update.rs +++ b/product/agent/src/update.rs @@ -317,7 +317,7 @@ impl AgentState { let mut agent_release=release.clone();agent_release.sha256=release.agent_sha256.clone(); verify_publisher(&replacement_agent,&agent_release)?; let activity=activity_file(directory)?; - fs2::FileExt::try_lock_exclusive(&activity).context("Update deferred while a session or technician app is active")?; + fs2::FileExt::try_lock_exclusive(&activity).context("Update deferred while a session or connection attempt is active")?; ensure!(!directory.join("pending-install.json").exists(),"Company setup started while the update was downloading"); // Re-read consent and enrollment immediately before installation. let latest=AgentState::load(directory)?; @@ -418,4 +418,20 @@ try {$stream.Lock(0,1);[Console]::WriteLine('locked');[Console]::ReadLine()|Out- fs2::FileExt::try_lock_exclusive(&update).unwrap();assert!(lock_session(&folder).is_err());drop(update); assert!(lock_session(&folder).is_ok());std::fs::remove_dir_all(folder).unwrap(); } + #[test] + fn closing_one_connection_does_not_release_another_connections_update_lock() { + let folder=std::env::temp_dir().join(format!("swan-multiple-session-lock-{}",random_token())); + let first=lock_session(&folder).unwrap(); + let second=lock_session(&folder).unwrap(); + let update=activity_file(&folder).unwrap(); + assert!(fs2::FileExt::try_lock_exclusive(&update).is_err()); + drop(first); + assert!(fs2::FileExt::try_lock_exclusive(&update).is_err()); + drop(second); + fs2::FileExt::try_lock_exclusive(&update).unwrap(); + assert!(lock_session(&folder).is_err()); + drop(update); + assert!(lock_session(&folder).is_ok()); + std::fs::remove_dir_all(folder).unwrap(); + } } diff --git a/src/client/io_loop.rs b/src/client/io_loop.rs index c0eb7fb57fd..a7fbf0163d6 100644 --- a/src/client/io_loop.rs +++ b/src/client/io_loop.rs @@ -136,6 +136,17 @@ impl Remote { } pub async fn io_loop(&mut self, key: &str, token: &str, round: u32) { + // Cover connection establishment as well as the authenticated session. + // Each loop owns its lock, so retries and multiple windows cannot let + // installation race a connection or retain a lock after disconnection. + #[cfg(feature = "swan_custom")] + let _managed_activity = match swan_agent::lock_session(&swan_agent::state_directory()) { + Ok(activity) => activity, + Err(_) => { + self.handler.msgbox("error", "Company update in progress", "Wait for company setup or update recovery to finish before connecting.", ""); + return; + } + }; #[cfg(target_os = "windows")] let _file_clip_context_holder = { // `is_port_forward()` will not reach here, but we still check it for clarity. diff --git a/src/managed.rs b/src/managed.rs index 6691a655db8..04aed5f62d8 100644 --- a/src/managed.rs +++ b/src/managed.rs @@ -144,12 +144,9 @@ pub async fn renew(lease:&mut Lease)->ResultType<()> { } pub fn login(challenge:&str,target:&str,local_ticket:&str)->ResultType> { - // Hold the shared activity lock for the outgoing app process. Incoming leases - // hold their own locks. The updater acquires the exclusive side before install. - static ACTIVITY:std::sync::Mutex>=std::sync::Mutex::new(None); - let mut activity=ACTIVITY.lock().unwrap(); - if activity.is_none(){*activity=Some(swan_agent::lock_session(&swan_agent::state_directory())?);} - drop(activity); + // The outgoing connection loop retains its own shared lock through close. + // Recheck pending installation here as well before consuming a proof ticket. + let _activity=swan_agent::lock_session(&swan_agent::state_directory())?; let state=AgentState::load(&swan_agent::state_directory())?; if state.bootstrap.edition!=Edition::Technician {hbb_common::bail!("Technician edition required");} // The opaque handle identifies one window's pending request. It contains From 0f700d575f3cb8c8bcc1ba51e6c49bd170172a3a Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Fri, 2 Oct 2026 13:00:40 +0200 Subject: [PATCH 062/652] Stage verified executables before update replacement --- docs/ACCEPTANCE.md | 9 ++++++ product/agent/src/update.rs | 56 +++++++++++++++++++++++++++++++------ 2 files changed, 56 insertions(+), 9 deletions(-) diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index cda040f12e2..600bfa5390e 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -307,3 +307,12 @@ checks the installation guard before consuming its ticket. All 30 workspace tests pass, including simultaneous independent locks and Windows installer byte lock exclusion. Native Rust parsing passes. Native connection lifecycle and GUI shutdown/restart during signed installation still require end-to-end tests. + +Portable technician and configuration-agent replacements now write and flush a +complete, hash-verified staging file on the destination volume before replacing +the installed file. Replacement failures retain the previous destination and +remove staging files; Windows sharing conflicts use a bounded retry. All 31 +workspace tests pass, including hash rejection, successful replacement, unchanged +source and failed-replacement cleanup on Windows. This reduces partial-write +failure exposure; power-loss testing, signed executable replacement, MSI rollback +and complete interrupted-upgrade recovery remain unverified or incomplete. diff --git a/product/agent/src/update.rs b/product/agent/src/update.rs index f61de179ad6..f1c469a73f4 100644 --- a/product/agent/src/update.rs +++ b/product/agent/src/update.rs @@ -80,6 +80,34 @@ fn verify_publisher(path:&Path,release:&Release)->Result<()> { ensure!(status.success(),"Package publisher verification failed");Ok(()) } +#[cfg(any(windows,test))] +fn replace_verified_file(source:&Path,target:&Path,expected_hash:&str)->Result<()> { + use std::io::Write; + // Stage on the destination volume so replacement never streams bytes into + // the currently installed executable. Verify the exact bytes being staged. + let bytes=std::fs::read(source)?; + ensure!(digest(&bytes).eq_ignore_ascii_case(expected_hash),"Replacement executable hash mismatch"); + let temporary=target.parent().context("Missing replacement directory")?.join(format!("swan-replacement-{}.tmp",random_token())); + let mut file=std::fs::OpenOptions::new().create_new(true).write(true).open(&temporary)?; + let result=(||->Result<()> { + file.write_all(&bytes)?;file.sync_all()?;drop(file); + let deadline=std::time::Instant::now()+std::time::Duration::from_secs(30); + loop { + match crate::replace_state(&temporary,target) { + Ok(())=>return Ok(()), + Err(error)=>{ + let sharing_conflict=error.downcast_ref::().map(|e|matches!(e.raw_os_error(),Some(5|32|33))).unwrap_or(false); + if cfg!(windows) && sharing_conflict && std::time::Instant::now()Result { let script=package.parent().context("Missing update directory")?.join("Get-MsiInstallMode.ps1"); @@ -265,7 +293,7 @@ impl AgentState { if release.format=="msi" { let status=msi_install_command(&package)?.arg(format!("INSTALLFOLDER={}",directory.display())).status()?; ensure!(matches!(status.code(),Some(0|3010)),"Technician MSI installation failed; retain signed recovery receipt"); - }else{std::fs::copy(&package,&target)?;} + }else{replace_verified_file(&package,&target,&release.installed_sha256)?;} }else{ require_existing_application(&installed_target(directory,&latest.bootstrap.edition)?)?; let mut command=if release.format=="msi" {msi_install_command(&package)?}else{let mut c=std::process::Command::new(&package);c.args(["--silent-install","printer=0"]);c}; @@ -277,14 +305,7 @@ impl AgentState { if !agent_already_current { let previous=folder.join("previous-agent.exe"); if installed_agent.exists() && !previous.exists(){std::fs::copy(&installed_agent,&previous)?;} - let deadline=std::time::Instant::now()+std::time::Duration::from_secs(30); - loop { - match std::fs::copy(&helper,&installed_agent) { - Ok(_)=>break, - Err(error) if matches!(error.raw_os_error(),Some(5|32|33)) && std::time::Instant::now()std::thread::sleep(std::time::Duration::from_millis(100)), - Err(error)=>return Err(error).context("Agent replacement failed; retain update receipt"), - } - } + replace_verified_file(&helper,&installed_agent,&release.agent_sha256).context("Agent replacement failed; retain update receipt")?; } } verify_installed(directory,&release)?; @@ -363,6 +384,23 @@ fn verify_installed(directory:&Path,release:&Release)->Result<()> { mod tests { use super::*; #[test] + fn executable_replacement_verifies_staged_bytes_and_preserves_previous_on_failure(){ + let folder=std::env::temp_dir().join(format!("swan-replacement-test-{}",random_token())); + std::fs::create_dir(&folder).unwrap(); + let source=folder.join("source.exe");let target=folder.join("installed.exe"); + std::fs::write(&source,b"complete replacement").unwrap();std::fs::write(&target,b"previous executable").unwrap(); + assert!(replace_verified_file(&source,&target,&digest(b"wrong content")).is_err()); + assert_eq!(std::fs::read(&target).unwrap(),b"previous executable"); + replace_verified_file(&source,&target,&digest(b"complete replacement")).unwrap(); + assert_eq!(std::fs::read(&target).unwrap(),b"complete replacement"); + assert_eq!(std::fs::read(&source).unwrap(),b"complete replacement"); + let directory_target=folder.join("directory.exe");std::fs::create_dir(&directory_target).unwrap(); + assert!(replace_verified_file(&source,&directory_target,&digest(b"complete replacement")).is_err()); + assert!(directory_target.is_dir()); + assert!(!std::fs::read_dir(&folder).unwrap().any(|entry|entry.unwrap().file_name().to_string_lossy().starts_with("swan-replacement-"))); + std::fs::remove_dir_all(folder).unwrap(); + } + #[test] fn interrupted_setup_blocks_sessions_until_recovered(){ let folder=std::env::temp_dir().join(format!("swan-pending-install-{}",random_token())); std::fs::create_dir(&folder).unwrap();std::fs::write(folder.join("pending-install.json"),b"interrupted setup fixture").unwrap(); From 3c1873c0d56c8e79642ff28f9bd726dfe1be2b8d Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Fri, 2 Oct 2026 13:03:06 +0200 Subject: [PATCH 063/652] Hand off approved technician updates from the authenticated GUI --- docs/ACCEPTANCE.md | 9 +++++++++ .../lib/desktop/pages/company_technician_page.dart | 11 ++++++++++- product/agent/src/main.rs | 14 +++++++++++++- src/managed.rs | 14 ++++++++++++++ 4 files changed, 46 insertions(+), 2 deletions(-) diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index 600bfa5390e..76371e047b8 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -316,3 +316,12 @@ workspace tests pass, including hash rejection, successful replacement, unchange source and failed-replacement cleanup on Windows. This reduces partial-write failure exposure; power-loss testing, signed executable replacement, MSI rollback and complete interrupted-upgrade recovery remain unverified or incomplete. + +Technician refresh now requests automatic update handoff through its Rust login. +Open connection windows defer the request; native locks and signed recovery +receipts coordinate other processes. The GUI closes only after a verified helper +has been launched, and the helper restarts the verified technician executable +without credentials after success. Existing pending handoffs use bounded retry. +All 31 workspace tests and native Rust parsing pass. GUI compilation, signed +end-to-end shutdown/install/restart, offline pending recovery and full rollback +remain unverified or incomplete; this flow is not yet release acceptance. diff --git a/flutter/lib/desktop/pages/company_technician_page.dart b/flutter/lib/desktop/pages/company_technician_page.dart index b2d48bd1dab..3afc77a8553 100644 --- a/flutter/lib/desktop/pages/company_technician_page.dart +++ b/flutter/lib/desktop/pages/company_technician_page.dart @@ -1,5 +1,6 @@ import 'dart:convert'; import 'dart:async'; +import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'package:flutter_hbb/common.dart'; @@ -29,7 +30,8 @@ class _CompanyTechnicianPageState extends State { Future _request(Map input) async { final text = await bind.mainCompanyRequest(request: jsonEncode(input)) - .first.timeout(const Duration(seconds: 25)); + .first.timeout(input['action'] == 'update' + ? const Duration(minutes: 25) : const Duration(seconds: 25)); final response = jsonDecode(text) as Map; if (response['ok'] != true) { throw Exception(response['error'] ?? 'Company request failed'); @@ -55,6 +57,13 @@ class _CompanyTechnicianPageState extends State { final loggedIn = status['logged_in'] == true; final devices = loggedIn ? await _request({'action': 'devices'}) as List : []; final history = loggedIn ? await _request({'action': 'history'}) as List : []; + if (mounted && loggedIn && Platform.isWindows) { + final result = await _request({'action': 'update'}) as Map; + // Rust writes the signed recovery receipt and starts the verified helper + // before acknowledging handoff. New sessions are then blocked natively. + // Run this even without new metadata so an interrupted handoff can retry. + if (result['handed_off'] == true) exit(0); + } final update = loggedIn ? await _request({'action': 'update-status'}) : null; if (mounted) setState(() { _company = company; _loggedIn = loggedIn; _devices = devices; _history = history; diff --git a/product/agent/src/main.rs b/product/agent/src/main.rs index 3eb02e07fba..100488d0102 100644 --- a/product/agent/src/main.rs +++ b/product/agent/src/main.rs @@ -72,7 +72,19 @@ async fn main()->Result<()> { bail!("Configuration task restart failed; inspect the protected update log and resume recovery"); } } - println!("Installed release verified: {}",outcome?); + let installed=outcome?; + if !customer && installed { + use std::os::windows::process::CommandExt; + // Installation verified the pinned executable and publisher. + // Restart with company state only; technicians sign in again. + std::process::Command::new(directory.join("SwanRemoteSupport-Technician.exe")) + .env("SWAN_STATE_DIR",&directory).env_remove("SWAN_TECHNICIAN_TOKEN") + .env_remove("SWAN_SESSION_GRANT").env_remove("SWAN_SESSION_PROOF_KEY") + .creation_flags(0x08000000).stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()).stderr(std::process::Stdio::null()).spawn() + .context("Updated technician application could not restart")?; + } + println!("Installed release verified: {}",installed); } #[cfg(windows)] "record-installation"=>{ diff --git a/src/managed.rs b/src/managed.rs index 04aed5f62d8..82ba18ad817 100644 --- a/src/managed.rs +++ b/src/managed.rs @@ -66,6 +66,20 @@ async fn technician_request_inner(request:&str)->ResultType { } if action=="devices" {return Ok(state.technician_inventory(&token).await?);} if action=="history" {return Ok(state.technician_history(&token).await?);} + #[cfg(all(windows,feature="flutter"))] + if action=="update" { + // Include disconnected/reconnecting windows, not just authenticated + // sessions. The file lock and pending receipt guard other processes. + if !crate::flutter::sessions::get_sessions().is_empty(){return Ok(json!({"handed_off":false}));} + {let memory=TECHNICIAN.lock().unwrap();if memory.generation!=generation || memory.login.is_none(){bail!("Login changed before update");}} + let mut state=state; + let handed_off=state.update(&directory,Some(&token)).await?; + if handed_off { + let mut memory=TECHNICIAN.lock().unwrap(); + memory.generation=memory.generation.wrapping_add(1);memory.login=None;memory.tickets.clear(); + } + return Ok(json!({"handed_off":handed_off})); + } if action=="update-status" { let envelope=state.approved_update(Some(&token)).await?; let memory=TECHNICIAN.lock().unwrap(); From 54e56f4704fa059b53e2e9c6a0e74170eacc4b69 Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Fri, 2 Oct 2026 13:05:25 +0200 Subject: [PATCH 064/652] Revalidate company approval and update policy after downloads --- docs/ACCEPTANCE.md | 9 +++++++++ product/agent/src/lib.rs | 18 ++++++++++++++++++ product/agent/src/update.rs | 16 +++++++++++++--- 3 files changed, 40 insertions(+), 3 deletions(-) diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index 76371e047b8..66e62f7b086 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -325,3 +325,12 @@ without credentials after success. Existing pending handoffs use bounded retry. All 31 workspace tests and native Rust parsing pass. GUI compilation, signed end-to-end shutdown/install/restart, offline pending recovery and full rollback remain unverified or incomplete; this flow is not yet release acceptance. + +New update installation now refreshes signed company configuration and queries +current release approval after downloads. Withdrawn or changed selections defer +handoff; the newest saved pause/maintenance policy is checked again under session +exclusion before writing the receipt. All 32 workspace tests pass, including a +persisted signed-policy change that pauses or moves the window after an older +snapshot was eligible. Real HTTP withdrawal during downloads and signed Windows +installation still require end-to-end evidence. Existing installation receipts +retain recovery semantics rather than starting a new policy-controlled upgrade. diff --git a/product/agent/src/lib.rs b/product/agent/src/lib.rs index fdf3e47adcf..9dbcb3d1f98 100644 --- a/product/agent/src/lib.rs +++ b/product/agent/src/lib.rs @@ -274,6 +274,24 @@ mod tests { AgentState{bootstrap:Bootstrap{schema:1,edition:Edition::Customer,company_id:profile.company_id.clone(),management_url:profile.management_url.clone(),profile_public_key:public.clone(),release_public_key:public},profile:SignedEnvelope::sign(&profile,&key).unwrap(),accepted_revision:1,device_id:Some("device".into()),device_token:Some(random_token()),unattended_consent:true,consent_revision:0,last_release_sequence:0} } #[test] + fn refreshed_update_policy_can_pause_a_previously_eligible_installation() { + let directory=std::env::temp_dir().join(format!("swan-update-policy-{}",random_token())); + let key=SigningKey::from_bytes(&[7;32]);let mut state=fixture(); + let mut profile=state.company_profile().unwrap();profile.updates_paused=false; + state.profile=SignedEnvelope::sign(&profile,&key).unwrap();state.save(&directory).unwrap(); + let downloading=state.clone();assert!(update::update_policy_open(&downloading).unwrap()); + profile.revision+=1;profile.updates_paused=true; + state.accepted_revision=profile.revision;state.profile=SignedEnvelope::sign(&profile,&key).unwrap();state.save(&directory).unwrap(); + assert!(!update::update_policy_open(&AgentState::load(&directory).unwrap()).unwrap()); + profile.revision+=1;profile.updates_paused=false; + let hour=((now()/3600)%24) as u8; + profile.maintenance_start_utc=(hour+2)%24;profile.maintenance_end_utc=(hour+3)%24; + state.accepted_revision=profile.revision;state.profile=SignedEnvelope::sign(&profile,&key).unwrap();state.save(&directory).unwrap(); + assert!(!update::update_policy_open(&AgentState::load(&directory).unwrap()).unwrap()); + assert!(update::update_policy_open(&downloading).unwrap()); + std::fs::remove_dir_all(directory).unwrap(); + } + #[test] fn repair_bootstrap_cannot_change_company_edition_or_trust() { let state=fixture();let original=state.bootstrap.clone(); assert!(state.validate_installer_bootstrap(&original).is_ok()); diff --git a/product/agent/src/update.rs b/product/agent/src/update.rs index f1c469a73f4..41eb7691a5c 100644 --- a/product/agent/src/update.rs +++ b/product/agent/src/update.rs @@ -80,6 +80,11 @@ fn verify_publisher(path:&Path,release:&Release)->Result<()> { ensure!(status.success(),"Package publisher verification failed");Ok(()) } +pub(crate) fn update_policy_open(state:&AgentState)->Result { + let profile=state.company_profile()?; + Ok(!profile.updates_paused && maintenance_open(profile.maintenance_start_utc,profile.maintenance_end_utc,now())) +} + #[cfg(any(windows,test))] fn replace_verified_file(source:&Path,target:&Path,expected_hash:&str)->Result<()> { use std::io::Write; @@ -192,7 +197,7 @@ fn launch_update_helper(directory:&Path,release:&Release)->Result<()> { impl AgentState { pub async fn approved_update(&self,technician_token:Option<&str>)->Result> { let profile=self.company_profile()?; - if profile.updates_paused || !maintenance_open(profile.maintenance_start_utc,profile.maintenance_end_utc,now()){return Ok(None);} + if !update_policy_open(self)?{return Ok(None);} let (path,token)=if self.bootstrap.edition==Edition::Customer { ("device/update",self.device_token.as_deref().context("Not enrolled")?) }else{("user/update",technician_token.context("Technician authentication required")?)}; @@ -319,8 +324,7 @@ impl AgentState { ensure!(!directory.join("pending-install.json").exists(),"Company setup recovery is required before automatic updates"); if directory.join("pending-update.json").exists(){return self.resume_pending_update(directory,false);} require_existing_application(&installed_target(directory,&self.bootstrap.edition)?)?; - let profile=self.company_profile()?; - if profile.updates_paused || !maintenance_open(profile.maintenance_start_utc,profile.maintenance_end_utc,now()){return Ok(false);} + if !update_policy_open(self)?{return Ok(false);} let receipt_path=directory.join("pending-update.json"); // An interrupted installation is deliberately not guessed successful. // Keep the signed receipt so recovery can verify the installed build. @@ -337,11 +341,17 @@ impl AgentState { download(&release.agent_url,&release.agent_sha256,&replacement_agent).await?; let mut agent_release=release.clone();agent_release.sha256=release.agent_sha256.clone(); verify_publisher(&replacement_agent,&agent_release)?; + // Downloads may take minutes. Require a fresh signed policy and + // current server approval before creating an installation receipt. + self.sync().await?;self.save(directory)?; + let Some(current)=self.approved_update(technician_token).await? else{return Ok(false);}; + if current.payload!=envelope.payload || current.signature!=envelope.signature{return Ok(false);} let activity=activity_file(directory)?; fs2::FileExt::try_lock_exclusive(&activity).context("Update deferred while a session or connection attempt is active")?; ensure!(!directory.join("pending-install.json").exists(),"Company setup started while the update was downloading"); // Re-read consent and enrollment immediately before installation. let latest=AgentState::load(directory)?; + if !update_policy_open(&latest)?{return Ok(false);} // Removal during download must not be turned into a fresh install. require_existing_application(&installed_target(directory,&latest.bootstrap.edition)?)?; validate_release(&latest,&envelope)?; From 8f5a8433eed3a461603af7f3b51d9e188b20ad17 Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Fri, 2 Oct 2026 13:06:57 +0200 Subject: [PATCH 065/652] Allow signed technician update recovery before network login --- docs/ACCEPTANCE.md | 10 ++++++++++ .../lib/desktop/pages/company_technician_page.dart | 13 ++++++++++++- src/managed.rs | 8 ++++++++ 3 files changed, 30 insertions(+), 1 deletion(-) diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index 66e62f7b086..ad2451df25f 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -334,3 +334,13 @@ persisted signed-policy change that pauses or moves the window after an older snapshot was eligible. Real HTTP withdrawal during downloads and signed Windows installation still require end-to-end evidence. Existing installation receipts retain recovery semantics rather than starting a new policy-controlled upgrade. + +Technician refresh now attempts pending signed-installation recovery before +network sync, login or inventory. The local native action cannot choose a new +release; it retains receipt signature, sequence, staged helper, publisher and +session-exclusion checks, and runs blocking verification outside the async +executor. Cached branding is displayed before these operations. Native Rust +parsing passes; the existing 32 component tests remain the last workspace +evidence. This new GUI path still needs native compilation and a real offline +interrupted-installation test. The QEMU process is confirmed live; no completed +clean Windows installation is demonstrated and VirtualBox remains uninstalled. diff --git a/flutter/lib/desktop/pages/company_technician_page.dart b/flutter/lib/desktop/pages/company_technician_page.dart index 3afc77a8553..b84482ab434 100644 --- a/flutter/lib/desktop/pages/company_technician_page.dart +++ b/flutter/lib/desktop/pages/company_technician_page.dart @@ -30,7 +30,7 @@ class _CompanyTechnicianPageState extends State { Future _request(Map input) async { final text = await bind.mainCompanyRequest(request: jsonEncode(input)) - .first.timeout(input['action'] == 'update' + .first.timeout(input['action'] == 'update' || input['action'] == 'resume-update' ? const Duration(minutes: 25) : const Duration(seconds: 25)); final response = jsonDecode(text) as Map; if (response['ok'] != true) { @@ -48,6 +48,13 @@ class _CompanyTechnicianPageState extends State { } Future _refresh() async { + final cachedCompany = jsonDecode(await bind.mainGetCommon(key: 'company-overview')) + as Map; + if (mounted) setState(() { _company = cachedCompany; }); + if (Platform.isWindows) { + final recovery = await _request({'action': 'resume-update'}) as Map; + if (recovery['handed_off'] == true) exit(0); + } // Retain cached branding if the server is offline; authenticated operations // still require a current signed policy on the native side. try { await _request({'action': 'sync'}); } catch (_) {} @@ -55,6 +62,10 @@ class _CompanyTechnicianPageState extends State { as Map; final status = await _request({'action': 'status'}) as Map; final loggedIn = status['logged_in'] == true; + if (mounted) setState(() { + _company = company; _loggedIn = loggedIn; + if (!loggedIn) { _devices = []; _history = []; _approvedUpdate = null; } + }); final devices = loggedIn ? await _request({'action': 'devices'}) as List : []; final history = loggedIn ? await _request({'action': 'history'}) as List : []; if (mounted && loggedIn && Platform.isWindows) { diff --git a/src/managed.rs b/src/managed.rs index 82ba18ad817..5d12a800dd0 100644 --- a/src/managed.rs +++ b/src/managed.rs @@ -33,6 +33,14 @@ async fn technician_request_inner(request:&str)->ResultType { let directory=swan_agent::state_directory(); let state=AgentState::load_for_refresh(&directory)?; if state.bootstrap.edition!=Edition::Technician {bail!("Technician edition required");} + #[cfg(all(windows,feature="flutter"))] + if action=="resume-update" { + // Recovery can finish an already authorized installation offline. It + // cannot select a new release and does not require a reusable login. + if !directory.join("pending-update.json").exists() || !crate::flutter::sessions::get_sessions().is_empty(){return Ok(json!({"handed_off":false}));} + let handed_off=hbb_common::tokio::task::spawn_blocking(move ||state.resume_pending_update(&directory,false)).await??; + return Ok(json!({"handed_off":handed_off})); + } if action=="sync" { let mut state=state;state.sync().await?;state.save(&directory)?; return Ok(json!({"revision":state.accepted_revision})); From b4721ab836323fcac7fe2a12916c5da01b91d551 Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Fri, 2 Oct 2026 13:09:45 +0200 Subject: [PATCH 066/652] Keep Windows update verification off the async executor --- docs/ACCEPTANCE.md | 6 ++++++ product/agent/src/update.rs | 34 ++++++++++++++++++++++++---------- 2 files changed, 30 insertions(+), 10 deletions(-) diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index ad2451df25f..e1ad06afd5f 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -344,3 +344,9 @@ parsing passes; the existing 32 component tests remain the last workspace evidence. This new GUI path still needs native compilation and a real offline interrupted-installation test. The QEMU process is confirmed live; no completed clean Windows installation is demonstrated and VirtualBox remains uninstalled. + +Automatic updater Windows compatibility checks, publisher verification, pending +recovery and final receipt/helper handoff now run in blocking workers. Final +session exclusion is acquired and released wholly within one worker, without +holding its file lock across an async wait. All 32 workspace tests pass. Native +GUI executor responsiveness and signed update handoff remain unverified. diff --git a/product/agent/src/update.rs b/product/agent/src/update.rs index 41eb7691a5c..afe08dc0952 100644 --- a/product/agent/src/update.rs +++ b/product/agent/src/update.rs @@ -80,6 +80,12 @@ fn verify_publisher(path:&Path,release:&Release)->Result<()> { ensure!(status.success(),"Package publisher verification failed");Ok(()) } +#[cfg(windows)] +async fn verify_downloaded_publisher(path:&Path,release:&Release)->Result<()> { + let path=path.to_owned();let release=release.clone(); + tokio::task::spawn_blocking(move ||verify_publisher(&path,&release)).await.context("Publisher verification task failed")? +} + pub(crate) fn update_policy_open(state:&AgentState)->Result { let profile=state.company_profile()?; Ok(!profile.updates_paused && maintenance_open(profile.maintenance_start_utc,profile.maintenance_end_utc,now())) @@ -322,7 +328,10 @@ impl AgentState { #[cfg(not(windows))] {let _=(directory,technician_token);anyhow::bail!("Endpoint updates require Windows");} #[cfg(windows)] { ensure!(!directory.join("pending-install.json").exists(),"Company setup recovery is required before automatic updates"); - if directory.join("pending-update.json").exists(){return self.resume_pending_update(directory,false);} + if directory.join("pending-update.json").exists(){ + let state=self.clone();let directory=directory.to_owned(); + return tokio::task::spawn_blocking(move ||state.resume_pending_update(&directory,false)).await.context("Update recovery task failed")?; + } require_existing_application(&installed_target(directory,&self.bootstrap.edition)?)?; if !update_policy_open(self)?{return Ok(false);} let receipt_path=directory.join("pending-update.json"); @@ -333,27 +342,30 @@ impl AgentState { let release=validate_release(self,&envelope)?; ensure!(release.edition!=Edition::Technician || (release.format=="msi" || (release.format=="exe" && release.installed_sha256.eq_ignore_ascii_case(&release.sha256))),"Technician updates require an MSI or a portable EXE with matching installed identity"); let folder=directory.join("updates").join(release.sequence.to_string());std::fs::create_dir_all(&folder)?; - verify_compatibility(&folder,&release)?; + let check_folder=folder.clone();let check_release=release.clone(); + tokio::task::spawn_blocking(move ||verify_compatibility(&check_folder,&check_release)).await.context("Compatibility verification task failed")??; let package=folder.join(format!("SwanRemoteSupport-install.{}",release.format)); download(&release.artifact_url,&release.sha256,&package).await?; - verify_publisher(&package,&release)?; + verify_downloaded_publisher(&package,&release).await?; let replacement_agent=folder.join("swan-agent.exe"); download(&release.agent_url,&release.agent_sha256,&replacement_agent).await?; let mut agent_release=release.clone();agent_release.sha256=release.agent_sha256.clone(); - verify_publisher(&replacement_agent,&agent_release)?; + verify_downloaded_publisher(&replacement_agent,&agent_release).await?; // Downloads may take minutes. Require a fresh signed policy and // current server approval before creating an installation receipt. self.sync().await?;self.save(directory)?; let Some(current)=self.approved_update(technician_token).await? else{return Ok(false);}; if current.payload!=envelope.payload || current.signature!=envelope.signature{return Ok(false);} - let activity=activity_file(directory)?; + let directory=directory.to_owned(); + let prepared=tokio::task::spawn_blocking(move ||->Result> { + let activity=activity_file(&directory)?; fs2::FileExt::try_lock_exclusive(&activity).context("Update deferred while a session or connection attempt is active")?; ensure!(!directory.join("pending-install.json").exists(),"Company setup started while the update was downloading"); // Re-read consent and enrollment immediately before installation. - let latest=AgentState::load(directory)?; - if !update_policy_open(&latest)?{return Ok(false);} + let latest=AgentState::load(&directory)?; + if !update_policy_open(&latest)?{return Ok(None);} // Removal during download must not be turned into a fresh install. - require_existing_application(&installed_target(directory,&latest.bootstrap.edition)?)?; + require_existing_application(&installed_target(&directory,&latest.bootstrap.edition)?)?; validate_release(&latest,&envelope)?; let receipt=Receipt{release:envelope,previous_sequence:latest.last_release_sequence,phase:"installing".into()}; let mut options=std::fs::OpenOptions::new();options.create_new(true).write(true); @@ -361,8 +373,10 @@ impl AgentState { let mut file=options.open(&receipt_path)?;file.write_all(&serde_json::to_vec(&receipt)?)?;file.sync_all()?;drop(file); // The signed replacement runs from staging. This caller must exit so // Windows releases the old executable before it is overwritten. - launch_update_helper(directory,&release)?; - *self=latest;Ok(true) + launch_update_helper(&directory,&release)?; + Ok(Some(latest)) + }).await.context("Update handoff task failed")??; + if let Some(latest)=prepared {*self=latest;Ok(true)}else{Ok(false)} } } } From fb9d2c4bee9c1ef8bf093b3a8a5917dc5ba18908 Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Fri, 2 Oct 2026 13:13:34 +0200 Subject: [PATCH 067/652] Allow administrators to withdraw release approval --- docs/ACCEPTANCE.md | 8 ++++++ product/management/src/service.rs | 46 ++++++++++++++++++++++++++++++- product/management/web/app.js | 2 +- 3 files changed, 54 insertions(+), 2 deletions(-) diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index e1ad06afd5f..ceedb70f01f 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -350,3 +350,11 @@ recovery and final receipt/helper handoff now run in blocking workers. Final session exclusion is acquired and released wholly within one worker, without holding its file lock across an async wait. All 32 workspace tests pass. Native GUI executor responsiveness and signed update handoff remain unverified. + +Administrators can now withdraw individual release approval from the release +page or DELETE its approval endpoint. Approval withdrawal and its audit event +commit atomically; technicians are denied. Eight management tests pass, including +withdrawal stopping update selection, edition/channel separation, pause and +zero-percent rollout. Web JavaScript syntax passes. Browser interaction and real +HTTP withdrawal during client downloads remain unverified. Previously generated +bundles and already-started installation recovery are not removed by withdrawal. diff --git a/product/management/src/service.rs b/product/management/src/service.rs index 684e98485d6..1561fa71ee4 100644 --- a/product/management/src/service.rs +++ b/product/management/src/service.rs @@ -143,7 +143,7 @@ pub fn router(store:Shared)->Router { .route("/api/v1/audit",get(audit)) .route("/api/v1/sessions",get(session_history)) .route("/api/v1/releases",get(releases).post(import_release)) - .route("/api/v1/releases/{id}/approve",post(approve_release)) + .route("/api/v1/releases/{id}/approve",post(approve_release).delete(withdraw_release)) .route("/api/v1/device/update",get(device_update)) .route("/api/v1/user/update",get(technician_update)) .route("/api/v1/workers",post(create_worker)) @@ -509,6 +509,43 @@ struct BuildResult { success:bool, artifact_url:String,sha256:String,log:String #[cfg(test)] mod worker_completion_tests { use super::*; + #[tokio::test] + async fn release_withdrawal_requires_admin_and_stops_update_selection() { + let directory=std::env::temp_dir().join(format!("swan-release-withdrawal-{}",random_token())); + let store=std::sync::Arc::new(Store::open(&directory).unwrap()); + let public=STANDARD.encode(store.key.verifying_key().as_bytes()); + let profile=CompanyProfile{schema:1,company_id:"release-test".into(),revision:1,issued_at:now()-1,expires_at:now()+3600,management_url:"https://support.example.com".into(),rendezvous:"support.example.com".into(),relay:"support.example.com".into(),transport_public_key:public,customer:Default::default(),technician:Default::default(),allow_unattended:false,updates_paused:false,rollout_percent:100,maintenance_start_utc:0,maintenance_end_utc:0,update_channel:"stable".into(),next_profile_public_key:None}; + let release=Release{schema:1,product:"swan-remote-support".into(),version:"2.0.0".into(),sequence:1,edition:Edition::Technician,architecture:"x86_64".into(),channel:"stable".into(),expires_at:now()+3600,artifact_url:"https://releases.example/install.msi".into(),sha256:"a".repeat(64),installed_sha256:"b".repeat(64),installed_files:vec![],agent_url:"https://releases.example/agent.exe".into(),agent_sha256:"c".repeat(64),publisher:"Example".into(),publisher_certificate_sha256:"d".repeat(64),windows_versions:vec!["windows_11".into()],source_url:"https://releases.example/source.tar.gz".into(),format:"msi".into()}; + let envelope=SignedEnvelope::sign(&release,&store.key).unwrap(); + let admin_token=random_token();let technician_token=random_token(); + { + let db=store.db.lock().unwrap(); + db.execute("INSERT INTO profile VALUES(1,?1)",[serde_json::to_string(&profile).unwrap()]).unwrap(); + for (id,role,credential) in [("admin","admin",&admin_token),("technician","technician",&technician_token)] { + db.execute("INSERT INTO users(id,username,password_hash,totp_secret,role) VALUES(?1,?1,'unused','unused',?2)",params![id,role]).unwrap(); + db.execute("INSERT INTO sessions VALUES(?1,?2,?3)",params![digest(credential),id,now()+3600]).unwrap(); + } + db.execute("INSERT INTO releases VALUES('release',?1,?2,1)",params![serde_json::to_string(&release).unwrap(),serde_json::to_string(&envelope).unwrap()]).unwrap(); + } + let headers=|credential:&str| {let mut h=HeaderMap::new();h.insert(header::AUTHORIZATION,format!("Bearer {credential}").parse().unwrap());h}; + assert!(!select_update(&store,"technician",Edition::Technician).ok().unwrap().0.is_null()); + assert!(select_update(&store,"device",Edition::Customer).ok().unwrap().0.is_null()); + let save_policy=|policy:&CompanyProfile| {store.db.lock().unwrap().execute("UPDATE profile SET body=?1 WHERE id=1",[serde_json::to_string(policy).unwrap()]).unwrap();}; + let mut paused=profile.clone();paused.updates_paused=true;save_policy(&paused); + assert!(select_update(&store,"technician",Edition::Technician).ok().unwrap().0.is_null()); + let mut staged=profile.clone();staged.rollout_percent=0;save_policy(&staged); + assert!(select_update(&store,"technician",Edition::Technician).ok().unwrap().0.is_null()); + let mut channel=profile.clone();channel.update_channel="test".into();save_policy(&channel); + assert!(select_update(&store,"technician",Edition::Technician).ok().unwrap().0.is_null()); + save_policy(&profile); + assert!(withdraw_release(State(store.clone()),headers(&technician_token),Path("release".into())).await.is_err()); + assert!(!select_update(&store,"technician",Edition::Technician).ok().unwrap().0.is_null()); + assert!(withdraw_release(State(store.clone()),headers(&admin_token),Path("release".into())).await.is_ok()); + assert!(select_update(&store,"technician",Edition::Technician).ok().unwrap().0.is_null()); + assert!(withdraw_release(State(store.clone()),headers(&admin_token),Path("missing".into())).await.is_err()); + let audit:i64=store.db.lock().unwrap().query_row("SELECT COUNT(*) FROM audit WHERE event='release.withdrawn'",[],|r|r.get(0)).unwrap();assert_eq!(audit,1); + drop(store);std::fs::remove_dir_all(directory).unwrap(); + } #[test] fn uploaded_artifact_retry_preserves_bytes_and_owner() { let directory=std::env::temp_dir().join(format!("swan-upload-retry-{}",random_token())); @@ -546,6 +583,13 @@ mod worker_completion_tests { drop(store);std::fs::remove_dir_all(directory).unwrap(); } } +async fn withdraw_release(State(s):State,headers:HeaderMap,Path(id):Path)->ApiResult { + let user=s.user(&headers,true)?; + let mut db=s.db.lock().unwrap();let tx=db.transaction()?; + if tx.execute("UPDATE releases SET approved=0 WHERE id=?1",[&id])?!=1{return Err(bad("Unknown release"));} + tx.execute("INSERT INTO audit(at,actor,event,target) VALUES(?1,?2,'release.withdrawn',?3)",params![now(),user,id])?; + tx.commit()?;Ok(Json(json!({"ok":true}))) +} async fn revoke_group_access(State(s):State,headers:HeaderMap,Path((group,user)):Path<(String,String)>)->ApiResult { let actor=s.user(&headers,true)?; if group.len()>64 || group.is_empty(){return Err(bad("Invalid group"));} diff --git a/product/management/web/app.js b/product/management/web/app.js index 3e1919c4bd4..95c99a1bcf0 100644 --- a/product/management/web/app.js +++ b/product/management/web/app.js @@ -64,7 +64,7 @@ const screens={ }, async users(){title('Technicians and access','Every technician needs MFA and explicit device-group access.');const users=await api('users');content.innerHTML=`
${users.map(u=>``).join('')}
UsernameRoleIdentifierAccount access
${escapeText(u.username)}${u.disabled?' (disabled)':''}${escapeText(u.role)}${escapeText(u.id)}${u.disabled?'Disabled':``}

Add technician

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

Manage group access

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

Import signed release

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

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

`).join('')||'No releases imported.'}
`;bindForm('release',async v=>{await api('releases','POST',{public_key:v.get('public_key'),envelope:JSON.parse(v.get('envelope'))});await view('releases');});for(const button of document.querySelectorAll('[data-release]'))button.onclick=async()=>{try{await api('releases/'+encodeURIComponent(button.dataset.release)+'/approve','POST');await view('releases');}catch(error){showError(error);}}; + async releases(){title('Software releases','Import verified project releases, then approve them for company rollout.');const rows=await api('releases');content.innerHTML=`

Import signed release

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

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

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

Generate installer bundle

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

Connect a Windows worker

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

${rows.map(r=>``).join('')}
JobStatusResult
${escapeText(r.id)}${escapeText(r.state)}
${escapeText(r.result)}
${r.state==='completed'?`Website link: /api/v1/downloads/${escapeText(r.id)}`:''}
`;bindForm('build',async v=>{await api('builds','POST',{release_id:v.get('release_id')});await view('builds');});for(const button of document.querySelectorAll('[data-download]'))button.onclick=async()=>{try{const response=await fetch('/api/v1/downloads/'+encodeURIComponent(button.dataset.download),{headers:{Authorization:'Bearer '+accessToken},redirect:'error'});if(!response.ok)throw new Error('Bundle download failed');const url=URL.createObjectURL(await response.blob());const link=document.createElement('a');link.href=url;link.download='SwanRemoteSupport-company.zip';link.click();setTimeout(()=>URL.revokeObjectURL(url),1000);}catch(error){showError(error);}};document.querySelector('#worker').onclick=async()=>{try{document.querySelector('#worker_secret').textContent=JSON.stringify(await api('workers','POST'),null,2);}catch(error){showError(error);}}; }, From abccdfadec28050f1ea8301706a6aec5314caf60 Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Fri, 2 Oct 2026 13:15:47 +0200 Subject: [PATCH 068/652] Stop installer jobs and downloads after release withdrawal --- docs/ACCEPTANCE.md | 8 ++++++++ product/management/src/service.rs | 23 +++++++++++++++++++++-- 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index ceedb70f01f..dce2aaa336e 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -358,3 +358,11 @@ withdrawal stopping update selection, edition/channel separation, pause and zero-percent rollout. Web JavaScript syntax passes. Browser interaction and real HTTP withdrawal during client downloads remain unverified. Previously generated bundles and already-started installation recovery are not removed by withdrawal. + +Release withdrawal now also fails matching queued/running/uploaded build jobs +within its transaction and refuses new downloads of completed bundles while +approval is absent. Existing files are retained; already downloaded copies and +started installation recovery are unaffected. Build creation keeps its approval +check and queue insertion under the same database lock. All eight management +tests pass, including three job states and completed-bundle download denial. +Worker/network race fault injection and browser behavior remain unverified. diff --git a/product/management/src/service.rs b/product/management/src/service.rs index 1561fa71ee4..60acd1953b3 100644 --- a/product/management/src/service.rs +++ b/product/management/src/service.rs @@ -487,9 +487,10 @@ struct BuildRequest { release_id:String } async fn create_build(State(s):State,headers:HeaderMap,Json(input):Json)->ApiResult { let actor=s.user(&headers,true)?;let mut profile=s.profile()?; profile.issued_at=now();profile.expires_at=now()+7*86400; - let envelope:String=s.db.lock().unwrap().query_row("SELECT envelope FROM releases WHERE id=?1 AND approved=1",[&input.release_id],|r|r.get(0))?; + let db=s.db.lock().unwrap(); + let envelope:String=db.query_row("SELECT envelope FROM releases WHERE id=?1 AND approved=1",[&input.release_id],|r|r.get(0))?; let id=Uuid::new_v4().to_string();let body=json!({"release":serde_json::from_str::(&envelope)?,"profile":SignedEnvelope::sign(&profile,&s.key)?,"profile_public_key":STANDARD.encode(s.key.verifying_key().as_bytes()),"release_public_key":std::env::var("SWAN_RELEASE_PUBLIC_KEY").map_err(|_|bad("Release key not configured"))?}); - s.db.lock().unwrap().execute("INSERT INTO builds(id,body,state) VALUES(?1,?2,'queued')",params![id,body.to_string()])?;s.audit(&actor,"build.queued",&id)?;Ok(Json(json!({"id":id}))) + db.execute("INSERT INTO builds(id,body,state) VALUES(?1,?2,'queued')",params![id,body.to_string()])?;drop(db);s.audit(&actor,"build.queued",&id)?;Ok(Json(json!({"id":id}))) } async fn builds(State(s):State,headers:HeaderMap)->ApiResult { s.user(&headers,true)?;let db=s.db.lock().unwrap();let mut stmt=db.prepare("SELECT id,state,result FROM builds ORDER BY rowid DESC LIMIT 100")?; @@ -518,6 +519,7 @@ mod worker_completion_tests { let release=Release{schema:1,product:"swan-remote-support".into(),version:"2.0.0".into(),sequence:1,edition:Edition::Technician,architecture:"x86_64".into(),channel:"stable".into(),expires_at:now()+3600,artifact_url:"https://releases.example/install.msi".into(),sha256:"a".repeat(64),installed_sha256:"b".repeat(64),installed_files:vec![],agent_url:"https://releases.example/agent.exe".into(),agent_sha256:"c".repeat(64),publisher:"Example".into(),publisher_certificate_sha256:"d".repeat(64),windows_versions:vec!["windows_11".into()],source_url:"https://releases.example/source.tar.gz".into(),format:"msi".into()}; let envelope=SignedEnvelope::sign(&release,&store.key).unwrap(); let admin_token=random_token();let technician_token=random_token(); + let completed=Uuid::new_v4().to_string(); { let db=store.db.lock().unwrap(); db.execute("INSERT INTO profile VALUES(1,?1)",[serde_json::to_string(&profile).unwrap()]).unwrap(); @@ -526,6 +528,9 @@ mod worker_completion_tests { db.execute("INSERT INTO sessions VALUES(?1,?2,?3)",params![digest(credential),id,now()+3600]).unwrap(); } db.execute("INSERT INTO releases VALUES('release',?1,?2,1)",params![serde_json::to_string(&release).unwrap(),serde_json::to_string(&envelope).unwrap()]).unwrap(); + let job=json!({"release":envelope}).to_string(); + for state in ["queued","running","uploaded"] {db.execute("INSERT INTO builds(id,body,state) VALUES(?1,?2,?1)",params![state,job]).unwrap();} + db.execute("INSERT INTO builds(id,body,state) VALUES(?1,?2,'completed')",params![completed,job]).unwrap(); } let headers=|credential:&str| {let mut h=HeaderMap::new();h.insert(header::AUTHORIZATION,format!("Bearer {credential}").parse().unwrap());h}; assert!(!select_update(&store,"technician",Edition::Technician).ok().unwrap().0.is_null()); @@ -543,6 +548,8 @@ mod worker_completion_tests { assert!(withdraw_release(State(store.clone()),headers(&admin_token),Path("release".into())).await.is_ok()); assert!(select_update(&store,"technician",Edition::Technician).ok().unwrap().0.is_null()); assert!(withdraw_release(State(store.clone()),headers(&admin_token),Path("missing".into())).await.is_err()); + let cancelled:i64=store.db.lock().unwrap().query_row("SELECT COUNT(*) FROM builds WHERE state='failed'",[],|r|r.get(0)).unwrap();assert_eq!(cancelled,3); + assert!(matches!(download_artifact(State(store.clone()),Path(completed),HeaderMap::new()).await,Err(ApiError(StatusCode::NOT_FOUND,_)))); let audit:i64=store.db.lock().unwrap().query_row("SELECT COUNT(*) FROM audit WHERE event='release.withdrawn'",[],|r|r.get(0)).unwrap();assert_eq!(audit,1); drop(store);std::fs::remove_dir_all(directory).unwrap(); } @@ -587,6 +594,16 @@ async fn withdraw_release(State(s):State,headers:HeaderMap,Path(id):Path let user=s.user(&headers,true)?; let mut db=s.db.lock().unwrap();let tx=db.transaction()?; if tx.execute("UPDATE releases SET approved=0 WHERE id=?1",[&id])?!=1{return Err(bad("Unknown release"));} + let envelope:String=tx.query_row("SELECT envelope FROM releases WHERE id=?1",[&id],|r|r.get(0))?; + let envelope:Value=serde_json::from_str(&envelope)?; + let jobs={let mut statement=tx.prepare("SELECT id,body FROM builds WHERE state IN ('queued','running','uploaded')")?; + let rows=statement.query_map([],|r|Ok((r.get::<_,String>(0)?,r.get::<_,String>(1)?)))?.collect::,_>>()?;rows}; + for (job,body) in jobs { + if serde_json::from_str::(&body)?["release"]==envelope { + let result=json!({"artifact_url":"","sha256":"","log":"Release approval withdrawn"}); + tx.execute("UPDATE builds SET state='failed',result=?1 WHERE id=?2",params![result.to_string(),job])?; + } + } tx.execute("INSERT INTO audit(at,actor,event,target) VALUES(?1,?2,'release.withdrawn',?3)",params![now(),user,id])?; tx.commit()?;Ok(Json(json!({"ok":true}))) } @@ -701,6 +718,8 @@ async fn download_artifact(State(s):State,Path(id):Path,headers: let job:String=s.db.lock().unwrap().query_row("SELECT body FROM builds WHERE id=?1 AND state='completed'",[&id],|r|r.get(0)).optional()?.ok_or(ApiError(StatusCode::NOT_FOUND,"Artifact unavailable"))?; let job:Value=serde_json::from_str(&job)?; let envelope:SignedEnvelope=serde_json::from_value(job["release"].clone())?; + let approved:bool=s.db.lock().unwrap().query_row("SELECT EXISTS(SELECT 1 FROM releases WHERE approved=1 AND envelope=?1)",[serde_json::to_string(&envelope)?],|r|r.get(0))?; + if !approved{return Err(ApiError(StatusCode::NOT_FOUND,"Release approval withdrawn"));} let trust=std::env::var("SWAN_RELEASE_PUBLIC_KEY").map_err(|_|bad("Release trust key unavailable"))?; let release:Release=envelope.verify(&public_key(&trust)?).map_err(|_|denied())?; if release.edition==Edition::Technician{s.user(&headers,false)?;} From 22d2c332f293b31288a3a46399dfc62f1663fb39 Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Fri, 2 Oct 2026 13:17:52 +0200 Subject: [PATCH 069/652] Verify cancelled workers cannot republish withdrawn releases --- docs/ACCEPTANCE.md | 8 ++++++++ product/management/src/service.rs | 16 +++++++++++++++- 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index dce2aaa336e..8195915bcd4 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -366,3 +366,11 @@ started installation recovery are unaffected. Build creation keeps its approval check and queue insertion under the same database lock. All eight management tests pass, including three job states and completed-bundle download denial. Worker/network race fault injection and browser behavior remain unverified. + +The withdrawal regression now exercises a real authenticated worker claim and +stored fixture artifact: late persistence, failure completion and success +completion are rejected after withdrawal, stored bytes remain unchanged, and +restoring approval does not revive or requeue cancelled claims. Eight management +tests pass; the final expanded withdrawal test also passes independently. This +uses handler/storage fixtures, not a signed installer worker end-to-end run; +simultaneous network faults and production package publication remain unverified. diff --git a/product/management/src/service.rs b/product/management/src/service.rs index 60acd1953b3..5ae3cc5870e 100644 --- a/product/management/src/service.rs +++ b/product/management/src/service.rs @@ -520,6 +520,7 @@ mod worker_completion_tests { let envelope=SignedEnvelope::sign(&release,&store.key).unwrap(); let admin_token=random_token();let technician_token=random_token(); let completed=Uuid::new_v4().to_string(); + let in_flight=Uuid::new_v4().to_string();let worker_token=random_token();let worker=digest(&worker_token); { let db=store.db.lock().unwrap(); db.execute("INSERT INTO profile VALUES(1,?1)",[serde_json::to_string(&profile).unwrap()]).unwrap(); @@ -531,7 +532,10 @@ mod worker_completion_tests { let job=json!({"release":envelope}).to_string(); for state in ["queued","running","uploaded"] {db.execute("INSERT INTO builds(id,body,state) VALUES(?1,?2,?1)",params![state,job]).unwrap();} db.execute("INSERT INTO builds(id,body,state) VALUES(?1,?2,'completed')",params![completed,job]).unwrap(); + db.execute("INSERT INTO workers(id,token_hash) VALUES('worker',?1)",[&worker]).unwrap(); + db.execute("INSERT INTO builds(id,body,state,worker,claimed_at) VALUES(?1,?2,'running',?3,?4)",params![in_flight,job,worker,now()]).unwrap(); } + persist_verified_artifact(&store,&in_flight,&worker,b"previously validated bundle").ok().unwrap(); let headers=|credential:&str| {let mut h=HeaderMap::new();h.insert(header::AUTHORIZATION,format!("Bearer {credential}").parse().unwrap());h}; assert!(!select_update(&store,"technician",Edition::Technician).ok().unwrap().0.is_null()); assert!(select_update(&store,"device",Edition::Customer).ok().unwrap().0.is_null()); @@ -548,9 +552,19 @@ mod worker_completion_tests { assert!(withdraw_release(State(store.clone()),headers(&admin_token),Path("release".into())).await.is_ok()); assert!(select_update(&store,"technician",Edition::Technician).ok().unwrap().0.is_null()); assert!(withdraw_release(State(store.clone()),headers(&admin_token),Path("missing".into())).await.is_err()); - let cancelled:i64=store.db.lock().unwrap().query_row("SELECT COUNT(*) FROM builds WHERE state='failed'",[],|r|r.get(0)).unwrap();assert_eq!(cancelled,3); + let cancelled:i64=store.db.lock().unwrap().query_row("SELECT COUNT(*) FROM builds WHERE state='failed'",[],|r|r.get(0)).unwrap();assert_eq!(cancelled,4); + assert!(persist_verified_artifact(&store,&in_flight,&worker,b"late replacement").is_err()); + let late_result=BuildResult{success:false,artifact_url:String::new(),sha256:String::new(),log:"Worker reports a different terminal outcome".into()}; + assert!(finish_build(State(store.clone()),headers(&worker_token),Path(in_flight.clone()),Json(late_result)).await.is_err()); + let late_success=BuildResult{success:true,artifact_url:format!("{}/api/v1/downloads/{in_flight}",profile.management_url),sha256:digest(b"previously validated bundle"),log:"Worker finished after withdrawal".into()}; + assert!(finish_build(State(store.clone()),headers(&worker_token),Path(in_flight.clone()),Json(late_success)).await.is_err()); + assert_eq!(std::fs::read(store.artifact_dir.join(format!("{in_flight}.zip"))).unwrap(),b"previously validated bundle"); assert!(matches!(download_artifact(State(store.clone()),Path(completed),HeaderMap::new()).await,Err(ApiError(StatusCode::NOT_FOUND,_)))); let audit:i64=store.db.lock().unwrap().query_row("SELECT COUNT(*) FROM audit WHERE event='release.withdrawn'",[],|r|r.get(0)).unwrap();assert_eq!(audit,1); + // Restoring release approval cannot revive a cancelled worker claim. + store.db.lock().unwrap().execute("UPDATE releases SET approved=1 WHERE id='release'",[]).unwrap(); + assert!(persist_verified_artifact(&store,&in_flight,&worker,b"late replacement").is_err()); + assert!(claim_build(State(store.clone()),headers(&worker_token)).await.ok().unwrap().0.is_null()); drop(store);std::fs::remove_dir_all(directory).unwrap(); } #[test] From 68b835e521d7fa2efd814cf3fc1eac718eebbe04 Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Fri, 2 Oct 2026 13:20:07 +0200 Subject: [PATCH 070/652] Keep technician inventory visible when automatic updates fail --- docs/ACCEPTANCE.md | 9 ++++++ .../pages/company_technician_page.dart | 32 ++++++++++++++++--- src/managed.rs | 5 +-- 3 files changed, 39 insertions(+), 7 deletions(-) diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index 8195915bcd4..5d43853c017 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -374,3 +374,12 @@ restoring approval does not revive or requeue cancelled claims. Eight management tests pass; the final expanded withdrawal test also passes independently. This uses handler/storage fixtures, not a signed installer worker end-to-end run; simultaneous network faults and production package publication remain unverified. + +Technician inventory/history now display before optional update discovery and +handoff. Update errors have a separate visible status and no longer discard +fresh inventory. The native recovery response exposes a public pending flag; +pending/failed recovery is visible and disables new connection controls while +native receipt and file-lock enforcement remains authoritative. Native Rust +parsing and diff checks pass. Flutter compilation, rendering and responsiveness +during long downloads remain unverified. The branding native run is confirmed +live in dependency installation; later native runs remain queued. diff --git a/flutter/lib/desktop/pages/company_technician_page.dart b/flutter/lib/desktop/pages/company_technician_page.dart index b84482ab434..43451ac878c 100644 --- a/flutter/lib/desktop/pages/company_technician_page.dart +++ b/flutter/lib/desktop/pages/company_technician_page.dart @@ -25,6 +25,8 @@ class _CompanyTechnicianPageState extends State { bool _loggedIn = false; bool _busy = false; String _error = ''; + String _updateError = ''; + bool _recoveryPending = false; String? _approvedUpdate; Timer? _refreshTimer; @@ -52,8 +54,19 @@ class _CompanyTechnicianPageState extends State { as Map; if (mounted) setState(() { _company = cachedCompany; }); if (Platform.isWindows) { - final recovery = await _request({'action': 'resume-update'}) as Map; - if (recovery['handed_off'] == true) exit(0); + try { + final recovery = await _request({'action': 'resume-update'}) as Map; + if (recovery['handed_off'] == true) exit(0); + if (mounted) setState(() { + _recoveryPending = recovery['pending'] == true; + _updateError = _recoveryPending ? 'Software installation recovery is pending. New support connections are unavailable.' : ''; + }); + } catch (_) { + if (mounted) setState(() { + _recoveryPending = true; + _updateError = 'Software recovery could not finish. It will retry automatically.'; + }); + } } // Retain cached branding if the server is offline; authenticated operations // still require a current signed policy on the native side. @@ -68,7 +81,9 @@ class _CompanyTechnicianPageState extends State { }); final devices = loggedIn ? await _request({'action': 'devices'}) as List : []; final history = loggedIn ? await _request({'action': 'history'}) as List : []; - if (mounted && loggedIn && Platform.isWindows) { + if (mounted) setState(() { _devices = devices; _history = history; }); + try { + if (mounted && loggedIn && Platform.isWindows && !_recoveryPending) { final result = await _request({'action': 'update'}) as Map; // Rust writes the signed recovery receipt and starts the verified helper // before acknowledging handoff. New sessions are then blocked natively. @@ -80,6 +95,12 @@ class _CompanyTechnicianPageState extends State { _company = company; _loggedIn = loggedIn; _devices = devices; _history = history; _approvedUpdate = update is Map ? update['version'] as String? : null; }); + } catch (_) { + if (mounted) setState(() { + _approvedUpdate = null; + if (!_recoveryPending) _updateError = 'Software update could not finish. It will retry automatically.'; + }); + } } Future _login() async { @@ -139,6 +160,7 @@ class _CompanyTechnicianPageState extends State { Text(_company['domain'] as String? ?? 'Company setup required'), CompanyContactLinks(company: _company), if (_approvedUpdate != null) Text('Company-approved update available: $_approvedUpdate'), + if (_updateError.isNotEmpty) Text(_updateError), const SizedBox(height: 16), if (_busy) const LinearProgressIndicator(), if (_error.isNotEmpty) Padding(padding: const EdgeInsets.symmetric(vertical: 12), @@ -170,10 +192,10 @@ class _CompanyTechnicianPageState extends State { return Card(child: ListTile(title: Text(device['name'] as String), subtitle: Text('${device['group']} ยท ${device['state']}'), trailing: Wrap(spacing: 8, children: [ - TextButton(onPressed: !_busy && approved ? () => _run(() => _connect(device)) : null, + TextButton(onPressed: !_busy && approved && !_recoveryPending ? () => _run(() => _connect(device)) : null, child: const Text('Request support')), if (device['unattended'] == true) - TextButton(onPressed: !_busy && approved ? () => _run(() => _connect(device, unattended: true)) : null, + TextButton(onPressed: !_busy && approved && !_recoveryPending ? () => _run(() => _connect(device, unattended: true)) : null, child: const Text('Unattended')), ]))); }), diff --git a/src/managed.rs b/src/managed.rs index 5d12a800dd0..e50d0133dac 100644 --- a/src/managed.rs +++ b/src/managed.rs @@ -37,9 +37,10 @@ async fn technician_request_inner(request:&str)->ResultType { if action=="resume-update" { // Recovery can finish an already authorized installation offline. It // cannot select a new release and does not require a reusable login. - if !directory.join("pending-update.json").exists() || !crate::flutter::sessions::get_sessions().is_empty(){return Ok(json!({"handed_off":false}));} + let pending=directory.join("pending-update.json").exists(); + if !pending || !crate::flutter::sessions::get_sessions().is_empty(){return Ok(json!({"handed_off":false,"pending":pending}));} let handed_off=hbb_common::tokio::task::spawn_blocking(move ||state.resume_pending_update(&directory,false)).await??; - return Ok(json!({"handed_off":handed_off})); + return Ok(json!({"handed_off":handed_off,"pending":true})); } if action=="sync" { let mut state=state;state.sync().await?;state.save(&directory)?; From 85630c3bdb60f2d7ca0c6420dc18b4491b6a0cad Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Fri, 2 Oct 2026 13:22:57 +0200 Subject: [PATCH 071/652] Prepare technician updates in a native background task --- docs/ACCEPTANCE.md | 10 ++++++ .../pages/company_technician_page.dart | 25 ++++++++++++++- src/managed.rs | 31 +++++++++++++++---- 3 files changed, 59 insertions(+), 7 deletions(-) diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index 5d43853c017..ddfbcec2f40 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -383,3 +383,13 @@ native receipt and file-lock enforcement remains authoritative. Native Rust parsing and diff checks pass. Flutter compilation, rendering and responsiveness during long downloads remain unverified. The branding native run is confirmed live in dependency installation; later native runs remain queued. + +Technician update preparation now runs as one native background task instead +of holding the refresh request open through downloads/signature verification. +The UI polls public running/failed/handoff flags independently, keeps login +credentials in Rust, and closes only after verified handoff. Recovery avoids +launching another helper while that task is active. Final native session locks, +fresh server approval and pending receipts remain installation gates. Rust +parsing and diff checks pass; source inspection confirms the task uses the +existing persistent Tokio runner. Native compilation, task responsiveness and +GUI/session races still require end-to-end validation. diff --git a/flutter/lib/desktop/pages/company_technician_page.dart b/flutter/lib/desktop/pages/company_technician_page.dart index 43451ac878c..dac68d56f2f 100644 --- a/flutter/lib/desktop/pages/company_technician_page.dart +++ b/flutter/lib/desktop/pages/company_technician_page.dart @@ -29,10 +29,13 @@ class _CompanyTechnicianPageState extends State { bool _recoveryPending = false; String? _approvedUpdate; Timer? _refreshTimer; + Timer? _updateTimer; + bool _pollingUpdate = false; + bool _updateRunning = false; Future _request(Map input) async { final text = await bind.mainCompanyRequest(request: jsonEncode(input)) - .first.timeout(input['action'] == 'update' || input['action'] == 'resume-update' + .first.timeout(input['action'] == 'resume-update' ? const Duration(minutes: 25) : const Duration(seconds: 25)); final response = jsonDecode(text) as Map; if (response['ok'] != true) { @@ -49,6 +52,23 @@ class _CompanyTechnicianPageState extends State { finally { if (mounted) setState(() { _busy = false; }); } } + Future _pollUpdate() async { + if (_pollingUpdate || !mounted || !Platform.isWindows) return; + _pollingUpdate = true; + try { + final progress = await _request({'action': 'update-progress'}) as Map; + if (progress['handed_off'] == true) exit(0); + if (mounted) setState(() { + _updateRunning = progress['running'] == true; + if (progress['failed'] == true && !_recoveryPending) { + _updateError = 'Software update was deferred or could not finish. It will retry automatically.'; + } + }); + } catch (_) { + if (mounted) setState(() { _updateError = 'Software update status is unavailable.'; }); + } finally { _pollingUpdate = false; } + } + Future _refresh() async { final cachedCompany = jsonDecode(await bind.mainGetCommon(key: 'company-overview')) as Map; @@ -135,11 +155,13 @@ class _CompanyTechnicianPageState extends State { _refreshTimer = Timer.periodic(const Duration(seconds: 60), (_) { if (mounted && !_busy) _run(_refresh); }); + _updateTimer = Timer.periodic(const Duration(seconds: 1), (_) { _pollUpdate(); }); } @override void dispose() { _refreshTimer?.cancel(); + _updateTimer?.cancel(); _username.dispose(); _password.dispose(); _code.dispose(); super.dispose(); } @@ -161,6 +183,7 @@ class _CompanyTechnicianPageState extends State { CompanyContactLinks(company: _company), if (_approvedUpdate != null) Text('Company-approved update available: $_approvedUpdate'), if (_updateError.isNotEmpty) Text(_updateError), + if (_updateRunning) const Text('Preparing company-approved software updateโ€ฆ'), const SizedBox(height: 16), if (_busy) const LinearProgressIndicator(), if (_error.isNotEmpty) Padding(padding: const EdgeInsets.symmetric(vertical: 12), diff --git a/src/managed.rs b/src/managed.rs index e50d0133dac..006f5da7d20 100644 --- a/src/managed.rs +++ b/src/managed.rs @@ -10,6 +10,9 @@ struct TechnicianMemory { generation:u64, login:Option, tickets:std::collections::HashMap, + update_running:bool, + update_handed_off:bool, + update_failed:bool, } hbb_common::lazy_static::lazy_static! { static ref TECHNICIAN:std::sync::Mutex=Default::default(); @@ -33,11 +36,17 @@ async fn technician_request_inner(request:&str)->ResultType { let directory=swan_agent::state_directory(); let state=AgentState::load_for_refresh(&directory)?; if state.bootstrap.edition!=Edition::Technician {bail!("Technician edition required");} + if action=="update-progress" { + let memory=TECHNICIAN.lock().unwrap(); + return Ok(json!({"running":memory.update_running,"handed_off":memory.update_handed_off,"failed":memory.update_failed})); + } #[cfg(all(windows,feature="flutter"))] if action=="resume-update" { // Recovery can finish an already authorized installation offline. It // cannot select a new release and does not require a reusable login. let pending=directory.join("pending-update.json").exists(); + {let memory=TECHNICIAN.lock().unwrap(); + if memory.update_running || memory.update_handed_off{return Ok(json!({"handed_off":memory.update_handed_off,"pending":pending}));}} if !pending || !crate::flutter::sessions::get_sessions().is_empty(){return Ok(json!({"handed_off":false,"pending":pending}));} let handed_off=hbb_common::tokio::task::spawn_blocking(move ||state.resume_pending_update(&directory,false)).await??; return Ok(json!({"handed_off":handed_off,"pending":true})); @@ -80,14 +89,24 @@ async fn technician_request_inner(request:&str)->ResultType { // Include disconnected/reconnecting windows, not just authenticated // sessions. The file lock and pending receipt guard other processes. if !crate::flutter::sessions::get_sessions().is_empty(){return Ok(json!({"handed_off":false}));} - {let memory=TECHNICIAN.lock().unwrap();if memory.generation!=generation || memory.login.is_none(){bail!("Login changed before update");}} + {let mut memory=TECHNICIAN.lock().unwrap(); + if memory.generation!=generation || memory.login.is_none(){bail!("Login changed before update");} + if memory.update_running || memory.update_handed_off{return Ok(json!({"handed_off":memory.update_handed_off}));} + memory.update_running=true;memory.update_failed=false; + } let mut state=state; - let handed_off=state.update(&directory,Some(&token)).await?; - if handed_off { + hbb_common::tokio::spawn(async move { + // Authentication remains in native memory. Final server approval, + // receipt creation and session exclusion remain inside the updater. + let result=state.update(&directory,Some(&token)).await; let mut memory=TECHNICIAN.lock().unwrap(); - memory.generation=memory.generation.wrapping_add(1);memory.login=None;memory.tickets.clear(); - } - return Ok(json!({"handed_off":handed_off})); + memory.update_running=false;memory.update_failed=result.is_err(); + memory.update_handed_off=matches!(result,Ok(true)); + if memory.update_handed_off { + memory.generation=memory.generation.wrapping_add(1);memory.login=None;memory.tickets.clear(); + } + }); + return Ok(json!({"handed_off":false,"started":true})); } if action=="update-status" { let envelope=state.approved_update(Some(&token)).await?; From 6438ce2d2ffaacd97ea411573e2fed45fa910262 Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Fri, 2 Oct 2026 13:26:16 +0200 Subject: [PATCH 072/652] Request fresh company authorization for technician reconnects --- docs/ACCEPTANCE.md | 9 +++++ .../pages/company_technician_page.dart | 4 +-- src/client.rs | 2 +- src/flutter_ffi.rs | 2 +- src/managed.rs | 33 +++++++++++++++---- 5 files changed, 40 insertions(+), 10 deletions(-) diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index ddfbcec2f40..9825842fe75 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -393,3 +393,12 @@ fresh server approval and pending receipts remain installation gates. Rust parsing and diff checks pass; source inspection confirms the task uses the existing persistent Tokio runner. Native compilation, task responsiveness and GUI/session races still require end-to-end validation. + +Consumed native technician ticket handles now retain bounded, login-generation +bound reconnection context without grants or proof keys. A reconnect requests a +new server grant and ephemeral proof key, verifies the original device/peer and +consent mode, and rejects logout or login changes. Attended sessions still need +customer approval; reconnection never upgrades them to unattended access. +Logout/login/update handoff clear the context. Native Rust parsing and diff +checks pass. Reboot reconnection, concurrent-window behavior, revocation during +reconnect and full native compilation remain unverified. diff --git a/flutter/lib/desktop/pages/company_technician_page.dart b/flutter/lib/desktop/pages/company_technician_page.dart index dac68d56f2f..93e4a4d6301 100644 --- a/flutter/lib/desktop/pages/company_technician_page.dart +++ b/flutter/lib/desktop/pages/company_technician_page.dart @@ -142,8 +142,8 @@ class _CompanyTechnicianPageState extends State { final response = await _request({'action': 'connect', 'device_id': device['id'], 'unattended': unattended}) as Map; if (!mounted) return; - // Only a one-use local handle enters the window API; signed grants and - // challenge proof keys stay in Rust memory and are bound to the target. + // Only an opaque native request handle enters the window API. Reconnects + // need fresh target-bound grants; signing proof keys stay in Rust memory. await connect(context, response['rustdesk_id'] as String, password: response['ticket_handle'] as String); } diff --git a/src/client.rs b/src/client.rs index 0d93cf04294..c8364be2316 100644 --- a/src/client.rs +++ b/src/client.rs @@ -3483,7 +3483,7 @@ pub async fn handle_hash( #[cfg(feature = "swan_custom")] { let target = lc.read().unwrap().id.clone(); - match crate::managed::login(&hash.challenge, &target, password_preset) { + match crate::managed::login(&hash.challenge, &target, password_preset).await { Ok(proof) => send_login(lc, String::new(), String::new(), proof, peer).await, Err(_) => interface.msgbox("error", "Company authorization required", "Sign in and request a new session from the company technician device list.", ""), } diff --git a/src/flutter_ffi.rs b/src/flutter_ffi.rs index 775e7e0c24e..ac2680c38d5 100644 --- a/src/flutter_ffi.rs +++ b/src/flutter_ffi.rs @@ -2811,7 +2811,7 @@ pub fn main_get_printer_names() -> SyncReturn { } /// Login tokens and grant proof keys stay in Rust process memory, shared by -/// this app's Flutter window engines. Connections return one-use local handles. +/// this app's Flutter window engines. Connections return opaque local handles. pub fn main_company_request(request:String,sink:StreamSink)->ResultType<()> { #[cfg(feature = "swan_custom")] {return flutter::async_tasks::company_request(request,sink);} diff --git a/src/managed.rs b/src/managed.rs index 006f5da7d20..6a5ed2873c8 100644 --- a/src/managed.rs +++ b/src/managed.rs @@ -5,11 +5,14 @@ use swan_agent::{protocol::{self, Edition, ManagedLogin, SignedEnvelope}, AgentS struct TechnicianLogin {token:String,company:String,username:String} struct PendingTicket {envelope:SignedEnvelope,key_hex:String,expires_at:u64,company:String,peer:String} +#[derive(Clone)] +struct ReconnectRequest {device:String,unattended:bool,company:String,peer:String,generation:u64,expires_at:u64} #[derive(Default)] struct TechnicianMemory { generation:u64, login:Option, tickets:std::collections::HashMap, + reconnects:std::collections::HashMap, update_running:bool, update_handed_off:bool, update_failed:bool, @@ -63,7 +66,7 @@ async fn technician_request_inner(request:&str)->ResultType { let username=input["username"].as_str().ok_or_else(||anyhow!("Missing username"))?; let password=input["password"].as_str().ok_or_else(||anyhow!("Missing password"))?; let code=input["code"].as_str().ok_or_else(||anyhow!("Missing code"))?; - let generation={let mut memory=TECHNICIAN.lock().unwrap();memory.generation=memory.generation.wrapping_add(1);memory.login=None;memory.tickets.clear();memory.generation}; + let generation={let mut memory=TECHNICIAN.lock().unwrap();memory.generation=memory.generation.wrapping_add(1);memory.login=None;memory.tickets.clear();memory.reconnects.clear();memory.generation}; let token=state.technician_login(username,password,code).await?; let accepted={let mut memory=TECHNICIAN.lock().unwrap(); if memory.generation==generation {memory.login=Some(TechnicianLogin{token:token.clone(),company:state.bootstrap.company_id.clone(),username:username.into()});true}else{false} @@ -77,7 +80,7 @@ async fn technician_request_inner(request:&str)->ResultType { (login.token.clone(),memory.generation) }; if action=="logout" { - {let mut memory=TECHNICIAN.lock().unwrap();memory.generation=memory.generation.wrapping_add(1);memory.login=None;memory.tickets.clear();} + {let mut memory=TECHNICIAN.lock().unwrap();memory.generation=memory.generation.wrapping_add(1);memory.login=None;memory.tickets.clear();memory.reconnects.clear();} // Local logout is immediate even if the server cannot receive revocation. state.technician_logout(&token).await?; return Ok(json!({"logged_in":false})); @@ -103,7 +106,7 @@ async fn technician_request_inner(request:&str)->ResultType { memory.update_running=false;memory.update_failed=result.is_err(); memory.update_handed_off=matches!(result,Ok(true)); if memory.update_handed_off { - memory.generation=memory.generation.wrapping_add(1);memory.login=None;memory.tickets.clear(); + memory.generation=memory.generation.wrapping_add(1);memory.login=None;memory.tickets.clear();memory.reconnects.clear(); } }); return Ok(json!({"handed_off":false,"started":true})); @@ -123,10 +126,13 @@ async fn technician_request_inner(request:&str)->ResultType { let mut memory=TECHNICIAN.lock().unwrap(); if memory.generation!=generation || memory.login.is_none(){bail!("Login changed during authorization");} memory.tickets.retain(|_,ticket|ticket.expires_at>protocol::now()); + memory.reconnects.retain(|_,request|request.expires_at>protocol::now()); if memory.tickets.len()>=32 {bail!("Too many pending connections");} + if memory.reconnects.len()>=1024 {bail!("Too many connection requests; sign in again");} // The proof is consumed inside native handle_hash, never sent to Dart, // command-line arguments, another app instance or IPC configuration. let handle=format!("SWT1.{}",protocol::random_token()); + memory.reconnects.insert(handle.clone(),ReconnectRequest{device:device.into(),unattended:grant.unattended,company:grant.company_id.clone(),peer:grant.rustdesk_id.clone(),generation,expires_at:protocol::now()+86400}); memory.tickets.insert(handle.clone(),PendingTicket{envelope,key_hex:hex::encode(key.to_bytes()),expires_at:grant.expires_at,company:grant.company_id,peer:grant.rustdesk_id.clone()}); return Ok(json!({"rustdesk_id":grant.rustdesk_id,"ticket_handle":handle})); } @@ -185,26 +191,41 @@ pub async fn renew(lease:&mut Lease)->ResultType<()> { state.renew(lease).await?;Ok(()) } -pub fn login(challenge:&str,target:&str,local_ticket:&str)->ResultType> { +pub async fn login(challenge:&str,target:&str,local_ticket:&str)->ResultType> { // The outgoing connection loop retains its own shared lock through close. // Recheck pending installation here as well before consuming a proof ticket. - let _activity=swan_agent::lock_session(&swan_agent::state_directory())?; + {let _activity=swan_agent::lock_session(&swan_agent::state_directory())?;} let state=AgentState::load(&swan_agent::state_directory())?; if state.bootstrap.edition!=Edition::Technician {hbb_common::bail!("Technician edition required");} // The opaque handle identifies one window's pending request. It contains // no grant or proof key and cannot overwrite another request to this peer. let pending=if local_ticket.is_empty(){None}else{ if !local_ticket.starts_with("SWT1."){hbb_common::bail!("Managed ticket required; legacy passwords are disabled");} - Some(TECHNICIAN.lock().unwrap().tickets.remove(local_ticket).ok_or_else(||hbb_common::anyhow::anyhow!("Ticket missing or already consumed"))?) + TECHNICIAN.lock().unwrap().tickets.remove(local_ticket) }; let (envelope,key)=if let Some(ticket)=pending { if ticket.company!=state.bootstrap.company_id || ticket.peer!=target || ticket.expires_at<=protocol::now(){hbb_common::bail!("Expired or wrong-target ticket");} (ticket.envelope,protocol::signing_key_from_hex(&ticket.key_hex)?) + }else if !local_ticket.is_empty(){ + // A consumed proof is never replayed. Reconnection requires current + // native login and a new target-bound server grant and proof key. + let (request,token)={let memory=TECHNICIAN.lock().unwrap(); + let request=memory.reconnects.get(local_ticket).ok_or_else(||hbb_common::anyhow::anyhow!("Unknown connection request"))?.clone(); + let login=memory.login.as_ref().ok_or_else(||hbb_common::anyhow::anyhow!("Login required for reconnection"))?; + if request.company!=state.bootstrap.company_id || login.company!=request.company || request.peer!=target || request.generation!=memory.generation || request.expires_at<=protocol::now(){hbb_common::bail!("Expired or wrong-target reconnection");} + (request,login.token.clone()) + }; + let key=protocol::signing_key_from_hex(&protocol::random_token())?; + let (envelope,grant)=state.technician_ticket(&token,&request.device,request.unattended,&key).await?; + {let memory=TECHNICIAN.lock().unwrap();if memory.generation!=request.generation || memory.login.is_none(){hbb_common::bail!("Login changed during reconnection");}} + if grant.device_id!=request.device || grant.unattended!=request.unattended{hbb_common::bail!("Wrong reconnection grant");} + (envelope,key) }else{ (serde_json::from_str(&std::env::var("SWAN_SESSION_GRANT")?)?,protocol::signing_key_from_hex(&std::env::var("SWAN_SESSION_PROOF_KEY")?)?) }; let grant:protocol::SessionGrant=envelope.verify(&protocol::public_key(&state.bootstrap.profile_public_key)?)?; grant.validate(&state.bootstrap.company_id,&grant.device_id,target,protocol::now())?; + let _activity=swan_agent::lock_session(&swan_agent::state_directory())?; // A ticket stolen from a connection cannot be used without this ephemeral proof key. Ok(ManagedLogin::prove(envelope,&key,challenge).to_wire()?) } From d07fc23a34c414c77b262e761288da7e378d2ff6 Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Fri, 2 Oct 2026 13:29:13 +0200 Subject: [PATCH 073/652] Refresh current acceptance evidence and label baseline research --- docs/ACCEPTANCE.md | 2 +- docs/PRODUCT_COMPLETION_ASSESSMENT.md | 7 +++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index 9825842fe75..5d5701c0691 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -8,7 +8,7 @@ native support session or a clean-machine installation. - Public baseline tag `swan-single-company-baseline-1.4.9` preserves the original Swan deployment and build instructions. Its source was not replaced. -- Twenty-eight product tests pass on the local Windows development host, including +- Thirty-three product tests pass on the local Windows development host at `6438ce2` (18 agent, eight management, seven protocol), including enrollment/trust preservation across stale writes, trusted key rotation, refusal to automatically reinstall a removed endpoint, and explicit consent changes surviving older refreshes. Consent API tests also reject delayed enable requests and older revocation retries; same-revision revocation takes precedence. The preceding eleven-test component source passed tests and release compilation on [Windows 2022 and Ubuntu 24.04 CI](https://github.com/rijff24/SwanRemoteSupport/actions/runs/36856716860) diff --git a/docs/PRODUCT_COMPLETION_ASSESSMENT.md b/docs/PRODUCT_COMPLETION_ASSESSMENT.md index d2f01209812..c7bb83fc6bb 100644 --- a/docs/PRODUCT_COMPLETION_ASSESSMENT.md +++ b/docs/PRODUCT_COMPLETION_ASSESSMENT.md @@ -1,5 +1,12 @@ # Swan Remote Support completion assessment +**Historical baseline research, not current implementation status.** The findings +below describe `9494a5a` before the configurable-product work. Management, +configuration trust, enrollment, authorization, workers, installer authoring and +update code have since been added on `codex/configurable-product`. Current +verification and remaining release gates are recorded in [ACCEPTANCE.md](ACCEPTANCE.md). +The original requirements remain in scope; the product is not production-ready. + Research date: 1 October 2026. This assessment covers the company-configurable open-source product: a self-hosted server, public customer installer, internal technician installer, branding and policy synchronization, and automatic updates without requiring Tailscale. The remote desktop engine and a Swan-specific unsigned customer build exist. The reusable company administration and distribution system does not yet exist in this checkout. We should extend the RustDesk OSS foundation with our own open-source management service and company configuration protocol. The proposed architecture below is a recommendation, not implemented behavior. From adb2a97196d2fd2efc40cc3e775841ab1aee876d Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Fri, 2 Oct 2026 13:31:01 +0200 Subject: [PATCH 074/652] Disable inherited password persistence in managed clients --- docs/ACCEPTANCE.md | 8 ++++++++ src/client.rs | 19 ++++++++++++++++++- 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index 5d5701c0691..a8cb46ff02f 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -402,3 +402,11 @@ customer approval; reconnection never upgrades them to unattended access. Logout/login/update handoff clear the context. Native Rust parsing and diff checks pass. Reboot reconnection, concurrent-window behavior, revocation during reconnect and full native compilation remain unverified. + +Managed peer-information handling now clears inherited peer passwords and skips +upstream password/address-book synchronization. Legacy UI password submission +is refused before constructing a network login; managed challenge proofs still +use their separate native path. These guards apply only to the managed build, +preserving baseline source behavior. Rust parsing and diff checks pass. Native +compilation, saved-settings inspection and negative legacy-UI tests remain +unverified; receiver-side signed authorization remains the access boundary. diff --git a/src/client.rs b/src/client.rs index c8364be2316..48d8ab09c48 100644 --- a/src/client.rs +++ b/src/client.rs @@ -2547,10 +2547,21 @@ impl LoginConfigHandler { }; let mut config = self.load_config(); config.info = serde; + #[cfg(feature = "swan_custom")] + { + // Managed authorization is never a saved peer password. Remove + // inherited credentials rather than syncing them to address books. + self.password.clear();self.remember=false;config.password.clear(); + } + #[cfg(not(feature = "swan_custom"))] let password = self.password.clone(); + #[cfg(not(feature = "swan_custom"))] let password0 = config.password.clone(); + #[cfg(not(feature = "swan_custom"))] let remember = self.remember; + #[cfg(not(feature = "swan_custom"))] let hash = self.hash.clone(); + #[cfg(not(feature = "swan_custom"))] if remember { // remember is true: use PeerConfig password or ui login // not sync shared password to recent @@ -2583,7 +2594,7 @@ impl LoginConfigHandler { .options .insert("force-always-relay".to_owned(), "Y".to_owned()); } - #[cfg(feature = "flutter")] + #[cfg(all(feature = "flutter",not(feature = "swan_custom")))] { // sync connected password to personal ab automatically if it is not shared password if !config.password.is_empty() @@ -3665,6 +3676,12 @@ pub async fn handle_login_from_ui( remember: bool, peer: &mut Stream, ) { + #[cfg(feature = "swan_custom")] + { + let _=(&lc,&os_username,&os_password,&password,remember,&peer); + log::warn!("Managed login requires company authorization; legacy UI password submission refused"); + return; + } let mut hash_password = if password.is_empty() { let mut password2 = lc.read().unwrap().password.clone(); if password2.is_empty() { From 0c71544014dff5a5284a0adb9738026462e7f92a Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Fri, 2 Oct 2026 13:36:18 +0200 Subject: [PATCH 075/652] Acknowledge durable customer consent changes in the UI --- docs/ACCEPTANCE.md | 9 +++ .../lib/desktop/pages/desktop_home_page.dart | 37 ++++++++--- src/ipc.rs | 62 ++++++++++--------- src/managed.rs | 4 ++ 4 files changed, 73 insertions(+), 39 deletions(-) diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index a8cb46ff02f..5361a857b91 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -410,3 +410,12 @@ use their separate native path. These guards apply only to the managed build, preserving baseline source behavior. Rust parsing and diff checks pass. Native compilation, saved-settings inspection and negative legacy-UI tests remain unverified; receiver-side signed authorization remains the access boundary. + +Customer consent changes now use acknowledged asynchronous service IPC through +the existing company request bridge. Enable acknowledges only after server +acceptance and local persistence. Revoke persists locally first, then reports +whether server sync succeeded; the UI distinguishes pending sync and refuses to +claim success after an error/timeout. Consent buttons prevent overlapping UI +requests. Existing consent revision checks still reject a delayed enable after +revocation. Rust parsing and diff checks pass. Full Windows service IPC, offline +revocation feedback, persistence failures and UI rendering remain unverified. diff --git a/flutter/lib/desktop/pages/desktop_home_page.dart b/flutter/lib/desktop/pages/desktop_home_page.dart index 93b2ab38a53..4fe5fbde751 100644 --- a/flutter/lib/desktop/pages/desktop_home_page.dart +++ b/flutter/lib/desktop/pages/desktop_home_page.dart @@ -54,6 +54,30 @@ class _DesktopHomePageState extends State var watchIsCanRecordAudio = false; Timer? _updateTimer; Timer? _companyRefreshTimer; + bool _companyConsentBusy = false; + + Future _changeCompanyConsent(bool enabled) async { + if (_companyConsentBusy) return; + setState(() { _companyConsentBusy = true; }); + String message; + try { + final text = await bind.mainCompanyRequest(request: jsonEncode({ + 'action': 'customer-consent', 'enabled': enabled, + })).first.timeout(const Duration(seconds: 25)); + final response = jsonDecode(text) as Map; + if (response['ok'] != true) throw StateError('Consent not confirmed'); + final data = response['data'] as Map; + if (data['unattended'] != enabled) throw StateError('Consent not confirmed'); + message = enabled ? 'Unattended access enabled.' + : data['server_synced'] == true ? 'Unattended access revoked.' + : 'Unattended access revoked on this computer. Server synchronization is pending.'; + } catch (_) { + message = 'The consent change was not confirmed. Check the support status before relying on it.'; + } finally { + if (mounted) setState(() { _companyConsentBusy = false; }); + } + if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(message))); + } bool isCardClosed = false; final RxBool _editHover = false.obs; @@ -280,16 +304,13 @@ class _DesktopHomePageState extends State CompanyContactLinks(company: company), if (company['unattended'] == true) TextButton.icon( - onPressed: () async { - await bind.mainSetCommon(key: 'company-revoke-unattended', value: 'N'); - if (mounted) setState(() {}); - }, + onPressed: _companyConsentBusy ? null : () => _changeCompanyConsent(false), icon: const Icon(Icons.shield_outlined), label: const Text('Revoke unattended access'), ), if (isReady && company['unattended'] != true && company['allow_unattended'] == true) TextButton.icon( - onPressed: () async { + onPressed: _companyConsentBusy ? null : () async { final accepted = await showDialog( context: context, builder: (dialogContext) => AlertDialog( @@ -302,11 +323,7 @@ class _DesktopHomePageState extends State ), ); if (accepted != true || !mounted) return; - await bind.mainSetCommon(key: 'company-allow-unattended', value: 'Y'); - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Consent change requested. Unattended access stays disabled until the company accepts it.'))); - setState(() {}); - } + await _changeCompanyConsent(true); }, icon: const Icon(Icons.shield_outlined), label: const Text('Allow unattended support'), diff --git a/src/ipc.rs b/src/ipc.rs index 413cd0c9f44..989756255df 100644 --- a/src/ipc.rs +++ b/src/ipc.rs @@ -885,35 +885,25 @@ async fn handle(data: Data, stream: &mut Connection) { } Some(value) => { #[cfg(feature = "swan_custom")] - if name == "company-allow-unattended" && value == "Y" { - let directory=swan_agent::state_directory(); - match swan_agent::AgentState::load(&directory) { - Ok(mut state)=>{ - match state.consent(true).await { - Ok(())=>if let Err(error)=state.set_local_consent(&directory,true){log::error!("Cannot persist explicit unattended consent: {}",error);}, - Err(error)=>log::warn!("Company did not accept unattended consent: {}",error), - } - } - Err(error)=>log::error!("Cannot load current company policy: {}",error), - } - return; - } - #[cfg(feature = "swan_custom")] - if name == "company-revoke-unattended" && value == "N" { - match swan_agent::AgentState::load_for_refresh(&swan_agent::state_directory()) { - Ok(mut state) => { - match state.set_local_consent(&swan_agent::state_directory(),false) { - Ok(()) => { - // Revocation is already durable locally even if the API is offline. - if let Err(error) = state.consent(false).await { - log::warn!("Server consent synchronization failed: {}", error); - } - } - Err(error) => log::error!("Cannot persist consent revocation: {}", error), - } - } - Err(error) => log::error!("Cannot load company consent: {}", error), - } + if (name == "company-allow-unattended" && value == "Y") || (name == "company-revoke-unattended" && value == "N") { + let enabled=name=="company-allow-unattended"; + let result=async { + let directory=swan_agent::state_directory(); + let mut state=if enabled{swan_agent::AgentState::load(&directory)?}else{swan_agent::AgentState::load_for_refresh(&directory)?}; + let synced=if enabled { + state.consent(true).await?; + state.set_local_consent(&directory,true)?;true + }else{ + state.set_local_consent(&directory,false)?; + match state.consent(false).await {Ok(())=>true,Err(error)=>{log::warn!("Server consent synchronization failed: {}",error);false}} + }; + Ok::<_,hbb_common::anyhow::Error>(serde_json::json!({"ok":true,"unattended":enabled,"server_synced":synced})) + }.await; + let response=match result { + Ok(response)=>response, + Err(error)=>{log::error!("Cannot persist company consent: {}",error);serde_json::json!({"ok":false})} + }; + allow_err!(stream.send(&Data::Config((name,Some(response.to_string())))).await); return; } let mut updated = true; @@ -1545,6 +1535,20 @@ async fn get_config_async(name: &str, ms_timeout: u64) -> ResultTypeResultType { + let name=if enabled{"company-allow-unattended"}else{"company-revoke-unattended"}; + let mut connection=connect(1000, "").await?; + connection.send(&Data::Config((name.into(),Some(if enabled{"Y"}else{"N"}.into())))).await?; + if let Some(Data::Config((returned,Some(value))))=connection.next_timeout(20_000).await? { + if returned==name { + let response:serde_json::Value=serde_json::from_str(&value)?; + if response["ok"]==true && response["unattended"]==enabled{return Ok(response);} + } + } + bail!("Company consent change was not confirmed") +} + pub async fn set_config_async(name: &str, value: String) -> ResultType<()> { let mut c = connect(1000, "").await?; c.send_config(name, value).await?; diff --git a/src/managed.rs b/src/managed.rs index 6a5ed2873c8..39c274758b3 100644 --- a/src/managed.rs +++ b/src/managed.rs @@ -36,6 +36,10 @@ async fn technician_request_inner(request:&str)->ResultType { if request.len()>8192 {bail!("Request too large");} let input:serde_json::Value=serde_json::from_str(request)?; let action=input["action"].as_str().ok_or_else(||anyhow!("Missing action"))?; + if action=="customer-consent" { + let enabled=input["enabled"].as_bool().ok_or_else(||anyhow!("Missing consent choice"))?; + return crate::ipc::set_company_consent(enabled).await; + } let directory=swan_agent::state_directory(); let state=AgentState::load_for_refresh(&directory)?; if state.bootstrap.edition!=Edition::Technician {bail!("Technician edition required");} From 1ee79fcdfd560b04b854e1fbd482fe52b0541b5b Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Fri, 2 Oct 2026 13:46:09 +0200 Subject: [PATCH 076/652] Record clean Server 2016 VM reaching Windows Setup --- docs/ACCEPTANCE.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index 5361a857b91..3a70ad05de3 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -419,3 +419,12 @@ claim success after an error/timeout. Consent buttons prevent overlapping UI requests. Existing consent revision checks still reject a delayed enable after revocation. Rust parsing and diff checks pass. Full Windows service IPC, offline revocation feedback, persistence failures and UI rendering remain unverified. + +An independent official Windows Server 2016 evaluation VM is now provisioned +and running with a separate 64 GB disk, 2 GB memory, one CPU and user NAT without +forwarded ports. Its fresh screenshot confirms the Windows Setup language page. +The evaluation download completed with recorded origin/hash; private VM metadata +and screenshots remain outside the repository. The existing Server 2025 VM is +still running at its boot screen and was not restarted. No clean Windows install +or product installation has passed yet. Native build 37002003558 is queued at +0c71544, covering the acknowledged customer-consent source. From b531b164dbfb28065f0d6fc71433965f8a59b771 Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Fri, 2 Oct 2026 14:05:51 +0200 Subject: [PATCH 077/652] Include HTTPS certificate storage in encrypted deployment backups --- docs/ACCEPTANCE.md | 11 +++++ docs/SERVER_BACKUP.md | 22 +++++++-- product/management/src/backup.rs | 81 +++++++++++++++++++++++++++----- product/management/src/main.rs | 4 +- 4 files changed, 101 insertions(+), 17 deletions(-) diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index 3a70ad05de3..b73db91b96a 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -428,3 +428,14 @@ and screenshots remain outside the repository. The existing Server 2025 VM is still running at its boot screen and was not restarted. No clean Windows install or product installation has passed yet. Native build 37002003558 is queued at 0c71544, covering the acknowledged customer-consent source. + +The Server 2016 VM has now selected Standard Evaluation with Desktop Experience +and started installation on its empty virtual disk. File preparation reached +81%; this is provisioning progress, not a completed OS or product test. + +Management backup accepts `--tls-directory` for the complete HTTPS storage tree. +Nested certificate-key and ACME account fixtures survive authenticated encrypted +export/restore. Restore rejects traversal, Windows filename collisions and +file/directory conflicts before creating its target. Nine management tests pass; +the three backup tests also pass after adding malformed encrypted archive cases. +Live certificate renewal, transport restore and migration rollback remain open. diff --git a/docs/SERVER_BACKUP.md b/docs/SERVER_BACKUP.md index 9f1234de73e..bc8598dfb5b 100644 --- a/docs/SERVER_BACKUP.md +++ b/docs/SERVER_BACKUP.md @@ -13,13 +13,21 @@ swan-management backup company.swan-backup For a deployment export, replace the path placeholders with company-controlled paths. Omit `--tls-identity` only when HTTPS identity will be reissued separately. -The TLS argument accepts one existing identity file, such as a PFX or PEM bundle; -it does not export an entire ACME storage directory. +The TLS identity argument accepts one existing file, such as a PFX or PEM bundle. +Use `--tls-directory HTTPS_STORAGE_DIRECTORY` for the complete certificate-service +storage tree, including ACME account and certificate files. For Caddy, supply its +persistent data directory. Stop certificate renewal and other writes to this tree +for the duration of export. Export reads it without stopping or modifying services. ```text swan-management backup company.swan-backup --transport-directory TRANSPORT_DATA --deployment-env PRIVATE_ENV_FILE --tls-identity TLS_IDENTITY_FILE ``` +Alternatively, replace the final option with `--tls-directory HTTPS_STORAGE_DIRECTORY`. +Both options can be included when the deployment uses separate storage and identity files. +Directory exports reject symbolic links, Windows reparse points, special files, +unsafe paths, and more than 4096 total files. The 512 MiB total plaintext limit applies. + The transport directory must contain both `id_ed25519` and `id_ed25519.pub`. When present, `db_v2.sqlite3` is also snapshotted using SQLite's backup API. Management uses the same API for its database, including committed WAL data. @@ -41,8 +49,9 @@ swan-management restore company.swan-backup Set `SWAN_DATA_DIR` to that empty replacement directory before running restore. The restorer authenticates and validates the archive before writing contents. -It permits fixed filenames only, rejects duplicate names and incomplete key -pairs, and protects restored files with private Unix permissions or Windows +It permits fixed management filenames and validated relative paths under +`tls-storage/`, rejects duplicate or Windows-colliding names, file/directory +conflicts and incomplete key pairs, and protects restored files with private Unix permissions or Windows ACLs for the invoking account, SYSTEM and Administrators. Management files restore directly. Deployment extras are staged in that same @@ -55,6 +64,7 @@ protected directory; restore never overwrites external service files: | `transport-db.sqlite3` | Transport data directory as `db_v2.sqlite3` | | `deployment.env` | Private deployment environment configuration | | `tls-identity` | Company HTTPS identity location, or replace through certificate reissuance | +| `tls-storage/` | Complete HTTPS service data directory; copy while that service is stopped | Keep transport services stopped while placing their restored data. Preserve private ownership and permissions appropriate to the service accounts. Use the @@ -88,3 +98,7 @@ grant. Private results are recorded in the restored test directory. This does not demonstrate live hbbs/hbbr recovery, certificate renewal, recovery of changes after the backup, profile revision reconciliation or migration rollback. Those remain acceptance requirements. + +The extended storage component test additionally preserves nested certificate +private-key and ACME account fixtures through encrypted export and restore. +It does not demonstrate a running Caddy service restoring or renewing certificates. diff --git a/product/management/src/backup.rs b/product/management/src/backup.rs index edfe9a5e055..702019365f1 100644 --- a/product/management/src/backup.rs +++ b/product/management/src/backup.rs @@ -12,6 +12,37 @@ pub fn export(data:&Path,destination:&Path,password:&str)->Result<()> { export_complete(data,destination,password,None,None,None) } pub fn export_complete(data:&Path,destination:&Path,password:&str,transport:Option<&Path>,configuration:Option<&Path>,tls_identity:Option<&Path>)->Result<()> { + export_deployment(data,destination,password,transport,configuration,tls_identity,None) +} +fn tls_entry(name:&str)->bool { + name.strip_prefix("tls-storage/").is_some_and(|relative| { + !relative.is_empty() && !relative.contains(['\\',':']) && + relative.split('/').count()<=32 && relative.split('/').all(|part| { + let stem=part.split('.').next().unwrap_or_default().to_ascii_uppercase(); + !part.is_empty() && !part.ends_with(['.',' ']) && !part.chars().any(|c|c.is_control() || "<>\"|?*".contains(c)) && + !matches!(stem.as_str(),"CON"|"PRN"|"AUX"|"NUL"|"COM1"|"COM2"|"COM3"|"COM4"|"COM5"|"COM6"|"COM7"|"COM8"|"COM9"|"LPT1"|"LPT2"|"LPT3"|"LPT4"|"LPT5"|"LPT6"|"LPT7"|"LPT8"|"LPT9") + }) + }) +} +fn collect_tls(root:&Path,directory:&Path,files:&mut Vec<(String,std::path::PathBuf)>)->Result<()> { + let metadata=std::fs::symlink_metadata(directory)?; + ensure!(!metadata.file_type().is_symlink(),"TLS storage must not contain symbolic links"); + #[cfg(windows)] {use std::os::windows::fs::MetadataExt;ensure!(metadata.file_attributes()&0x400==0,"TLS storage must not contain reparse points");} + ensure!(metadata.is_dir(),"TLS storage must be a directory"); + for entry in std::fs::read_dir(directory)? { + let path=entry?.path();let metadata=std::fs::symlink_metadata(&path)?; + ensure!(!metadata.file_type().is_symlink(),"TLS storage must not contain symbolic links"); + #[cfg(windows)] {use std::os::windows::fs::MetadataExt;ensure!(metadata.file_attributes()&0x400==0,"TLS storage must not contain reparse points");} + let relative=path.strip_prefix(root)?.to_str().context("TLS storage filenames must be UTF-8")?.replace('\\',"/"); + let name=format!("tls-storage/{relative}");ensure!(tls_entry(&name),"Unsupported TLS storage path"); + if metadata.is_dir(){collect_tls(root,&path,files)?;}else{ + ensure!(metadata.is_file(),"TLS storage must contain regular files only"); + ensure!(files.len()<4096,"Too many backup files");files.push((name,path)); + } + } + Ok(()) +} +pub fn export_deployment(data:&Path,destination:&Path,password:&str,transport:Option<&Path>,configuration:Option<&Path>,tls_identity:Option<&Path>,tls_directory:Option<&Path>)->Result<()> { ensure!(!destination.exists(),"Refusing to overwrite an existing backup"); let db=rusqlite::Connection::open_with_flags(data.join("management.sqlite3"),rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY)?; let snapshot=data.join(format!("backup-{}.sqlite3",swan_protocol::random_token())); @@ -20,18 +51,19 @@ pub fn export_complete(data:&Path,destination:&Path,password:&str,transport:Opti let result=(||->Result<()> { let mut archive=zip::ZipWriter::new(Cursor::new(Vec::new())); let options=zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated); - let mut files=vec![("management.sqlite3",snapshot.clone()),("profile-key.hex",data.join("profile-key.hex")),("setup-token.txt",data.join("setup-token.txt"))]; + let mut files=vec![("management.sqlite3".to_string(),snapshot.clone()),("profile-key.hex".to_string(),data.join("profile-key.hex")),("setup-token.txt".to_string(),data.join("setup-token.txt"))]; if let Some(directory)=transport { - files.push(("transport-id_ed25519",directory.join("id_ed25519")));files.push(("transport-id_ed25519.pub",directory.join("id_ed25519.pub"))); + files.push(("transport-id_ed25519".into(),directory.join("id_ed25519")));files.push(("transport-id_ed25519.pub".into(),directory.join("id_ed25519.pub"))); if directory.join("db_v2.sqlite3").exists(){ let transport_snapshot=data.join(format!("backup-transport-{}.sqlite3",swan_protocol::random_token()));snapshots.push(transport_snapshot.clone()); let transport_db=rusqlite::Connection::open_with_flags(directory.join("db_v2.sqlite3"),rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY)?; transport_db.backup(rusqlite::DatabaseName::Main,&transport_snapshot,None)?; - files.push(("transport-db.sqlite3",transport_snapshot)); + files.push(("transport-db.sqlite3".into(),transport_snapshot)); } } - if let Some(path)=configuration {files.push(("deployment.env",path.to_path_buf()));} - if let Some(path)=tls_identity {files.push(("tls-identity",path.to_path_buf()));} + if let Some(path)=configuration {files.push(("deployment.env".into(),path.to_path_buf()));} + if let Some(path)=tls_identity {files.push(("tls-identity".into(),path.to_path_buf()));} + if let Some(directory)=tls_directory {collect_tls(directory,directory,&mut files)?;} let mut total=0u64; for (name,path) in files { let size=std::fs::metadata(&path)?.len();total=total.checked_add(size).context("Backup size overflow")?; @@ -58,17 +90,22 @@ pub fn restore(data:&Path,source:&Path,password:&str)->Result<()> { let cipher=Aes256Gcm::new_from_slice(&key(password,salt)?).map_err(|_|anyhow::anyhow!("Invalid decryption key"))?; let plaintext=cipher.decrypt(Nonce::from_slice(nonce),&bytes[offset+28..]).map_err(|_|anyhow::anyhow!("Backup password incorrect or backup modified"))?; let mut archive=zip::ZipArchive::new(Cursor::new(plaintext))?; - ensure!((3..=8).contains(&archive.len()),"Unexpected backup contents"); + ensure!((3..=4096).contains(&archive.len()),"Unexpected backup contents"); let allowed=["management.sqlite3","profile-key.hex","setup-token.txt","transport-id_ed25519","transport-id_ed25519.pub","transport-db.sqlite3","deployment.env","tls-identity"]; let mut names=std::collections::HashSet::new(); - for index in 0..archive.len(){let entry=archive.by_index(index)?;ensure!(allowed.contains(&entry.name()) && names.insert(entry.name().to_string()),"Unknown or duplicate backup entry");} + for index in 0..archive.len(){let entry=archive.by_index(index)?;ensure!((allowed.contains(&entry.name()) || tls_entry(entry.name())) && !entry.is_dir() && names.insert(entry.name().to_string()),"Unknown or duplicate backup entry");} + let mut portable_names=std::collections::HashSet::new(); + for name in &names {ensure!(portable_names.insert(name.to_ascii_lowercase()),"Backup filenames collide on Windows");} + for name in &portable_names { + let mut parent=name.as_str();while let Some((prefix,_))=parent.rsplit_once('/') {ensure!(!portable_names.contains(prefix),"Backup file conflicts with a directory");parent=prefix;} + } ensure!(names.contains("transport-id_ed25519")==names.contains("transport-id_ed25519.pub"),"Incomplete transport trust key pair"); for required in &allowed[..3]{ensure!(names.contains(*required),"Missing management backup entry");} // Validate all contents before creating anything. Never extract arbitrary archive paths. let mut files=Vec::new(); let mut total=0u64; - for name in allowed.into_iter().filter(|name|names.contains(*name)) { - let mut entry=archive.by_name(name)?;total=total.checked_add(entry.size()).context("Backup size overflow")?;ensure!(total<=512*1024*1024,"Backup contents too large"); + for name in names { + let mut entry=archive.by_name(&name)?;total=total.checked_add(entry.size()).context("Backup size overflow")?;ensure!(total<=512*1024*1024,"Backup contents too large"); let mut content=Vec::new();entry.read_to_end(&mut content)?;files.push((name,content)); } std::fs::create_dir_all(data)?; @@ -85,7 +122,8 @@ pub fn restore(data:&Path,source:&Path,password:&str)->Result<()> { for (name,content) in files { let mut options=std::fs::OpenOptions::new();options.create_new(true).write(true); #[cfg(unix)] {use std::os::unix::fs::OpenOptionsExt;options.mode(0o600);} - let mut file=options.open(data.join(name))?;file.write_all(&content)?;file.sync_all()?; + let path=data.join(name);if let Some(parent)=path.parent(){std::fs::create_dir_all(parent)?;} + let mut file=options.open(path)?;file.write_all(&content)?;file.sync_all()?; } Ok(()) } @@ -102,16 +140,37 @@ mod tests { std::fs::write(transport.join("id_ed25519"),b"private transport key").unwrap();std::fs::write(transport.join("id_ed25519.pub"),b"public transport key").unwrap(); let db=rusqlite::Connection::open(transport.join("db_v2.sqlite3")).unwrap();db.execute_batch("CREATE TABLE peers(id TEXT);INSERT INTO peers VALUES('retained peer');").unwrap();drop(db); let configuration=root.join("company.env");let tls=root.join("server.pfx");std::fs::write(&configuration,b"company settings and TLS password").unwrap();std::fs::write(&tls,b"TLS identity fixture").unwrap(); - let archive=root.join("complete.swan-backup");export_complete(&data,&archive,"complete backup passphrase",Some(&transport),Some(&configuration),Some(&tls)).unwrap(); + let tls_storage=root.join("caddy");let certificate=tls_storage.join("certificates/acme.example/company.example/company.key"); + std::fs::create_dir_all(certificate.parent().unwrap()).unwrap();std::fs::write(&certificate,b"ACME private key fixture").unwrap(); + std::fs::write(tls_storage.join("account.json"),b"ACME account fixture").unwrap(); + let archive=root.join("complete.swan-backup");export_deployment(&data,&archive,"complete backup passphrase",Some(&transport),Some(&configuration),Some(&tls),Some(&tls_storage)).unwrap(); let destination=root.join("restored");restore(&destination,&archive,"complete backup passphrase").unwrap(); assert_eq!(std::fs::read(destination.join("transport-id_ed25519")).unwrap(),b"private transport key");assert_eq!(std::fs::read(destination.join("transport-id_ed25519.pub")).unwrap(),b"public transport key"); assert_eq!(std::fs::read(destination.join("deployment.env")).unwrap(),std::fs::read(configuration).unwrap());assert_eq!(std::fs::read(destination.join("tls-identity")).unwrap(),std::fs::read(tls).unwrap()); + assert_eq!(std::fs::read(destination.join("tls-storage/certificates/acme.example/company.example/company.key")).unwrap(),std::fs::read(certificate).unwrap()); + assert_eq!(std::fs::read(destination.join("tls-storage/account.json")).unwrap(),b"ACME account fixture"); let db=rusqlite::Connection::open(destination.join("transport-db.sqlite3")).unwrap();assert_eq!(db.query_row("SELECT id FROM peers",[],|row|row.get::<_,String>(0)).unwrap(),"retained peer");drop(db); let mut modified=std::fs::read(&archive).unwrap();let last=modified.len()-1;modified[last]^=1;let tampered=root.join("tampered.swan-backup");std::fs::write(&tampered,modified).unwrap(); let rejected=root.join("rejected");assert!(restore(&rejected,&tampered,"complete backup passphrase").is_err());assert!(!rejected.exists()); std::fs::remove_dir_all(root).unwrap(); } #[test] + fn tls_archive_paths_cannot_escape_restore_or_collide_with_management() { + assert!(tls_entry("tls-storage/certificates/company.example/company.key")); + for name in ["tls-storage/../profile-key.hex","tls-storage/./key","tls-storage//key","tls-storage/C:/key","tls-storage/dir\\key","tls-storage/key\0","tls-storage/","tls-storage/key.","tls-storage/key ","tls-storage/CON.key","tls-storage/NUL","profile-key.hex"] {assert!(!tls_entry(name),"{name:?}");} + let root=std::env::temp_dir().join(format!("swan-invalid-tls-backup-{}",swan_protocol::random_token()));std::fs::create_dir(&root).unwrap(); + for (index,extras) in [vec!["tls-storage/../outside"],vec!["tls-storage/Key","tls-storage/key"],vec!["tls-storage/file","tls-storage/file/child"]].into_iter().enumerate() { + let mut archive=zip::ZipWriter::new(Cursor::new(Vec::new())); + for name in ["management.sqlite3","profile-key.hex","setup-token.txt"].into_iter().chain(extras) {archive.start_file(name,zip::write::SimpleFileOptions::default()).unwrap();archive.write_all(b"fixture").unwrap();} + let plaintext=archive.finish().unwrap().into_inner();let salt=[1u8;16];let nonce=[2u8;12]; + let cipher=Aes256Gcm::new_from_slice(&key("invalid archive test password",&salt).unwrap()).unwrap();let ciphertext=cipher.encrypt(Nonce::from_slice(&nonce),plaintext.as_ref()).unwrap(); + let mut bytes=HEADER.to_vec();bytes.extend(salt);bytes.extend(nonce);bytes.extend(ciphertext); + let source=root.join(format!("{index}.backup"));std::fs::write(&source,bytes).unwrap();let destination=root.join(format!("restore-{index}")); + assert!(restore(&destination,&source,"invalid archive test password").is_err());assert!(!destination.exists()); + } + std::fs::remove_dir_all(root).unwrap(); + } + #[test] fn backup_roundtrip_rejects_wrong_password_and_nonempty_restore() { let root=std::env::temp_dir().join(format!("swan-backup-{}",swan_protocol::random_token())); let data=root.join("data");std::fs::create_dir_all(&data).unwrap(); diff --git a/product/management/src/main.rs b/product/management/src/main.rs index 6c12940c36f..17d1f5d5379 100644 --- a/product/management/src/main.rs +++ b/product/management/src/main.rs @@ -33,11 +33,11 @@ async fn run(stop:tokio::sync::oneshot::Receiver<()>)->Result<()> { let mut inputs=std::collections::HashMap::new(); anyhow::ensure!(extra.len()%2==0,"Backup options require explicit paths"); for pair in extra.chunks_exact(2){ - anyhow::ensure!(operation=="backup" && matches!(pair[0].as_str(),"--transport-directory"|"--deployment-env"|"--tls-identity"),"Unknown backup option"); + anyhow::ensure!(operation=="backup" && matches!(pair[0].as_str(),"--transport-directory"|"--deployment-env"|"--tls-identity"|"--tls-directory"),"Unknown backup option"); anyhow::ensure!(inputs.insert(pair[0].clone(),PathBuf::from(&pair[1])).is_none(),"Duplicate backup option"); } if operation=="backup" { - backup::export_complete(&data,&path,&passphrase,inputs.get("--transport-directory").map(|p|p.as_path()),inputs.get("--deployment-env").map(|p|p.as_path()),inputs.get("--tls-identity").map(|p|p.as_path())) + backup::export_deployment(&data,&path,&passphrase,inputs.get("--transport-directory").map(|p|p.as_path()),inputs.get("--deployment-env").map(|p|p.as_path()),inputs.get("--tls-identity").map(|p|p.as_path()),inputs.get("--tls-directory").map(|p|p.as_path())) }else{backup::restore(&data,&path,&passphrase)} }).await??; println!("Encrypted {} completed.",args[1]);return Ok(()); From 6713ff31a730ce37c65604cc2b2639e14af8f2f6 Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Fri, 2 Oct 2026 14:14:16 +0200 Subject: [PATCH 078/652] Clean up owned configuration tasks during customer MSI uninstall --- deployment/windows/CUSTOMER-MSI.md | 13 +++++++++++ deployment/windows/Remove-Configuration.ps1 | 19 ++++++++++++++++ deployment/windows/Restore-Configuration.ps1 | 23 ++++++++++++++++++++ deployment/windows/generate-customer-msi.py | 9 ++++++++ deployment/windows/test_customer_msi.py | 11 ++++++++++ docs/ACCEPTANCE.md | 13 +++++++++++ product/agent/src/main.rs | 17 +++++++++++++++ 7 files changed, 105 insertions(+) create mode 100644 deployment/windows/Remove-Configuration.ps1 create mode 100644 deployment/windows/Restore-Configuration.ps1 diff --git a/deployment/windows/CUSTOMER-MSI.md b/deployment/windows/CUSTOMER-MSI.md index cea23ae8764..693993a7406 100644 --- a/deployment/windows/CUSTOMER-MSI.md +++ b/deployment/windows/CUSTOMER-MSI.md @@ -59,5 +59,18 @@ interrupted-install rollback remain unverified. Native build CI now includes both MSI recipes; its exact results must be recorded separately. MSI service rules and a successful build do not establish these acceptance conditions. +Customer MSI uninstall now invokes its installed configuration agent under +SYSTEM before file removal to remove the separately registered configuration +task. The task's executable path, `watch` arguments and SYSTEM principal must +match this installation; an unrelated task is refused. Company identity and +consent files remain protected for explicit reinstallation. Major upgrades skip +task removal. A paired MSI rollback action restores the previous enabled/running +state after an interrupted uninstall, using protected rollback state and a +validly signed restored agent. A task absent before cleanup is not created by +rollback. The agent compiles on Windows and both scripts parse; three authoring +tests pass. Pinned WiX compiled a real unsigned package and its Installer tables +confirm rollback, cleanup and file-removal order. Actual task deletion, MSI +rollback and reinstall on clean guests remain unverified. + References: [WiX service installation](https://docs.firegiant.com/wix/schema/wxs/serviceinstall/) and [Windows-owned file copying](https://docs.firegiant.com/wix/schema/wxs/copyfile/). diff --git a/deployment/windows/Remove-Configuration.ps1 b/deployment/windows/Remove-Configuration.ps1 new file mode 100644 index 00000000000..760b5f9baa3 --- /dev/null +++ b/deployment/windows/Remove-Configuration.ps1 @@ -0,0 +1,19 @@ +[CmdletBinding()] +param([Parameter(Mandatory=$true)][string]$Directory) +$ErrorActionPreference = 'Stop' +$expectedAgent = [IO.Path]::GetFullPath((Join-Path $Directory 'swan-agent.exe')) +$rollbackPath = Join-Path $Directory 'configuration-task-rollback.json' +if (Test-Path -LiteralPath $rollbackPath) { Remove-Item -LiteralPath $rollbackPath } +$tasks = @(Get-ScheduledTask | Where-Object { $_.TaskName -eq 'Swan Company Configuration' }) +if ($tasks.Count -eq 0) { return } +if ($tasks.Count -ne 1) { throw 'Ambiguous company configuration task.' } +$task = $tasks[0] +if ($task.Actions.Count -ne 1 -or [IO.Path]::GetFullPath($task.Actions[0].Execute) -ine $expectedAgent -or $task.Actions[0].Arguments -cne 'watch' -or $task.Principal.UserId -notin @('SYSTEM','S-1-5-18','NT AUTHORITY\SYSTEM')) { + throw 'Configuration task does not belong to this installation.' +} +@{enabled=($task.State -ne 'Disabled');running=($task.State -eq 'Running')} | ConvertTo-Json | Set-Content -LiteralPath $rollbackPath -Encoding UTF8 +$task | Disable-ScheduledTask | Out-Null +$task | Stop-ScheduledTask +$task | Unregister-ScheduledTask -Confirm:$false +# Preserve company identity and consent data for explicit repair/reinstallation. +# MSI removes its owned agent binary after this action exits. diff --git a/deployment/windows/Restore-Configuration.ps1 b/deployment/windows/Restore-Configuration.ps1 new file mode 100644 index 00000000000..dfc3f249f33 --- /dev/null +++ b/deployment/windows/Restore-Configuration.ps1 @@ -0,0 +1,23 @@ +[CmdletBinding()] +param([Parameter(Mandatory=$true)][string]$Directory) +$ErrorActionPreference = 'Stop' +$expectedAgent = [IO.Path]::GetFullPath((Join-Path $Directory 'swan-agent.exe')) +$rollbackPath = Join-Path $Directory 'configuration-task-rollback.json' +if (-not (Test-Path -LiteralPath $rollbackPath)) { return } +$previous = Get-Content -LiteralPath $rollbackPath -Raw | ConvertFrom-Json +if ($previous.enabled -isnot [bool] -or $previous.running -isnot [bool]) { throw 'Invalid configuration task rollback state.' } +$tasks = @(Get-ScheduledTask | Where-Object { $_.TaskName -eq 'Swan Company Configuration' }) +if ($tasks.Count -gt 1) { throw 'Ambiguous company configuration task.' } +if ($tasks.Count -eq 1) { + $task = $tasks[0] + if ($task.Actions.Count -ne 1 -or [IO.Path]::GetFullPath($task.Actions[0].Execute) -ine $expectedAgent -or $task.Actions[0].Arguments -cne 'watch' -or $task.Principal.UserId -notin @('SYSTEM','S-1-5-18','NT AUTHORITY\SYSTEM')) { throw 'Configuration task does not belong to this installation.' } +} else { +if ((Get-AuthenticodeSignature -LiteralPath $expectedAgent).Status -ne 'Valid') { throw 'Cannot restore the configuration task with an untrusted agent.' } +$action = New-ScheduledTaskAction -Execute $expectedAgent -Argument 'watch' +$trigger = New-ScheduledTaskTrigger -AtStartup +$settings = New-ScheduledTaskSettingsSet -RestartCount 3 -RestartInterval (New-TimeSpan -Minutes 1) -ExecutionTimeLimit ([TimeSpan]::Zero) +Register-ScheduledTask -TaskName 'Swan Company Configuration' -Action $action -Trigger $trigger -Settings $settings -User 'SYSTEM' -RunLevel Highest | Out-Null +} +if ($previous.enabled) { Enable-ScheduledTask -TaskName 'Swan Company Configuration' | Out-Null } else { Disable-ScheduledTask -TaskName 'Swan Company Configuration' | Out-Null } +if ($previous.running) { Start-ScheduledTask -TaskName 'Swan Company Configuration' } +Remove-Item -LiteralPath $rollbackPath diff --git a/deployment/windows/generate-customer-msi.py b/deployment/windows/generate-customer-msi.py index d4d7b3a4dc1..620f5ac9e75 100644 --- a/deployment/windows/generate-customer-msi.py +++ b/deployment/windows/generate-customer-msi.py @@ -88,6 +88,15 @@ def author(source, agent, version): component = element(state, "Component", Id="ConfigurationAgent", Guid=str(uuid.uuid5(UPGRADE, "configuration-agent")).upper(), Bitness="always64") element(component, "File", Id="ConfigurationAgentExe", Name="swan-agent.exe", Source=str(agent), KeyPath="yes") element(feature, "ComponentRef", Id="ConfigurationAgent") + element(package, "CustomAction", Id="RemoveCompanyConfigurationTask", FileRef="ConfigurationAgentExe", + ExeCommand="remove-configuration-task", Execute="deferred", Impersonate="no", Return="check") + element(package, "CustomAction", Id="RestoreCompanyConfigurationTask", FileRef="ConfigurationAgentExe", + ExeCommand="restore-configuration-task", Execute="rollback", Impersonate="no", Return="check") + sequence = element(package, "InstallExecuteSequence") + element(sequence, "Custom", Action="RestoreCompanyConfigurationTask", Before="RemoveCompanyConfigurationTask", + Condition='REMOVE = "ALL" AND NOT UPGRADINGPRODUCTCODE') + element(sequence, "Custom", Action="RemoveCompanyConfigurationTask", Before="RemoveFiles", + Condition='REMOVE = "ALL" AND NOT UPGRADINGPRODUCTCODE') return ET.ElementTree(root), files diff --git a/deployment/windows/test_customer_msi.py b/deployment/windows/test_customer_msi.py index 1ded23ecc76..1a857b35e0e 100644 --- a/deployment/windows/test_customer_msi.py +++ b/deployment/windows/test_customer_msi.py @@ -43,6 +43,17 @@ def test_full_manifest_license_and_service_lifecycle(self): self.assertEqual(registration.attrib["Key"], "Software\\SwanRemoteSupport\\Customer") location = tree.find(".//w:SetProperty[@Id='ARPINSTALLLOCATION']", ns) self.assertEqual(location.attrib["Value"], "[INSTALLFOLDER]") + cleanup = tree.find(".//w:CustomAction[@Id='RemoveCompanyConfigurationTask']", ns) + self.assertEqual(cleanup.attrib["FileRef"], "ConfigurationAgentExe") + self.assertEqual(cleanup.attrib["Execute"], "deferred") + self.assertEqual(cleanup.attrib["Impersonate"], "no") + scheduling = tree.find(".//w:InstallExecuteSequence/w:Custom[@Action='RemoveCompanyConfigurationTask']", ns) + self.assertEqual(scheduling.attrib["Before"], "RemoveFiles") + self.assertEqual(scheduling.attrib["Condition"], 'REMOVE = "ALL" AND NOT UPGRADINGPRODUCTCODE') + rollback = tree.find(".//w:CustomAction[@Id='RestoreCompanyConfigurationTask']", ns) + self.assertEqual(rollback.attrib["Execute"], "rollback") + rollback_scheduling = tree.find(".//w:InstallExecuteSequence/w:Custom[@Action='RestoreCompanyConfigurationTask']", ns) + self.assertEqual(rollback_scheduling.attrib["Before"], "RemoveCompanyConfigurationTask") installed_files = tree.findall(".//w:File", ns) self.assertEqual(len(installed_files), len(files)+1) diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index b73db91b96a..145cd19f0e8 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -439,3 +439,16 @@ export/restore. Restore rejects traversal, Windows filename collisions and file/directory conflicts before creating its target. Nine management tests pass; the three backup tests also pass after adding malformed encrypted archive cases. Live certificate renewal, transport restore and migration rollback remain open. + +Server 2016 has completed file preparation, features and its first reboot. It +is now at the initial administrator-password screen; desktop provisioning and +product tests are pending. The computer-use authentication boundary requires +user completion of that screen. + +Customer MSI now schedules a SYSTEM configuration-task cleanup action before +file removal, with paired rollback and exclusion during major upgrades. It +checks exact task ownership, preserves company state, and records the previous +enabled/running state for rollback. Windows agent compilation, script parsing, +three MSI authoring tests and real unsigned WiX packaging pass. Installer-table +inspection verifies action order. This does not establish actual uninstall, +rollback, task cleanup or service behavior; clean-guest tests remain open. diff --git a/product/agent/src/main.rs b/product/agent/src/main.rs index 100488d0102..741fe773da7 100644 --- a/product/agent/src/main.rs +++ b/product/agent/src/main.rs @@ -12,6 +12,23 @@ async fn main()->Result<()> { let directory=state_directory(); let command=args.get(1).map(String::as_str).unwrap_or("help"); match command { + #[cfg(windows)] + "remove-configuration-task"|"restore-configuration-task"=>{ + let executable=std::env::current_exe()?; + let installed_directory=executable.parent().context("Missing installed agent directory")?; + ensure!(executable.file_name().is_some_and(|name|name.to_string_lossy().eq_ignore_ascii_case("swan-agent.exe")),"Uninstall cleanup requires the installed configuration agent"); + // MSI invokes the installed binary under SYSTEM before removing it. + // The script checks the exact task executable, arguments and owner. + let (name,contents)=if command=="remove-configuration-task" { + ("Remove-Configuration.ps1",include_str!("../../../deployment/windows/Remove-Configuration.ps1")) + }else{("Restore-Configuration.ps1",include_str!("../../../deployment/windows/Restore-Configuration.ps1"))}; + let script=installed_directory.join(name); + std::fs::write(&script,contents)?; + let powershell=PathBuf::from(std::env::var_os("SystemRoot").context("Missing Windows directory")?).join("System32/WindowsPowerShell/v1.0/powershell.exe"); + let status=std::process::Command::new(powershell).args(["-NoLogo","-NoProfile","-NonInteractive","-ExecutionPolicy","Bypass","-File"]).arg(&script).arg("-Directory").arg(installed_directory).status()?; + std::fs::remove_file(&script)?; + ensure!(status.success(),"Company configuration task cleanup failed"); + } "setup"=>{ let file=args.get(2).context("Usage: swan-agent setup bootstrap.json --accept-company")?; ensure!(args.iter().any(|a|a=="--accept-company"),"Review the company HTTPS domain and trust key; pass --accept-company only after confirming them"); From 416496479136bdb4a368ff3d4766161e44813376 Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Fri, 2 Oct 2026 14:16:42 +0200 Subject: [PATCH 079/652] Distinguish configurable endpoint builds from the preserved baseline --- .github/workflows/swan-windows-build.yml | 12 +++++++++++- Cargo.lock | 4 ++-- Cargo.toml | 2 +- docs/ACCEPTANCE.md | 7 +++++++ flutter/pubspec.yaml | 2 +- libs/portable/Cargo.toml | 2 +- 6 files changed, 23 insertions(+), 6 deletions(-) diff --git a/.github/workflows/swan-windows-build.yml b/.github/workflows/swan-windows-build.yml index a88f913ceb5..ca70cdd91fe 100644 --- a/.github/workflows/swan-windows-build.yml +++ b/.github/workflows/swan-windows-build.yml @@ -13,7 +13,7 @@ env: FLUTTER_ENGINE_SHA256: "ec8cabf36ee4ff24c8d98de25b00e70781eb03876265aee84d0fe554a110036e" VCPKG_COMMIT_ID: "120deac3062162151622ca4860575a33844ba10b" VCPKG_BINARY_SOURCES: "clear;x-gha,readwrite" - VERSION: "1.4.9" + VERSION: "1.5.0" jobs: generate-bridge: @@ -47,6 +47,16 @@ jobs: with: submodules: recursive + - name: Verify application and installer versions agree + shell: pwsh + run: | + $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.' + } + - name: Restore Flutter bridge files uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: diff --git a/Cargo.lock b/Cargo.lock index 2a93ffb29be..0106ce0f631 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7356,7 +7356,7 @@ dependencies = [ [[package]] name = "rustdesk" -version = "1.4.9" +version = "1.5.0" dependencies = [ "android-wakelock", "android_logger", @@ -7470,7 +7470,7 @@ dependencies = [ [[package]] name = "rustdesk-portable-packer" -version = "1.4.9" +version = "1.5.0" dependencies = [ "brotli", "dirs 5.0.1", diff --git a/Cargo.toml b/Cargo.toml index d87671340cf..fcb22c2f30f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rustdesk" -version = "1.4.9" +version = "1.5.0" authors = ["rustdesk "] edition = "2021" build= "build.rs" diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index 145cd19f0e8..9cfcb051724 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -452,3 +452,10 @@ enabled/running state for rollback. Windows agent compilation, script parsing, three MSI authoring tests and real unsigned WiX packaging pass. Installer-table inspection verifies action order. This does not establish actual uninstall, rollback, task cleanup or service behavior; clean-guest tests remain open. + +Configurable endpoint source now identifies itself as 1.5.0 (Flutter build 68), +separately from the tagged 1.4.9 Swan baseline. Cargo, portable packer, lockfile, +Flutter and Windows artifact versions agree in a local source check. Native CI +checks app/packer/Flutter/artifact version agreement before building. This is a +development version change; no release tag or signed production publication has +been made, and native build/upgrade behavior must still be demonstrated. diff --git a/flutter/pubspec.yaml b/flutter/pubspec.yaml index b9f8e1ccbe1..d237eb8dbac 100644 --- a/flutter/pubspec.yaml +++ b/flutter/pubspec.yaml @@ -16,7 +16,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev # Read more about iOS versioning at # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # 1.1.9-1 works for android, but for ios it becomes 1.1.91, need to set it to 1.1.9-a.1 for iOS, will get 1.1.9.1, but iOS store not allow 4 numbers -version: 1.4.9+67 +version: 1.5.0+68 environment: sdk: '^3.1.0' diff --git a/libs/portable/Cargo.toml b/libs/portable/Cargo.toml index aacfdcf9bb5..bcf08f38627 100644 --- a/libs/portable/Cargo.toml +++ b/libs/portable/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rustdesk-portable-packer" -version = "1.4.9" +version = "1.5.0" edition = "2021" description = "RustDesk Remote Desktop" From 7843334610e547eabbac43951c8c01b425a4994a Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Fri, 2 Oct 2026 14:24:57 +0200 Subject: [PATCH 080/652] Add transactional company profile key rotation and guarded migration --- docs/ACCEPTANCE.md | 10 ++++ docs/PROFILE_KEY_ROTATION.md | 53 +++++++++++++++++++ docs/SERVER_BACKUP.md | 3 ++ product/Cargo.lock | 1 + product/management/Cargo.toml | 1 + product/management/src/main.rs | 5 ++ product/management/src/schema.sql | 3 +- product/management/src/service.rs | 87 ++++++++++++++++++++++++++++++- 8 files changed, 160 insertions(+), 3 deletions(-) create mode 100644 docs/PROFILE_KEY_ROTATION.md diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index 9cfcb051724..f6c4af03a5b 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -459,3 +459,13 @@ Flutter and Windows artifact versions agree in a local source check. Native CI checks app/packer/Flutter/artifact version agreement before building. This is a development version change; no release tag or signed production publication has been made, and native build/upgrade behavior must still be demonstrated. + +Management now provides offline prepare/activate profile-key commands. They +publish a replacement under the existing key before activation, require +operator confirmation of client synchronization, and atomically update active +key/profile state in SQLite. Activation closes grants and invalidates installer +jobs with old bootstrap trust. A process lock prevents simultaneous upgraded +management instances. Schema-1 migration requires an encrypted pre-upgrade +backup; restored active keys and revisions survive component tests. Eleven +management tests pass, including rotation, migration and backup recovery. +Installed-app HTTPS rotation and live migration rollback remain unverified. diff --git a/docs/PROFILE_KEY_ROTATION.md b/docs/PROFILE_KEY_ROTATION.md new file mode 100644 index 00000000000..484f9677591 --- /dev/null +++ b/docs/PROFILE_KEY_ROTATION.md @@ -0,0 +1,53 @@ +# Company profile signing-key rotation + +These commands operate on a company's private management data directory. They +do not rotate RustDesk transport keys or software release-signing keys. Keep +`SWAN_DATA_DIR` and `SWAN_BACKUP_PASSPHRASE` in private environment configuration. +Stop the management service before either command. Upgraded servers hold an +exclusive data-directory lock; older server versions must also be stopped. + +1. Create an encrypted deployment backup and retain its passphrase separately. +2. Run `swan-management prepare-profile-key`. This generates a private pending + key in SQLite and advances the profile revision with its public replacement + key. The active signing key stays unchanged. The command prints public + information only. +3. Restart management. Installed customer and technician apps must synchronize + this transition while the old key still signs it. Verify synchronization on + the intended devices, including technician installations, before activation. + A machine that misses this transition cannot independently trust the new key; + it needs explicit trusted reconfiguration. The server currently cannot prove + that every installation received the transition. +4. Stop management again and run + `swan-management activate-profile-key --confirm-clients-synced`. + Activation checks the pending public key matches the published transition, + then atomically changes the active private key and advances the profile + revision in SQLite. The next process loads this key. Outstanding session + grants close, and old installer jobs/downloads become unavailable. Existing + device credentials, company identity, consent and release trust stay intact. +5. Restart management. Verify the public key through its HTTPS status endpoint + and verify synchronized clients accept the new signed profile. Generate new + company installer bundles and replace website/internal distribution links. + Previously downloaded old bundles must also be withdrawn by the operator. + +Activation invalidates current sessions as their authorization is revalidated. +Schedule rotation as a maintenance operation. This does not create an automatic +trust reset or permit a profile signed by an unrelated key. + +Schema version 2 stores active and pending profile-signing keys in the protected +management SQLite database. `profile-key.hex` remains the initial seed for +legacy migration; it no longer identifies the active key after rotation. Do not +edit that file to rotate keys. Encrypted backups must include the database. +Restoring the database preserves active/pending keys and their profile revisions. + +When opening a version-1 management database, the upgraded server requires +`SWAN_BACKUP_PASSPHRASE` and creates an encrypted `pre-upgrade-v1-*.swan-backup` +before migration. It refuses migration without that password. Version-1 server +binaries refuse the version-2 database; downgrade recovery uses the matching +pre-upgrade backup in an empty directory, with public endpoints unavailable. +Reconcile all changes made since that backup before exposing a restored server. + +Component tests cover process exclusion, authorized transition checks, restart, +new-key signatures, encrypted restore and pre-upgrade backup recovery. Real +HTTPS rotation across installed apps, offline machines, interrupted activation, +live session termination and complete server migration rollback remain release +acceptance requirements. diff --git a/docs/SERVER_BACKUP.md b/docs/SERVER_BACKUP.md index bc8598dfb5b..bc9b184f993 100644 --- a/docs/SERVER_BACKUP.md +++ b/docs/SERVER_BACKUP.md @@ -31,6 +31,9 @@ unsafe paths, and more than 4096 total files. The 512 MiB total plaintext limit The transport directory must contain both `id_ed25519` and `id_ed25519.pub`. When present, `db_v2.sqlite3` is also snapshotted using SQLite's backup API. Management uses the same API for its database, including committed WAL data. +Schema version 2 keeps active and pending profile-signing keys in that database; +the original `profile-key.hex` file alone is insufficient after rotation. See +[profile key rotation](PROFILE_KEY_ROTATION.md) for the migration and maintenance procedure. Stop configuration changes during the export when a coordinated snapshot of the separately stored environment, TLS identity and transport data is needed. The export does not stop or modify running transport services. diff --git a/product/Cargo.lock b/product/Cargo.lock index 5707240b66d..d361333ef5f 100644 --- a/product/Cargo.lock +++ b/product/Cargo.lock @@ -1639,6 +1639,7 @@ dependencies = [ "axum", "base64 0.22.1", "ed25519-dalek", + "fs2", "hex", "http-body-util", "rand 0.8.8", diff --git a/product/management/Cargo.toml b/product/management/Cargo.toml index 448fa8a2114..8aaac0e5bfa 100644 --- a/product/management/Cargo.toml +++ b/product/management/Cargo.toml @@ -17,6 +17,7 @@ ed25519-dalek.workspace = true rand.workspace = true sha2.workspace = true hex.workspace = true +fs2 = "0.4" tokio.workspace = true uuid.workspace = true axum = "0.8" diff --git a/product/management/src/main.rs b/product/management/src/main.rs index 17d1f5d5379..8ef7b76f2af 100644 --- a/product/management/src/main.rs +++ b/product/management/src/main.rs @@ -43,6 +43,11 @@ async fn run(stop:tokio::sync::oneshot::Receiver<()>)->Result<()> { println!("Encrypted {} completed.",args[1]);return Ok(()); } let store = Arc::new(Store::open(&data)?); + if matches!(args.get(1).map(String::as_str),Some("prepare-profile-key"|"activate-profile-key")) { + if args[1]=="activate-profile-key" {anyhow::ensure!(args.iter().any(|arg|arg=="--confirm-clients-synced"),"First synchronize the prepared signed transition to installed clients; activation requires --confirm-clients-synced");} + let public=if args[1]=="prepare-profile-key" {store.prepare_profile_rotation()?}else{store.activate_profile_rotation()?}; + println!("Profile public key: {public}");return Ok(()); + } let address: SocketAddr = std::env::var("SWAN_LISTEN").unwrap_or_else(|_| "127.0.0.1:8080".into()).parse()?; let app: Router = service::router(store); let listener = tokio::net::TcpListener::bind(address).await.context("Bind management listener")?; diff --git a/product/management/src/schema.sql b/product/management/src/schema.sql index 33337a8bafe..e56a0535059 100644 --- a/product/management/src/schema.sql +++ b/product/management/src/schema.sql @@ -10,4 +10,5 @@ CREATE TABLE IF NOT EXISTS workers(id TEXT PRIMARY KEY,token_hash TEXT NOT NULL CREATE TABLE IF NOT EXISTS builds(id TEXT PRIMARY KEY,body TEXT NOT NULL,state TEXT NOT NULL,worker TEXT NOT NULL DEFAULT '',claimed_at INTEGER NOT NULL DEFAULT 0,result TEXT NOT NULL DEFAULT ''); CREATE TABLE IF NOT EXISTS group_permissions(group_id TEXT PRIMARY KEY,body TEXT NOT NULL); CREATE TABLE IF NOT EXISTS device_consent_revisions(device_id TEXT PRIMARY KEY REFERENCES devices(id),revision INTEGER NOT NULL); -PRAGMA user_version=1; +CREATE TABLE IF NOT EXISTS profile_signing_keys(id INTEGER PRIMARY KEY CHECK(id=1),active_hex TEXT NOT NULL,pending_hex TEXT); +PRAGMA user_version=2; diff --git a/product/management/src/service.rs b/product/management/src/service.rs index 5ae3cc5870e..cf4d7a29c47 100644 --- a/product/management/src/service.rs +++ b/product/management/src/service.rs @@ -13,6 +13,7 @@ use totp_rs::{Algorithm, Secret, TOTP}; use uuid::Uuid; pub struct Store { + _process_lock: std::fs::File, db: Mutex, key: SigningKey, setup_hash: String, @@ -58,8 +59,15 @@ fn totp_step(secret:&str,code:&str,time:u64)->anyhow::Result { impl Store { pub fn open(directory:&FsPath)->anyhow::Result { + Self::open_with_backup_password(directory,std::env::var("SWAN_BACKUP_PASSPHRASE").ok().as_deref()) + } + fn open_with_backup_password(directory:&FsPath,backup_password:Option<&str>)->anyhow::Result { std::fs::create_dir_all(directory)?; #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; std::fs::set_permissions(directory,std::fs::Permissions::from_mode(0o700))?; } + let mut lock_options=std::fs::OpenOptions::new();lock_options.create(true).read(true).write(true); + #[cfg(unix)] {use std::os::unix::fs::OpenOptionsExt;lock_options.mode(0o600);} + let process_lock=lock_options.open(directory.join("management.lock"))?; + fs2::FileExt::try_lock_exclusive(&process_lock).map_err(|_|anyhow::anyhow!("Company management data is in use; stop the service before offline operations"))?; let key_path=directory.join("profile-key.hex"); if !key_path.exists() { write_secret(&key_path,&hex::encode(SigningKey::generate(&mut OsRng).to_bytes()))?; } let key_bytes:[u8;32]=hex::decode(std::fs::read_to_string(&key_path)?.trim())?.try_into().map_err(|_|anyhow::anyhow!("Invalid signing key"))?; @@ -71,10 +79,48 @@ impl Store { db.pragma_update(None,"foreign_keys","ON")?; db.busy_timeout(std::time::Duration::from_secs(5))?; let version:u32=db.pragma_query_value(None,"user_version",|r|r.get(0))?; - ensure!(version<=1,"Database was created by a newer server; restore the pre-upgrade backup rather than downgrading it"); + ensure!(version<=2,"Database was created by a newer server; restore the pre-upgrade backup rather than downgrading it"); + if version==1 { + let password=backup_password.ok_or_else(||anyhow::anyhow!("Schema upgrade requires SWAN_BACKUP_PASSPHRASE in private environment configuration for an encrypted pre-upgrade backup"))?; + crate::backup::export(directory,&directory.join(format!("pre-upgrade-v1-{}.swan-backup",random_token())),password)?; + } db.execute_batch(include_str!("schema.sql"))?; + db.execute("INSERT OR IGNORE INTO profile_signing_keys(id,active_hex) VALUES(1,?1)",[hex::encode(key_bytes)])?; + let active_hex:String=db.query_row("SELECT active_hex FROM profile_signing_keys WHERE id=1",[],|row|row.get(0))?; + let key=signing_key_from_hex(&active_hex)?; let artifact_dir=directory.join("artifacts");std::fs::create_dir_all(&artifact_dir)?; - Ok(Self {db:Mutex::new(db),key:SigningKey::from_bytes(&key_bytes),setup_hash,limits:Mutex::new(HashMap::new()),artifact_dir}) + Ok(Self {_process_lock:process_lock,db:Mutex::new(db),key,setup_hash,limits:Mutex::new(HashMap::new()),artifact_dir}) + } + pub fn prepare_profile_rotation(&self)->anyhow::Result { + let mut db=self.db.lock().unwrap();let tx=db.transaction()?; + let pending:Option=tx.query_row("SELECT pending_hex FROM profile_signing_keys WHERE id=1",[],|row|row.get(0))?; + ensure!(pending.is_none(),"A profile key rotation is already pending"); + let raw:String=tx.query_row("SELECT body FROM profile WHERE id=1",[],|row|row.get(0))?; + let mut profile:CompanyProfile=serde_json::from_str(&raw)?; + ensure!(profile.next_profile_public_key.is_none(),"Profile already declares a replacement key"); + let next=SigningKey::generate(&mut OsRng);let public=STANDARD.encode(next.verifying_key().as_bytes()); + profile.next_profile_public_key=Some(public.clone());profile.revision=profile.revision.checked_add(1).ok_or_else(||anyhow::anyhow!("Profile revision exhausted"))?; + profile.issued_at=now();profile.expires_at=now()+30*86400;profile.validate(&profile.company_id,profile.revision,now())?; + tx.execute("UPDATE profile_signing_keys SET pending_hex=?1 WHERE id=1",[hex::encode(next.to_bytes())])?; + tx.execute("UPDATE profile SET body=?1 WHERE id=1",[serde_json::to_string(&profile)?])?; + tx.execute("INSERT INTO audit(at,actor,event,target) VALUES(?1,'local-operator','profile.key.prepared',?2)",params![now(),public])?; + tx.commit()?;Ok(public) + } + pub fn activate_profile_rotation(&self)->anyhow::Result { + let mut db=self.db.lock().unwrap();let tx=db.transaction()?; + let pending:Option=tx.query_row("SELECT pending_hex FROM profile_signing_keys WHERE id=1",[],|row|row.get(0))?; + let pending=pending.ok_or_else(||anyhow::anyhow!("No pending profile key rotation"))?; + let next=signing_key_from_hex(&pending)?;let public=STANDARD.encode(next.verifying_key().as_bytes()); + let raw:String=tx.query_row("SELECT body FROM profile WHERE id=1",[],|row|row.get(0))?;let mut profile:CompanyProfile=serde_json::from_str(&raw)?; + ensure!(profile.next_profile_public_key.as_ref()==Some(&public),"Published profile does not authorize the pending key"); + profile.next_profile_public_key=None;profile.revision=profile.revision.checked_add(1).ok_or_else(||anyhow::anyhow!("Profile revision exhausted"))?; + profile.issued_at=now();profile.expires_at=now()+30*86400;profile.validate(&profile.company_id,profile.revision,now())?; + tx.execute("UPDATE profile_signing_keys SET active_hex=?1,pending_hex=NULL WHERE id=1",[pending])?; + tx.execute("UPDATE profile SET body=?1 WHERE id=1",[serde_json::to_string(&profile)?])?; + tx.execute("UPDATE grants SET closed=1,lease_until=0 WHERE closed=0",[])?; + tx.execute("UPDATE builds SET state='failed',result=?1 WHERE state IN ('queued','running','uploaded','completed')",[json!({"ok":false,"error":"Company signing key rotated; regenerate company bundles"}).to_string()])?; + tx.execute("INSERT INTO audit(at,actor,event,target) VALUES(?1,'local-operator','profile.key.activated',?2)",params![now(),public])?; + tx.commit()?;Ok(public) } fn profile(&self)->anyhow::Result { let raw:String=self.db.lock().unwrap().query_row("SELECT body FROM profile WHERE id=1",[],|r|r.get(0))?; @@ -510,6 +556,43 @@ struct BuildResult { success:bool, artifact_url:String,sha256:String,log:String #[cfg(test)] mod worker_completion_tests { use super::*; + #[test] + fn legacy_schema_migration_requires_encrypted_backup_and_keeps_original_key() { + let directory=std::env::temp_dir().join(format!("swan-key-migration-{}",random_token())); + let initial=Store::open(&directory).unwrap();let public=initial.key.verifying_key(); + initial.db.lock().unwrap().execute_batch("DROP TABLE profile_signing_keys;PRAGMA user_version=1;").unwrap();drop(initial); + assert!(Store::open_with_backup_password(&directory,None).is_err()); + let db=Connection::open(directory.join("management.sqlite3")).unwrap();assert_eq!(db.pragma_query_value(None,"user_version",|r|r.get::<_,u32>(0)).unwrap(),1);drop(db); + let upgraded=Store::open_with_backup_password(&directory,Some("schema migration backup password")).unwrap();assert_eq!(upgraded.key.verifying_key(),public); + let archive=std::fs::read_dir(&directory).unwrap().map(|e|e.unwrap().path()).find(|path|path.extension().is_some_and(|e|e=="swan-backup")).unwrap();drop(upgraded); + let restored_path=directory.with_extension("restored");crate::backup::restore(&restored_path,&archive,"schema migration backup password").unwrap(); + let db=Connection::open(restored_path.join("management.sqlite3")).unwrap();assert_eq!(db.pragma_query_value(None,"user_version",|r|r.get::<_,u32>(0)).unwrap(),1);drop(db); + std::fs::remove_dir_all(directory).unwrap();std::fs::remove_dir_all(restored_path).unwrap(); + } + #[test] + fn profile_key_rotation_is_atomic_survives_backup_and_requires_published_transition() { + let directory=std::env::temp_dir().join(format!("swan-server-key-rotation-{}",random_token())); + let store=Store::open(&directory).unwrap();assert!(Store::open(&directory).is_err()); + let old=store.key.verifying_key();let old_public=STANDARD.encode(old.as_bytes()); + let profile=CompanyProfile{schema:1,company_id:"rotation-test".into(),revision:1,issued_at:now()-1,expires_at:now()+3600,management_url:"https://support.example.com".into(),rendezvous:"support.example.com".into(),relay:"support.example.com".into(),transport_public_key:old_public.clone(),customer:Default::default(),technician:Default::default(),allow_unattended:false,updates_paused:true,rollout_percent:0,maintenance_start_utc:0,maintenance_end_utc:0,update_channel:"test".into(),next_profile_public_key:None}; + store.db.lock().unwrap().execute("INSERT INTO profile VALUES(1,?1)",[serde_json::to_string(&profile).unwrap()]).unwrap(); + assert!(store.activate_profile_rotation().is_err()); + let next=store.prepare_profile_rotation().unwrap();assert_ne!(next,old_public);assert!(store.prepare_profile_rotation().is_err()); + let transition=store.profile().unwrap();assert_eq!(transition.revision,2);assert_eq!(transition.next_profile_public_key.as_ref(),Some(&next)); + let envelope=SignedEnvelope::sign(&transition,&store.key).unwrap();assert!(envelope.verify::(&old).is_ok()); + let mut altered=transition.clone();altered.next_profile_public_key=Some(old_public); + store.db.lock().unwrap().execute("UPDATE profile SET body=?1 WHERE id=1",[serde_json::to_string(&altered).unwrap()]).unwrap(); + assert!(store.activate_profile_rotation().is_err()); + store.db.lock().unwrap().execute("UPDATE profile SET body=?1 WHERE id=1",[serde_json::to_string(&transition).unwrap()]).unwrap(); + assert_eq!(store.activate_profile_rotation().unwrap(),next);drop(store); + let restarted=Store::open(&directory).unwrap();assert_eq!(STANDARD.encode(restarted.key.verifying_key().as_bytes()),next); + let activated=restarted.profile().unwrap();assert_eq!(activated.revision,3);assert!(activated.next_profile_public_key.is_none()); + let signed=SignedEnvelope::sign(&activated,&restarted.key).unwrap();assert!(signed.verify::(&old).is_err());assert!(signed.verify::(&public_key(&next).unwrap()).is_ok()); + let archive=directory.with_extension("backup");crate::backup::export(&directory,&archive,"rotation backup test passphrase").unwrap();drop(restarted); + let restored_path=directory.with_extension("restored");crate::backup::restore(&restored_path,&archive,"rotation backup test passphrase").unwrap(); + let restored=Store::open(&restored_path).unwrap();assert_eq!(STANDARD.encode(restored.key.verifying_key().as_bytes()),next);assert_eq!(restored.profile().unwrap().revision,3);drop(restored); + std::fs::remove_dir_all(directory).unwrap();std::fs::remove_dir_all(restored_path).unwrap();std::fs::remove_file(archive).unwrap(); + } #[tokio::test] async fn release_withdrawal_requires_admin_and_stops_update_selection() { let directory=std::env::temp_dir().join(format!("swan-release-withdrawal-{}",random_token())); From 04fb166d6342e797b4309141746718e81928785c Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Fri, 2 Oct 2026 14:34:03 +0200 Subject: [PATCH 081/652] Rehearse company key rotation over real isolated HTTPS --- deployment/windows/test-profile-rotation.js | 70 +++++++++++++++++++++ docs/ACCEPTANCE.md | 10 +++ docs/PROFILE_KEY_ROTATION.md | 18 ++++++ 3 files changed, 98 insertions(+) create mode 100644 deployment/windows/test-profile-rotation.js diff --git a/deployment/windows/test-profile-rotation.js b/deployment/windows/test-profile-rotation.js new file mode 100644 index 00000000000..5449200fa9a --- /dev/null +++ b/deployment/windows/test-profile-rotation.js @@ -0,0 +1,70 @@ +// Owns only freshly created, loopback-only fixture processes. No production inputs. +const fs=require('node:fs'),path=require('node:path'),https=require('node:https'); +const crypto=require('node:crypto'),assert=require('node:assert/strict'); +const {spawn,spawnSync}=require('node:child_process'); +const root=path.resolve(__dirname,'../..'),settings={}; +assert(process.argv[2]&&process.argv[3],'Pass private fixture environment and binary directory'); +for(const line of fs.readFileSync(process.argv[2],'utf8').split(/\r?\n/)) { + if(!line.trim()||line.trimStart().startsWith('#'))continue; + const match=/^([A-Z_]+)=(.*)$/.exec(line);assert(match,'Invalid environment file');settings[match[1]]=match[2]; +} +assert.match(settings.SWAN_LISTEN??'',/^127\.0\.0\.1:\d+$/); +const data=path.resolve(settings.SWAN_DATA_DIR),binary=path.resolve(process.argv[3]); +assert(fs.existsSync(path.join(data,'rotation-fixture.marker')),'Only explicitly marked isolated fixtures are allowed'); +assert(!fs.existsSync(path.join(data,'management.sqlite3')),'A fresh, unconfigured fixture is required'); +const manager=path.join(binary,'swan-management.exe'),agent=path.join(binary,'swan-agent.exe'); +const caFile=path.join(data,'localhost.cer'),ca=new crypto.X509Certificate(fs.readFileSync(caFile)).toString(); +const port=Number(settings.SWAN_TEST_TLS_PORT);assert(Number.isInteger(port)&&port>=1024&&port<=65535); +const env={...process.env,...settings,SWAN_TEST_CA_FILE:caFile}; +let server,proxy; +function request(endpoint,method='GET',token,body){return new Promise((resolve,reject)=>{const bytes=body===undefined?null:Buffer.from(JSON.stringify(body));const call=https.request(`https://localhost:${port}/api/v1/${endpoint}`,{method,ca,timeout:2000,headers:{'content-type':'application/json',...(token?{authorization:'Bearer '+token}:{}),...(bytes?{'content-length':bytes.length}:{})}},response=>{const chunks=[];response.on('data',c=>chunks.push(c));response.on('end',()=>{try{resolve({status:response.statusCode,body:JSON.parse(Buffer.concat(chunks))});}catch(error){reject(error);}});});call.on('error',reject);call.on('timeout',()=>call.destroy());if(bytes)call.write(bytes);call.end();});} +async function api(endpoint){const result=await request(endpoint);assert.equal(result.status,200);return result.body;} +function totp(secret){const alphabet='ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';let bits=0,value=0,bytes=[];for(const c of secret.replace(/=/g,'')){value=(value<<5)|alphabet.indexOf(c);bits+=5;if(bits>=8){bytes.push((value>>>(bits-8))&255);bits-=8;}}const counter=Buffer.alloc(8);counter.writeBigUInt64BE(BigInt(Math.floor(Date.now()/30000)+1));const hash=crypto.createHmac('sha1',Buffer.from(bytes)).update(counter).digest();const offset=hash[19]&15;return String((hash.readUInt32BE(offset)&0x7fffffff)%1000000).padStart(6,'0');} +function run(executable,args,extra={}){const result=spawnSync(executable,args,{cwd:root,env:{...env,...extra},encoding:'utf8',timeout:60000});assert.equal(result.status,0,`Isolated ${path.basename(executable)} ${args[0]} failed`);return result.stdout;} +function agentRun(directory,args){return run(agent,args,{SWAN_STATE_DIR:directory});} +function readState(directory){return JSON.parse(fs.readFileSync(path.join(directory,'managed-state.json')));} +async function ready(){const deadline=Date.now()+20000;while(Date.now()setTimeout(r,200));}throw new Error('Owned fixture did not become ready');} +async function stop(child){if(!child||child.exitCode!==null||child.signalCode!==null)return;const exited=new Promise(resolve=>child.once('exit',resolve));assert(child.kill(),'Owned fixture process could not be stopped');await exited;} +function start(){server=spawn(manager,[],{cwd:root,env,stdio:'ignore'});} +async function main(){ + start();proxy=spawn(process.execPath,[path.join(__dirname,'local-https.js'),process.argv[2]],{cwd:root,env,stdio:'ignore'}); + const initial=await ready();assert.equal(initial.configured,false); + run(process.execPath,[path.join(__dirname,'test-company-lifecycle.js'),process.argv[2],binary]); + const customerDir=path.join(data,'customer-agent'),techDir=path.join(data,'technician-agent'),missedDir=path.join(data,'missed-transition-agent'); + agentRun(missedDir,['setup',path.join(data,'customer-bootstrap.json'),'--accept-company']); + const original=readState(customerDir),oldKey=original.bootstrap.profile_public_key; + await stop(server);run(manager,['prepare-profile-key']);start();await ready(); + agentRun(customerDir,['sync']);agentRun(techDir,['sync']); + const prepared=readState(customerDir),transition=JSON.parse(Buffer.from(prepared.profile.payload,'base64')); + assert.equal(prepared.bootstrap.profile_public_key,oldKey);assert(transition.next_profile_public_key);assert.notEqual(transition.next_profile_public_key,oldKey); + assert.equal(readState(techDir).bootstrap.profile_public_key,oldKey); + // The lifecycle consumed the following MFA step. Wait for a genuinely fresh + // step instead of replaying that code or weakening the server's MFA checks. + await new Promise(resolve=>setTimeout(resolve,30000-Date.now()%30000+300)); + const credentials=JSON.parse(fs.readFileSync(path.join(data,'test-admin.private.json'))); + const login=await request('login','POST',null,{username:credentials.username,password:credentials.password,totp_code:totp(credentials.totp_secret)});assert.equal(login.status,200); + const approval=await request(`devices/${original.device_id}/state`,'PUT',login.body.token,{state:'approved',group:'test-customers'});assert.equal(approval.status,200); + const proof=crypto.generateKeyPairSync('ed25519').publicKey.export({type:'spki',format:'der'}).subarray(-32).toString('base64'); + const activeGrant=await request('grants','POST',login.body.token,{device_id:original.device_id,proof_public_key:proof,unattended:false});assert.equal(activeGrant.status,200); + const grantBody=JSON.parse(Buffer.from(activeGrant.body.payload,'base64')); + assert.equal((await request('grants/claim','POST',original.device_token,{grant:activeGrant.body})).status,200); + await stop(server); + const denied=spawnSync(manager,['activate-profile-key'],{cwd:root,env,stdio:'ignore'});assert.notEqual(denied.status,0,'Activation without synchronization confirmation succeeded'); + run(manager,['activate-profile-key','--confirm-clients-synced']);start();const activated=await ready(); + assert.equal(activated.profile_public_key,transition.next_profile_public_key); + assert(grantBody.expires_at>Math.floor(Date.now()/1000),'Grant expired before the rotation check'); + assert.equal((await request(`grants/${grantBody.grant_id}/renew`,'POST',original.device_token)).status,403,'Rotation renewed an old claimed grant'); + const envelope=await api('profile');const newProfile=JSON.parse(Buffer.from(envelope.payload,'base64')); + const oldDer=Buffer.concat([Buffer.from('302a300506032b6570032100','hex'),Buffer.from(oldKey,'base64')]); + assert.equal(crypto.verify(null,Buffer.from(envelope.payload,'base64'),crypto.createPublicKey({key:oldDer,type:'spki',format:'der'}),Buffer.from(envelope.signature,'base64')),false); + agentRun(customerDir,['sync']);agentRun(techDir,['sync']); + const after=readState(customerDir);assert.equal(after.bootstrap.profile_public_key,activated.profile_public_key);assert.equal(readState(techDir).bootstrap.profile_public_key,activated.profile_public_key); + for(const key of ['device_id','device_token','unattended_consent','consent_revision'])assert.equal(after[key],original[key],`Rotation changed ${key}`); + assert(newProfile.revision>transition.revision); + const missed=spawnSync(agent,['sync'],{cwd:root,env:{...env,SWAN_STATE_DIR:missedDir},stdio:'ignore',timeout:30000});assert.notEqual(missed.status,0,'A client missing the trusted transition accepted the new key');assert.equal(readState(missedDir).bootstrap.profile_public_key,oldKey); + await stop(server);start();await ready();agentRun(customerDir,['sync']);assert.equal(readState(customerDir).bootstrap.profile_public_key,activated.profile_public_key); + const sha256=file=>crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex'); + fs.writeFileSync(path.join(data,'rotation-result.json'),JSON.stringify({passed:true,at:new Date().toISOString(),source_commit:run('git',['rev-parse','HEAD']).trim(),source_dirty:run('git',['status','--porcelain']).trim().length>0,management_sha256:sha256(manager),agent_sha256:sha256(agent),checks:['HTTPS trusted transition','customer and technician key rotation','identity and revoked consent preserved','missed-transition client rejected','activation confirmation required','claimed unexpired grant renewal denied','restart persistence'],native_endpoint_tested:false},null,2)); + console.log('PASS: real HTTPS profile-key rotation with built customer/technician agents. Native apps and live sessions remain unverified.'); +} +main().catch(error=>{console.error(error.message);process.exitCode=1;}).finally(async()=>{await stop(server);await stop(proxy);}); diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index f6c4af03a5b..25dfd00dbbf 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -469,3 +469,13 @@ management instances. Schema-1 migration requires an encrypted pre-upgrade backup; restored active keys and revisions survive component tests. Eleven management tests pass, including rotation, migration and backup recovery. Installed-app HTTPS rotation and live migration rollback remain unverified. + +An isolated real HTTPS rehearsal with management/agent binaries built at +7843334 now passes trusted profile-key rotation for both agent editions. +Enrollment, device token and revoked consent persist; a client missing the +transition rejects the new key; activation without explicit confirmation fails; +restart retains the active key. Renewal of a claimed, still-unexpired grant is +denied after activation. Private results record binary hashes and note the +uncommitted harness in the worktree. Owned fixture listeners stopped afterward. +Graphical installed apps, actual session termination, production certificates +and full migration rollback remain separate unverified acceptance gates. diff --git a/docs/PROFILE_KEY_ROTATION.md b/docs/PROFILE_KEY_ROTATION.md index 484f9677591..be859897325 100644 --- a/docs/PROFILE_KEY_ROTATION.md +++ b/docs/PROFILE_KEY_ROTATION.md @@ -51,3 +51,21 @@ new-key signatures, encrypted restore and pre-upgrade backup recovery. Real HTTPS rotation across installed apps, offline machines, interrupted activation, live session termination and complete server migration rollback remain release acceptance requirements. + +## Local HTTPS rehearsal + +`node deployment/windows/test-profile-rotation.js PRIVATE_ENV BINARY_DIRECTORY` +owns fresh management and HTTPS proxy processes for a marked isolated fixture. +It requires `rotation-fixture.marker`, a fresh data directory without a database, +private TLS configuration and debug management/agent binaries. Use the local +test certificate mechanism; do not import a test CA into production trust or +point this harness at an existing company. All credentials/results stay in the +protected fixture directory. Processes stop on completion or failure. + +The rehearsal passed with binaries built from `7843334`: real HTTPS customer and +technician agent sync adopted the prepared key, retained enrollment and revoked +consent, rejected a client that missed the transition, required activation +confirmation, and preserved the active key through restart. A claimed grant was +still unexpired when activation denied its renewal. This tests built agents and +the management authorization API; it does not prove the graphical receiving app +terminates a live remote session or that production HTTPS deployment passes. From ad1c494d55ddb35c046ebc84aa8752e5228f63a9 Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Fri, 2 Oct 2026 14:41:49 +0200 Subject: [PATCH 082/652] Verify rotated company and HTTPS identity through encrypted restore --- deployment/windows/test-profile-rotation.js | 21 ++++++++++++++++++++- docs/ACCEPTANCE.md | 9 +++++++++ docs/PROFILE_KEY_ROTATION.md | 7 +++++++ docs/SERVER_BACKUP.md | 8 ++++++++ product/management/src/main.rs | 6 ++++-- 5 files changed, 48 insertions(+), 3 deletions(-) diff --git a/deployment/windows/test-profile-rotation.js b/deployment/windows/test-profile-rotation.js index 5449200fa9a..36a7f035a54 100644 --- a/deployment/windows/test-profile-rotation.js +++ b/deployment/windows/test-profile-rotation.js @@ -63,8 +63,27 @@ async function main(){ assert(newProfile.revision>transition.revision); const missed=spawnSync(agent,['sync'],{cwd:root,env:{...env,SWAN_STATE_DIR:missedDir},stdio:'ignore',timeout:30000});assert.notEqual(missed.status,0,'A client missing the trusted transition accepted the new key');assert.equal(readState(missedDir).bootstrap.profile_public_key,oldKey); await stop(server);start();await ready();agentRun(customerDir,['sync']);assert.equal(readState(customerDir).bootstrap.profile_public_key,activated.profile_public_key); + // Export the real local HTTPS identity as storage files, not dummy bytes. + // The passphrase exists only in the child-process environment. + await stop(server);await stop(proxy); + const tlsStorage=path.join(data,'https-storage');fs.mkdirSync(tlsStorage); + fs.copyFileSync(settings.SWAN_TEST_TLS_PFX,path.join(tlsStorage,'localhost.pfx')); + fs.copyFileSync(caFile,path.join(tlsStorage,'localhost.cer')); + const backupPassword=crypto.randomBytes(32).toString('hex'),backup=path.join(data,'rotated-company.swan-backup'); + run(manager,['backup',backup,'--deployment-env',process.argv[2],'--tls-directory',tlsStorage],{SWAN_BACKUP_PASSPHRASE:backupPassword}); + const restored=path.join(data,'restored-company'); + run(manager,['restore',backup],{SWAN_DATA_DIR:restored,SWAN_BACKUP_PASSPHRASE:backupPassword}); + for(const name of ['localhost.pfx','localhost.cer'])assert.deepEqual(fs.readFileSync(path.join(restored,'tls-storage',name)),fs.readFileSync(path.join(tlsStorage,name))); + const restoredSettings={...settings,SWAN_DATA_DIR:restored,SWAN_TEST_TLS_PFX:path.join(restored,'tls-storage','localhost.pfx'),SWAN_TEST_CA_FILE:path.join(restored,'tls-storage','localhost.cer')}; + const restoredEnvFile=path.join(data,'restored-test.env');fs.writeFileSync(restoredEnvFile,Object.entries(restoredSettings).map(([key,value])=>`${key}=${value}`).join('\n')+'\n'); + server=spawn(manager,[],{cwd:root,env:{...env,...restoredSettings},stdio:'ignore'}); + proxy=spawn(process.execPath,[path.join(__dirname,'local-https.js'),restoredEnvFile],{cwd:root,env,stdio:'ignore'}); + assert.equal((await ready()).profile_public_key,activated.profile_public_key); + assert.equal((await request('device/status','GET',original.device_token)).status,200,'Restored device credential lost ownership'); + assert.equal((await request(`grants/${grantBody.grant_id}/renew`,'POST',original.device_token)).status,403,'Restore revived the closed grant'); + agentRun(customerDir,['sync']);assert.equal(readState(customerDir).bootstrap.profile_public_key,activated.profile_public_key); const sha256=file=>crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex'); - fs.writeFileSync(path.join(data,'rotation-result.json'),JSON.stringify({passed:true,at:new Date().toISOString(),source_commit:run('git',['rev-parse','HEAD']).trim(),source_dirty:run('git',['status','--porcelain']).trim().length>0,management_sha256:sha256(manager),agent_sha256:sha256(agent),checks:['HTTPS trusted transition','customer and technician key rotation','identity and revoked consent preserved','missed-transition client rejected','activation confirmation required','claimed unexpired grant renewal denied','restart persistence'],native_endpoint_tested:false},null,2)); + fs.writeFileSync(path.join(data,'rotation-result.json'),JSON.stringify({passed:true,at:new Date().toISOString(),source_commit:run('git',['rev-parse','HEAD']).trim(),source_dirty:run('git',['status','--porcelain']).trim().length>0,management_sha256:sha256(manager),agent_sha256:sha256(agent),checks:['HTTPS trusted transition','customer and technician key rotation','identity and revoked consent preserved','missed-transition client rejected','activation confirmation required','claimed unexpired grant renewal denied','restart persistence','encrypted CLI export and restore of rotated company and TLS storage','restored HTTPS identity and device credential','restored grant stays closed'],native_endpoint_tested:false},null,2)); console.log('PASS: real HTTPS profile-key rotation with built customer/technician agents. Native apps and live sessions remain unverified.'); } main().catch(error=>{console.error(error.message);process.exitCode=1;}).finally(async()=>{await stop(server);await stop(proxy);}); diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index 25dfd00dbbf..0b3da0a2048 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -479,3 +479,12 @@ denied after activation. Private results record binary hashes and note the uncommitted harness in the worktree. Owned fixture listeners stopped afterward. Graphical installed apps, actual session termination, production certificates and full migration rollback remain separate unverified acceptance gates. + +The HTTPS rotation harness now exercises actual management CLI encrypted export +and restore of rotated company data, private configuration and real HTTPS +PFX/certificate storage. A replacement HTTPS proxy uses those restored files; +the active profile key and device credential remain valid and the closed grant +stays denied. The rehearsal passes again after rebuilding startup and rotation +operations onto blocking worker threads. Private results record the binary +hashes and dirty source state; both fixture listeners stop. Live ACME renewal, +hbbs/hbbr restoration and Windows native acceptance remain unverified. diff --git a/docs/PROFILE_KEY_ROTATION.md b/docs/PROFILE_KEY_ROTATION.md index be859897325..12a7bcab5a8 100644 --- a/docs/PROFILE_KEY_ROTATION.md +++ b/docs/PROFILE_KEY_ROTATION.md @@ -69,3 +69,10 @@ confirmation, and preserved the active key through restart. A claimed grant was still unexpired when activation denied its renewal. This tests built agents and the management authorization API; it does not prove the graphical receiving app terminates a live remote session or that production HTTPS deployment passes. + +The harness also exports the rotated database, private deployment configuration +and real local HTTPS storage through the management CLI, then restores them to +an empty protected directory. The replacement HTTPS proxy uses the restored PFX; +key/certificate bytes match the originals, the management public signing key and +device credential survive, and the closed grant remains denied. Local certificate +storage recovery does not prove ACME renewal or production Caddy recovery. diff --git a/docs/SERVER_BACKUP.md b/docs/SERVER_BACKUP.md index bc9b184f993..54c86677336 100644 --- a/docs/SERVER_BACKUP.md +++ b/docs/SERVER_BACKUP.md @@ -105,3 +105,11 @@ rollback. Those remain acceptance requirements. The extended storage component test additionally preserves nested certificate private-key and ACME account fixtures through encrypted export and restore. It does not demonstrate a running Caddy service restoring or renewing certificates. + +The extended HTTPS rotation rehearsal additionally passed actual CLI export and +restore of a rotated management database, deployment environment and the local +HTTPS PFX/certificate storage. Its replacement HTTPS proxy serves using the +restored identity; rotated profile trust, device credentials and closed-grant +denial survive. Both original/restored fixture processes stop afterward. This +remains isolated local TLS evidence; ACME renewal and live transport restore +are still unverified. diff --git a/product/management/src/main.rs b/product/management/src/main.rs index 8ef7b76f2af..396b0b54d09 100644 --- a/product/management/src/main.rs +++ b/product/management/src/main.rs @@ -42,10 +42,12 @@ async fn run(stop:tokio::sync::oneshot::Receiver<()>)->Result<()> { }).await??; println!("Encrypted {} completed.",args[1]);return Ok(()); } - let store = Arc::new(Store::open(&data)?); + let store_data=data.clone(); + let store = Arc::new(tokio::task::spawn_blocking(move ||Store::open(&store_data)).await??); if matches!(args.get(1).map(String::as_str),Some("prepare-profile-key"|"activate-profile-key")) { if args[1]=="activate-profile-key" {anyhow::ensure!(args.iter().any(|arg|arg=="--confirm-clients-synced"),"First synchronize the prepared signed transition to installed clients; activation requires --confirm-clients-synced");} - let public=if args[1]=="prepare-profile-key" {store.prepare_profile_rotation()?}else{store.activate_profile_rotation()?}; + let operation=args[1].clone(); + let public=tokio::task::spawn_blocking(move ||if operation=="prepare-profile-key" {store.prepare_profile_rotation()}else{store.activate_profile_rotation()}).await??; println!("Profile public key: {public}");return Ok(()); } let address: SocketAddr = std::env::var("SWAN_LISTEN").unwrap_or_else(|_| "127.0.0.1:8080".into()).parse()?; From f737c6750b7b317f7ef526a21951cb27c5d4e164 Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Fri, 2 Oct 2026 14:46:32 +0200 Subject: [PATCH 083/652] Preserve company shortcut values with separate branding fields --- docs/ACCEPTANCE.md | 8 ++++++++ product/management/web/app.js | 24 ++++++++++++++++++++++-- product/management/web/style.css | 1 + 3 files changed, 31 insertions(+), 2 deletions(-) diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index 0b3da0a2048..0193e4ce049 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -488,3 +488,11 @@ stays denied. The rehearsal passes again after rebuilding startup and rotation operations onto blocking worker threads. Private results record the binary hashes and dirty source state; both fixture listeners stop. Live ACME renewal, hbbs/hbbr restoration and Windows native acceptance remain unverified. + +The administration branding form now uses separate shortcut name/HTTPS-address +fields with add/remove controls and an eight-entry limit. Valid names and URLs +containing pipe characters survive editing without delimiter ambiguity. A local +JavaScript regression check verifies exact pipe/whitespace preservation, blank +row omission, incomplete-entry rejection and HTML attribute escaping. JavaScript +syntax and diff checks pass. Browser interaction, accessibility and installed-app +shortcut rendering remain unverified. diff --git a/product/management/web/app.js b/product/management/web/app.js index 95c99a1bcf0..be9e7e65dfb 100644 --- a/product/management/web/app.js +++ b/product/management/web/app.js @@ -1,9 +1,29 @@ -function parseShortcuts(value){return (value||'').split(/\r?\n/).filter(line=>line.trim()).map(line=>{const separator=line.indexOf('|');if(separator<1)throw new Error('Each shortcut needs a name followed by | and an HTTPS URL.');return {label:line.slice(0,separator).trim(),url:line.slice(separator+1).trim()};});} 'use strict'; let accessToken=''; const content=document.querySelector('#content'); const notice=document.querySelector('#notice'); const escapeText=value=>String(value??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); +let shortcutCounter=0; +function shortcutRow(edition,shortcut={label:'',url:''}) { + const id=edition+'_shortcut_'+shortcutCounter++; + return `
`; +} +function shortcutFields(edition,shortcuts=[]) { + return `
Website shortcuts
${shortcuts.map(shortcut=>shortcutRow(edition,shortcut)).join('')}
Up to eight website shortcuts. Each shortcut has a name and an HTTPS address.
`; +} +function bindShortcuts(edition) { + const editor=document.querySelector('#'+edition+'_shortcut_editor'); + const rows=editor.querySelector('[data-shortcut-rows]'),add=editor.querySelector('[data-add-shortcut]'); + const refresh=()=>{add.disabled=rows.children.length>=8;}; + add.onclick=()=>{if(rows.children.length>=8)return;rows.insertAdjacentHTML('beforeend',shortcutRow(edition));refresh();}; + rows.onclick=event=>{const button=event.target.closest('[data-remove-shortcut]');if(button&&rows.contains(button)){button.closest('[data-shortcut-row]').remove();refresh();}}; + refresh(); +} +function readShortcuts(values,edition) { + const labels=values.getAll(edition+'_shortcut_label'),urls=values.getAll(edition+'_shortcut_url'); + if(labels.length!==urls.length||labels.length>8)throw new Error('Up to eight complete website shortcuts are allowed.'); + return labels.flatMap((label,index)=>{const url=urls[index];if(!label.trim()&&!url.trim())return [];if(!label.trim()||!url.trim())throw new Error('Each website shortcut needs a name and an HTTPS address.');return [{label,url}];}); +} async function api(path,method='GET',body,token=accessToken){ const 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'}); const result=await response.json();if(!response.ok)throw new Error(result.error||'Request failed');return result; @@ -60,7 +80,7 @@ const screens={ 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=`

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

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)}
${field('rollout','Rollout percentage','number',p.rollout_percent)}
${field('maintenance_start','Maintenance start hour (UTC, 0โ€“23)','number',p.maintenance_start_utc)}${field('maintenance_end','Maintenance end hour (UTC, 0โ€“23)','number',p.maintenance_end_utc)}
Windows can cross midnight. Equal start and end hours allow updates throughout the day. Releases still require administrator approval.
`;bindForm('branding',async 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=parseShortcuts(v.get(edition+'_shortcuts'));}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'));await api('profile','PUT',p);notice.textContent='Company profile published.';}); + 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=`

Customer app

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

Technician app

${field('technician_name','Display name','text',p.technician.display_name)}${field('technician_color','Primary color','color',p.technician.primary_color)}${field('technician_support','Support HTTPS URL','url',p.technician.support_url||p.management_url)}${shortcutFields('technician',p.technician.shortcuts||[])}
${field('rollout','Rollout percentage','number',p.rollout_percent)}
${field('maintenance_start','Maintenance start hour (UTC, 0โ€“23)','number',p.maintenance_start_utc)}${field('maintenance_end','Maintenance end hour (UTC, 0โ€“23)','number',p.maintenance_end_utc)}
Windows can cross midnight. Equal start and end hours allow updates throughout the day. Releases still require administrator approval.
`;for(const edition of ['customer','technician'])bindShortcuts(edition);bindForm('branding',async 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'));await api('profile','PUT',p);notice.textContent='Company profile published.';}); }, async users(){title('Technicians and access','Every technician needs MFA and explicit device-group access.');const users=await api('users');content.innerHTML=`
${users.map(u=>``).join('')}
UsernameRoleIdentifierAccount access
${escapeText(u.username)}${u.disabled?' (disabled)':''}${escapeText(u.role)}${escapeText(u.id)}${u.disabled?'Disabled':``}

Add technician

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

Manage group access

${field('user_id','Technician identifier')}${field('group','Device group','text','default')}
`;bindForm('user',async v=>{const result=await api('users','POST',{username:v.get('username'),password:v.get('password'),role:'technician'});document.querySelector('#secret').textContent='Transfer privately: '+JSON.stringify(result);});bindForm('access',async v=>{await api('groups/'+encodeURIComponent(v.get('group'))+'/users/'+encodeURIComponent(v.get('user_id')),v.get('access_action'));notice.textContent=v.get('access_action')==='DELETE'?'Group access revoked. Existing sessions cannot renew authorization.':'Group access granted.';});for(const button of document.querySelectorAll('[data-disable-user]'))button.onclick=async()=>{button.disabled=true;try{await api('users/'+encodeURIComponent(button.dataset.disableUser)+'/disable','POST');await view('users');notice.textContent='Account disabled. New access and authorization renewals are denied.';}catch(error){showError(error);button.disabled=false;}}; }, diff --git a/product/management/web/style.css b/product/management/web/style.css index 3ffc0c2cbf1..4ca70387a57 100644 --- a/product/management/web/style.css +++ b/product/management/web/style.css @@ -1 +1,2 @@ :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} From da9892059a7d90aafc9a78789116fe48177af50f Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Fri, 2 Oct 2026 14:51:46 +0200 Subject: [PATCH 084/652] Move installer verification and bundle packaging off async runtime --- docs/ACCEPTANCE.md | 8 ++++++++ docs/PROFILE_KEY_ROTATION.md | 5 +++++ product/worker/src/main.rs | 12 +++++++++--- 3 files changed, 22 insertions(+), 3 deletions(-) diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index 0193e4ce049..690424a38a1 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -496,3 +496,11 @@ JavaScript regression check verifies exact pipe/whitespace preservation, blank row omission, incomplete-entry rejection and HTML attribute escaping. JavaScript syntax and diff checks pass. Browser interaction, accessibility and installed-app shortcut rendering remain unverified. + +The Windows installer worker now stages downloaded files through asynchronous +filesystem operations and runs Authenticode verification, ZIP compression, +artifact hashing and immutable publication in a blocking worker task. Windows +compilation passes. Real signed worker packaging, upload faults and publication +remain unverified. After company profile-key activation, operators must update +the worker's privately configured profile-key pin before rebuilding packages; +automatic worker trust-pin rotation is not implemented. diff --git a/docs/PROFILE_KEY_ROTATION.md b/docs/PROFILE_KEY_ROTATION.md index 12a7bcab5a8..28e5e577905 100644 --- a/docs/PROFILE_KEY_ROTATION.md +++ b/docs/PROFILE_KEY_ROTATION.md @@ -28,6 +28,11 @@ exclusive data-directory lock; older server versions must also be stopped. and verify synchronized clients accept the new signed profile. Generate new company installer bundles and replace website/internal distribution links. Previously downloaded old bundles must also be withdrawn by the operator. + The worker currently pins `SWAN_PROFILE_PUBLIC_KEY` in its private operator + configuration. Update that pin to the key authorized by the prepared signed + transition and restart the worker before rebuilding; it deliberately rejects + jobs signed by an unrelated or unconfigured key. Automatic worker-pin rotation + is not implemented. Activation invalidates current sessions as their authorization is revalidated. Schedule rotation as a maintenance operation. This does not create an automatic diff --git a/product/worker/src/main.rs b/product/worker/src/main.rs index 556b05faa0f..e7bc3ec95fd 100644 --- a/product/worker/src/main.rs +++ b/product/worker/src/main.rs @@ -12,7 +12,7 @@ async fn main()->Result<()> { let release_key=std::env::var("SWAN_RELEASE_PUBLIC_KEY").context("Pin SWAN_RELEASE_PUBLIC_KEY on the worker")?;public_key(&release_key)?; let company_key=std::env::var("SWAN_PROFILE_PUBLIC_KEY").context("Pin SWAN_PROFILE_PUBLIC_KEY on the worker")?;public_key(&company_key)?; let output=PathBuf::from(std::env::var("SWAN_ARTIFACT_DIR").context("Set worker artifact output directory")?); - std::fs::create_dir_all(&output)?; + tokio::fs::create_dir_all(&output).await?; let client=swan_agent::http_client(600,false)?; loop { let response=client.post(format!("{}/api/v1/worker/claim",server.trim_end_matches('/'))).bearer_auth(&token).send().await; @@ -77,12 +77,18 @@ async fn build(_client:&reqwest::Client,job:&Value,output:&Path,id:&str,release_ let profile_envelope:SignedEnvelope=serde_json::from_value(job["profile"].clone())?; let profile:CompanyProfile=profile_envelope.verify(&public_key(company_key)?)?; profile.validate(&profile.company_id,0,now())?; - let work=output.join(format!("work-{id}"));std::fs::create_dir_all(&work)?; + let work=output.join(format!("work-{id}"));tokio::fs::create_dir_all(&work).await?; let installer=work.join(format!("SwanRemoteSupport-install.{}",release.format)); let agent=work.join("swan-agent.exe"); let artifact_client=swan_agent::http_client(600,true)?; download(&artifact_client,&release.artifact_url,&installer,&release.sha256).await?; download(&artifact_client,&release.agent_url,&agent,&release.agent_sha256).await?; + let output=output.to_owned();let id=id.to_owned();let company_key=company_key.to_owned();let release_key=release_key.to_owned(); + tokio::task::spawn_blocking(move ||package_verified(output,&id,work,installer,agent,release,profile,release_envelope,profile_envelope,&release_key,&company_key)) + .await.context("Installer verification and packaging task failed")? +} + +fn package_verified(output:PathBuf,id:&str,work:PathBuf,installer:PathBuf,agent:PathBuf,release:Release,profile:CompanyProfile,release_envelope:SignedEnvelope,profile_envelope:SignedEnvelope,release_key:&str,company_key:&str)->Result<(String,String)> { verify_windows(&installer,&release,&work)?; verify_windows(&agent,&release,&work)?; let bootstrap=Bootstrap{schema:SCHEMA,edition:release.edition.clone(),company_id:profile.company_id,management_url:profile.management_url,profile_public_key:company_key.into(),release_public_key:release_key.into()}; @@ -113,7 +119,7 @@ pub async fn download(client:&reqwest::Client,url:&str,path:&Path,hash:&str)->Re let mut bytes=Vec::new(); while let Some(chunk)=response.chunk().await?{ensure!(bytes.len()+chunk.len()<=512*1024*1024,"Artifact too large");bytes.extend(chunk);} ensure!(digest(&bytes).eq_ignore_ascii_case(hash),"Artifact hash mismatch"); - std::fs::write(path,bytes)?;Ok(()) + tokio::fs::write(path,bytes).await?;Ok(()) } fn verify_windows(path:&Path,release:&Release,directory:&Path)->Result<()> { let verifier=directory.join("Verify-Package.ps1");std::fs::write(&verifier,include_bytes!("../../../deployment/windows/Verify-Package.ps1"))?; From 7335010597d8d86b0c0241a805c562d631650425 Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Fri, 2 Oct 2026 14:57:59 +0200 Subject: [PATCH 085/652] Publish staged update downloads atomically --- docs/RELEASE_PAYLOAD.md | 8 ++++++++ product/agent/src/update.rs | 34 ++++++++++++++++++++++++++++++++-- 2 files changed, 40 insertions(+), 2 deletions(-) diff --git a/docs/RELEASE_PAYLOAD.md b/docs/RELEASE_PAYLOAD.md index 7a560c58ec4..e631e52fc11 100644 --- a/docs/RELEASE_PAYLOAD.md +++ b/docs/RELEASE_PAYLOAD.md @@ -57,6 +57,14 @@ before advancing the sequence or removing the receipt. Customer completion restarts only the existing SYSTEM task whose action matches the canonical agent and `watch` arguments. No credentials are passed to the helper. +Downloaded installers and helpers are published through a unique temporary file +on the destination volume, flushed before atomic replacement. Download staging +and replacement run outside the async runtime threads. A failed replacement +cleans its temporary file and retains the previous complete staged artifact; +hash and publisher verification still precede execution. Component regression +coverage checks complete replacement and a destination-conflict failure. This +does not establish recovery from a power loss or a failed native installer. + A watcher can resume a pending update after restart; administrators can invoke `swan-agent resume-update` to hand off explicitly. `recover-update` continues to verify an already completed installation without rerunning its installer. diff --git a/product/agent/src/update.rs b/product/agent/src/update.rs index afe08dc0952..06bb4cefde1 100644 --- a/product/agent/src/update.rs +++ b/product/agent/src/update.rs @@ -68,7 +68,22 @@ async fn download(url:&str,hash:&str,path:&Path)->Result<()> { let mut bytes=Vec::new(); while let Some(chunk)=response.chunk().await? {ensure!(bytes.len()+chunk.len()<=512*1024*1024,"Package too large");bytes.extend_from_slice(&chunk);} ensure!(digest(&bytes).eq_ignore_ascii_case(hash),"Package hash mismatch"); - std::fs::write(path,bytes)?;Ok(()) + let path=path.to_owned(); + tokio::task::spawn_blocking(move ||publish_download(&path,&bytes)).await.context("Download publication task failed")? +} + +fn publish_download(path:&Path,bytes:&[u8])->Result<()> { + use std::io::Write; + // A crash or a concurrent reader must see the previous complete download, + // never a truncated installer or helper executable. + let temporary=path.parent().context("Missing download directory")?.join(format!("swan-download-{}.tmp",random_token())); + let mut file=std::fs::OpenOptions::new().create_new(true).write(true).open(&temporary)?; + let result=(||->Result<()> { + file.write_all(bytes)?;file.sync_all()?;drop(file); + crate::replace_state(&temporary,path).context("Cannot publish complete download") + })(); + if result.is_err(){std::fs::remove_file(&temporary).context("Cannot remove failed download staging file")?;} + result } #[cfg(windows)] @@ -341,7 +356,7 @@ impl AgentState { let Some(envelope)=self.approved_update(technician_token).await? else{return Ok(false);}; let release=validate_release(self,&envelope)?; ensure!(release.edition!=Edition::Technician || (release.format=="msi" || (release.format=="exe" && release.installed_sha256.eq_ignore_ascii_case(&release.sha256))),"Technician updates require an MSI or a portable EXE with matching installed identity"); - let folder=directory.join("updates").join(release.sequence.to_string());std::fs::create_dir_all(&folder)?; + let folder=directory.join("updates").join(release.sequence.to_string());tokio::fs::create_dir_all(&folder).await?; let check_folder=folder.clone();let check_release=release.clone(); tokio::task::spawn_blocking(move ||verify_compatibility(&check_folder,&check_release)).await.context("Compatibility verification task failed")??; let package=folder.join(format!("SwanRemoteSupport-install.{}",release.format)); @@ -408,6 +423,21 @@ fn verify_installed(directory:&Path,release:&Release)->Result<()> { mod tests { use super::*; #[test] + fn download_publication_retains_complete_files_and_cleans_failed_staging(){ + let folder=std::env::temp_dir().join(format!("swan-download-test-{}",random_token())); + std::fs::create_dir(&folder).unwrap(); + let target=folder.join("installer.exe"); + std::fs::write(&target,b"previous complete package").unwrap(); + publish_download(&target,b"new complete package").unwrap(); + assert_eq!(std::fs::read(&target).unwrap(),b"new complete package"); + let conflict=folder.join("directory.exe");std::fs::create_dir(&conflict).unwrap(); + assert!(publish_download(&conflict,b"replacement").is_err()); + assert!(conflict.is_dir()); + assert_eq!(std::fs::read(&target).unwrap(),b"new complete package"); + assert!(!std::fs::read_dir(&folder).unwrap().any(|entry|entry.unwrap().file_name().to_string_lossy().starts_with("swan-download-"))); + std::fs::remove_dir_all(folder).unwrap(); + } + #[test] fn executable_replacement_verifies_staged_bytes_and_preserves_previous_on_failure(){ let folder=std::env::temp_dir().join(format!("swan-replacement-test-{}",random_token())); std::fs::create_dir(&folder).unwrap(); From 111abe31de2f071418e370759ab5ff0d48992cfc Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Fri, 2 Oct 2026 15:05:41 +0200 Subject: [PATCH 086/652] Recover damaged update staging from signed receipt --- deployment/windows/test-update-download.js | 60 ++++++++++++ docs/ACCEPTANCE.md | 21 +++++ docs/RELEASE_PAYLOAD.md | 22 ++++- .../pages/company_technician_page.dart | 3 +- product/agent/src/main.rs | 2 +- product/agent/src/update.rs | 91 ++++++++++++++++++- src/managed.rs | 18 +++- 7 files changed, 205 insertions(+), 12 deletions(-) create mode 100644 deployment/windows/test-update-download.js diff --git a/deployment/windows/test-update-download.js b/deployment/windows/test-update-download.js new file mode 100644 index 00000000000..a67094f989f --- /dev/null +++ b/deployment/windows/test-update-download.js @@ -0,0 +1,60 @@ +'use strict'; +// Real HTTPS transport regression; no installer or helper code is executed. +const fs = require('node:fs'), path = require('node:path'); +const https = require('node:https'), crypto = require('node:crypto'); +const assert = require('node:assert/strict'); +const {spawn, spawnSync} = require('node:child_process'); +const [environment, executable, resultPath] = process.argv.slice(2); +assert(environment && executable && resultPath, 'Pass private TLS environment, Rust agent test executable, and new result path'); +assert(!fs.existsSync(resultPath), 'Do not overwrite previous evidence'); +const settings = {}; +for (const line of fs.readFileSync(environment, 'utf8').split(/\r?\n/)) { + if (!line.trim() || line.trimStart().startsWith('#')) continue; + const match = /^([A-Z_]+)=(.*)$/.exec(line); + assert(match, 'Invalid environment file'); settings[match[1]] = match[2]; +} +assert(settings.SWAN_TEST_TLS_PFX && settings.SWAN_TEST_CA_FILE, 'Private test TLS paths required'); +const payload = Buffer.from('Swan HTTPS recovery download fixture\n'); +const requests = []; +const server = https.createServer({pfx: fs.readFileSync(settings.SWAN_TEST_TLS_PFX), passphrase: settings.SWAN_TEST_TLS_PASSWORD}, (request, response) => { + requests.push({path: request.url, authorization: !!request.headers.authorization, cookie: !!request.headers.cookie}); + if (request.url === '/artifact') { response.writeHead(302, {location: '/payload'}); response.end(); } + else if (request.url === '/payload') { response.end(payload); } + else if (request.url === '/tampered') { response.end('tampered download'); } + else if (request.url === '/partial') { + response.writeHead(200, {'content-length': payload.length}); + response.flushHeaders(); response.write(payload.subarray(0, 8)); + setImmediate(() => response.destroy()); + } else { response.writeHead(404); response.end(); } +}); +let child; +async function main() { + await new Promise((resolve, reject) => { server.once('error', reject); server.listen(0, '127.0.0.1', resolve); }); + const base = `https://localhost:${server.address().port}`; + child = spawn(path.resolve(executable), ['update::tests::https_download_recovery_preserves_staging_on_tamper_and_interruption', '--exact', '--ignored', '--nocapture'], { + env: {...process.env, SWAN_TEST_CA_FILE: settings.SWAN_TEST_CA_FILE, SWAN_TEST_DOWNLOAD_BASE: base, SWAN_TECHNICIAN_TOKEN: 'not-a-real-credential-downloads-must-not-send-it'}, + stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true, + }); + let output = ''; + child.stdout.on('data', data => { output += data; }); + child.stderr.on('data', data => { output += data; }); + const code = await new Promise((resolve, reject) => { child.once('error', reject); child.once('exit', resolve); }); + assert.equal(code, 0, 'HTTPS download test failed'); + assert.match(output, /1 passed; 0 failed/); + for (const route of ['/artifact', '/payload', '/tampered', '/partial']) assert(requests.some(request => request.path === route), `Missing ${route} request`); + assert(requests.every(request => !request.authorization && !request.cookie), 'Artifact requests carried credentials'); + const hash = file => crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex'); + const root = path.resolve(__dirname, '../..'); + const commit = spawnSync('git', ['rev-parse', 'HEAD'], {cwd: root, encoding: 'utf8'}); + const dirty = spawnSync('git', ['status', '--porcelain'], {cwd: root, encoding: 'utf8'}); + assert.equal(commit.status, 0); assert.equal(dirty.status, 0); + fs.writeFileSync(resultPath, JSON.stringify({passed: true, at: new Date().toISOString(), source_commit: commit.stdout.trim(), source_dirty: dirty.stdout.trim().length > 0, agent_test_sha256: hash(executable), tls_identity_sha256: hash(settings.SWAN_TEST_CA_FILE), checks: ['real HTTPS redirect', 'exact artifact hash', 'tamper rejection retains staging', 'interrupted response retains staging', 'no artifact credentials', 'temporary staging cleaned'], requests, native_installer_executed: false}, null, 2), {flag: 'wx'}); + console.log('PASS: HTTPS redirect, tamper/interruption rejection, complete staging retained, no artifact credentials.'); +} +main().catch(error => { console.error(error.message); process.exitCode = 1; }).finally(async () => { + if (child && child.exitCode === null && child.signalCode === null) { + const exited = new Promise(resolve => child.once('exit', resolve)); child.kill(); await exited; + } + server.closeAllConnections(); + if (server.listening) await new Promise(resolve => server.close(resolve)); +}); diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index 690424a38a1..5bd9f5619f8 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -504,3 +504,24 @@ compilation passes. Real signed worker packaging, upload faults and publication remain unverified. After company profile-key activation, operators must update the worker's privately configured profile-key pin before rebuilding packages; automatic worker trust-pin rotation is not implemented. + +At source `7335010`, all 37 product workspace component tests pass on the local +Windows GNU toolchain. Update downloads now flush unique staging files before +atomic publication rather than overwriting a complete staged artifact in place. + +The subsequent recovery change re-downloads missing or hash-damaged artifacts +from the exact signed pending receipt, rechecks the publisher and unchanged +receipt, and retains session exclusion at handoff. Technician recovery runs in +the background with the existing native progress guard. The agent Windows build +and its 20 ordinary component tests pass. A separate explicitly ignored real +HTTPS download test passes through `test-update-download.js`: an HTTPS redirect +carries no authorization or cookie headers, and tampered or interrupted responses +leave complete staging intact. Private evidence records the executable hash and +dirty source state. This transport regression does not execute a native installer, +prove successful signed recovery, or complete automatic rollback. Native Rust +syntax parses locally; the changed Rust/Flutter integration requires CI and VM +verification. + +GitHub native build run 36998413349 at `2eec008` is confirmed successful. +Run 37005806648 at `4164964` remains queued as of the latest direct API check; +neither run proves the subsequent online recovery implementation. diff --git a/docs/RELEASE_PAYLOAD.md b/docs/RELEASE_PAYLOAD.md index e631e52fc11..717533c440b 100644 --- a/docs/RELEASE_PAYLOAD.md +++ b/docs/RELEASE_PAYLOAD.md @@ -65,14 +65,32 @@ hash and publisher verification still precede execution. Component regression coverage checks complete replacement and a destination-conflict failure. This does not establish recovery from a power loss or a failed native installer. +For the real HTTPS download regression, build the `swan-agent` test executable +with `cargo test --manifest-path product/Cargo.toml --locked -p swan-agent --no-run`. +Run `node deployment/windows/test-update-download.js PRIVATE_ENV TEST_EXE NEW_RESULT_JSON`. +The private environment supplies `SWAN_TEST_TLS_PFX`, `SWAN_TEST_TLS_PASSWORD` +and `SWAN_TEST_CA_FILE` for a verified localhost test identity. The harness binds +an ephemeral loopback port, executes only the explicitly ignored download test, +and closes its own listener. It checks an actual HTTPS redirect, absent artifact +credentials, tampered bytes and an interrupted response. Evidence includes the +test executable hash; no installer is executed. Do not commit private TLS files +or the environment. Release builds do not accept the debug-only test CA input. + A watcher can resume a pending update after restart; administrators can invoke `swan-agent resume-update` to hand off explicitly. `recover-update` continues to verify an already completed installation without rerunning its installer. Recovery may finish the previously selected signed release after metadata expiry; it cannot authorize a new release or relax the stored sequence. The helper log and previous agent/portable executable are retained in the protected update -staging directory. Automatic rollback, damaged-stage re-download and graphical -technician update coordination remain incomplete. Signed clean-machine tests +staging directory. Missing or hash-damaged staged installers and helpers are +re-downloaded from the exact HTTPS URLs in the validated pending receipt. These +downloads send no company credentials, must match the receipt's hashes and +pinned publisher, and do not select a newer release. Intact staging remains +usable offline. Recovery rechecks the unchanged receipt and session exclusion +before handoff. Permission and other staging I/O errors remain errors rather +than being treated as missing files. Automatic rollback and canonical-agent +recovery remain incomplete. Graphical technician update coordination and this +staging recovery still require native end-to-end verification. Signed clean-machine tests are required before claiming interruption or self-update acceptance. Recovery validation is shared by completion, retry and helper execution. Its diff --git a/flutter/lib/desktop/pages/company_technician_page.dart b/flutter/lib/desktop/pages/company_technician_page.dart index 93e4a4d6301..43e9611dbd7 100644 --- a/flutter/lib/desktop/pages/company_technician_page.dart +++ b/flutter/lib/desktop/pages/company_technician_page.dart @@ -35,8 +35,7 @@ class _CompanyTechnicianPageState extends State { Future _request(Map input) async { final text = await bind.mainCompanyRequest(request: jsonEncode(input)) - .first.timeout(input['action'] == 'resume-update' - ? const Duration(minutes: 25) : const Duration(seconds: 25)); + .first.timeout(const Duration(seconds: 25)); final response = jsonDecode(text) as Map; if (response['ok'] != true) { throw Exception(response['error'] ?? 'Company request failed'); diff --git a/product/agent/src/main.rs b/product/agent/src/main.rs index 741fe773da7..bbe6df86462 100644 --- a/product/agent/src/main.rs +++ b/product/agent/src/main.rs @@ -70,7 +70,7 @@ async fn main()->Result<()> { #[cfg(windows)] "resume-update"=>{ let state=AgentState::load_for_refresh(&directory)?; - println!("Update recovery handed off: {}",state.resume_pending_update(&directory,true)?); + println!("Update recovery handed off: {}",state.resume_pending_update_online(&directory,true).await?); } #[cfg(windows)] "apply-update"=>{ diff --git a/product/agent/src/update.rs b/product/agent/src/update.rs index 06bb4cefde1..1ec991466d5 100644 --- a/product/agent/src/update.rs +++ b/product/agent/src/update.rs @@ -5,7 +5,7 @@ use anyhow::{ensure,Context,Result}; use serde::{Deserialize,Serialize}; use std::path::Path; -#[derive(Serialize,Deserialize)] +#[derive(Clone,Serialize,Deserialize)] #[serde(deny_unknown_fields)] struct Receipt { release:SignedEnvelope, previous_sequence:u64, phase:String } @@ -86,6 +86,23 @@ fn publish_download(path:&Path,bytes:&[u8])->Result<()> { result } +fn staged_hash_matches(path:&Path,hash:&str)->Result { + match std::fs::read(path) { + Ok(bytes)=>Ok(digest(bytes).eq_ignore_ascii_case(hash)), + Err(error) if error.kind()==std::io::ErrorKind::NotFound=>Ok(false), + Err(error)=>Err(error).context("Cannot inspect staged download"), + } +} + +#[cfg(windows)] +async fn restore_staged_download(path:&Path,url:&str,hash:&str,release:&Release)->Result<()> { + let check_path=path.to_owned();let check_hash=hash.to_owned(); + let valid=tokio::task::spawn_blocking(move ||staged_hash_matches(&check_path,&check_hash)).await.context("Staging inspection task failed")??; + if !valid {download(url,hash,path).await?;} + let mut artifact_release=release.clone();artifact_release.sha256=hash.to_owned(); + verify_downloaded_publisher(path,&artifact_release).await +} + #[cfg(windows)] fn verify_publisher(path:&Path,release:&Release)->Result<()> { let script=path.parent().context("Missing update directory")?.join("Verify-Package.ps1"); @@ -266,12 +283,19 @@ impl AgentState { } #[cfg(windows)] pub fn resume_pending_update(&self,directory:&Path,retry_now:bool)->Result { + self.resume_prepared_update(directory,retry_now,None) + } + #[cfg(windows)] + fn resume_prepared_update(&self,directory:&Path,retry_now:bool,expected:Option<&Receipt>)->Result { let activity=activity_file(directory)?;fs2::FileExt::try_lock_exclusive(&activity).context("Update recovery already running or sessions remain active")?; let receipt_path=directory.join("pending-update.json"); if !receipt_path.exists(){return Ok(false);} ensure!(!directory.join("pending-install.json").exists(),"Company setup recovery must finish first"); let latest=AgentState::load_for_refresh(directory)?; let receipt:Receipt=serde_json::from_slice(&std::fs::read(&receipt_path)?)?; + if let Some(expected)=expected { + ensure!(receipt.release.payload==expected.release.payload && receipt.release.signature==expected.release.signature && receipt.previous_sequence==expected.previous_sequence && receipt.phase==expected.phase,"Pending update changed during staging recovery"); + } let release=validate_recovery_release(&latest,&receipt.release,receipt.previous_sequence,&receipt.phase)?; verify_compatibility(directory,&release)?; let attempt=directory.join("updates").join(release.sequence.to_string()).join("last-attempt.txt"); @@ -282,6 +306,36 @@ impl AgentState { launch_update_helper(directory,&release)?;Ok(true) } #[cfg(windows)] + pub async fn resume_pending_update_online(&self,directory:&Path,retry_now:bool)->Result { + let check_directory=directory.to_owned(); + let plan=tokio::task::spawn_blocking(move ||->Result> { + let activity=activity_file(&check_directory)?; + fs2::FileExt::try_lock_exclusive(&activity).context("Update recovery already running or sessions remain active")?; + let receipt_path=check_directory.join("pending-update.json"); + if !receipt_path.exists(){return Ok(None);} + ensure!(!check_directory.join("pending-install.json").exists(),"Company setup recovery must finish first"); + let latest=AgentState::load_for_refresh(&check_directory)?; + let receipt:Receipt=serde_json::from_slice(&std::fs::read(receipt_path)?)?; + let release=validate_recovery_release(&latest,&receipt.release,receipt.previous_sequence,&receipt.phase)?; + verify_compatibility(&check_directory,&release)?; + let attempt=check_directory.join("updates").join(release.sequence.to_string()).join("last-attempt.txt"); + if !retry_now && attempt.exists(){ + let previous=std::fs::read_to_string(attempt)?.parse::().context("Invalid update retry timestamp")?; + if now().saturating_sub(previous)<300{return Ok(None);} + } + Ok(Some((receipt,release))) + }).await.context("Recovery planning task failed")??; + let Some((receipt,release))=plan else{return Ok(false);}; + // The durable signed receipt authorizes only these exact artifact bytes. + // No credentials accompany release downloads, including redirects. + let folder=directory.join("updates").join(release.sequence.to_string()); + tokio::fs::create_dir_all(&folder).await?; + restore_staged_download(&folder.join(format!("SwanRemoteSupport-install.{}",release.format)),&release.artifact_url,&release.sha256,&release).await?; + restore_staged_download(&folder.join("swan-agent.exe"),&release.agent_url,&release.agent_sha256,&release).await?; + let state=self.clone();let directory=directory.to_owned(); + tokio::task::spawn_blocking(move ||state.resume_prepared_update(&directory,retry_now,Some(&receipt))).await.context("Recovery handoff task failed")? + } + #[cfg(windows)] pub fn apply_pending_update(&mut self,directory:&Path)->Result { // This method performs blocking Windows installation and is called from // spawn_blocking. A bounded wait lets the preparing process release its lock. @@ -344,8 +398,7 @@ impl AgentState { #[cfg(windows)] { ensure!(!directory.join("pending-install.json").exists(),"Company setup recovery is required before automatic updates"); if directory.join("pending-update.json").exists(){ - let state=self.clone();let directory=directory.to_owned(); - return tokio::task::spawn_blocking(move ||state.resume_pending_update(&directory,false)).await.context("Update recovery task failed")?; + return self.resume_pending_update_online(directory,false).await; } require_existing_application(&installed_target(directory,&self.bootstrap.edition)?)?; if !update_policy_open(self)?{return Ok(false);} @@ -422,6 +475,38 @@ fn verify_installed(directory:&Path,release:&Release)->Result<()> { #[cfg(test)] mod tests { use super::*; + #[tokio::test] + #[ignore="Requires the isolated loopback HTTPS download-recovery harness"] + async fn https_download_recovery_preserves_staging_on_tamper_and_interruption(){ + let base=std::env::var("SWAN_TEST_DOWNLOAD_BASE").expect("Run deployment/windows/test-update-download.js"); + assert!(base.starts_with("https://localhost:")); + let folder=std::env::temp_dir().join(format!("swan-https-download-test-{}",random_token())); + std::fs::create_dir(&folder).unwrap(); + let artifact=folder.join("installer.exe"); + let payload=b"Swan HTTPS recovery download fixture\n";let hash=digest(payload); + std::fs::write(&artifact,b"previous complete staging").unwrap(); + download(&format!("{base}/artifact"),&hash,&artifact).await.unwrap(); + assert_eq!(std::fs::read(&artifact).unwrap(),payload); + assert!(download(&format!("{base}/tampered"),&hash,&artifact).await.is_err()); + assert_eq!(std::fs::read(&artifact).unwrap(),payload); + assert!(download(&format!("{base}/partial"),&hash,&artifact).await.is_err()); + assert_eq!(std::fs::read(&artifact).unwrap(),payload); + assert!(!std::fs::read_dir(&folder).unwrap().any(|entry|entry.unwrap().file_name().to_string_lossy().starts_with("swan-download-"))); + std::fs::remove_dir_all(folder).unwrap(); + } + #[test] + fn recovery_staging_requires_exact_hash_and_distinguishes_missing_from_io_failure(){ + let folder=std::env::temp_dir().join(format!("swan-staging-test-{}",random_token())); + std::fs::create_dir(&folder).unwrap(); + let artifact=folder.join("helper.exe");let hash=digest(b"authorized helper"); + assert!(!staged_hash_matches(&artifact,&hash).unwrap()); + std::fs::write(&artifact,b"truncated helper").unwrap(); + assert!(!staged_hash_matches(&artifact,&hash).unwrap()); + publish_download(&artifact,b"authorized helper").unwrap(); + assert!(staged_hash_matches(&artifact,&hash.to_uppercase()).unwrap()); + assert!(staged_hash_matches(&folder,&hash).is_err()); + std::fs::remove_dir_all(folder).unwrap(); + } #[test] fn download_publication_retains_complete_files_and_cleans_failed_staging(){ let folder=std::env::temp_dir().join(format!("swan-download-test-{}",random_token())); diff --git a/src/managed.rs b/src/managed.rs index 39c274758b3..479f19fab23 100644 --- a/src/managed.rs +++ b/src/managed.rs @@ -52,11 +52,21 @@ async fn technician_request_inner(request:&str)->ResultType { // Recovery can finish an already authorized installation offline. It // cannot select a new release and does not require a reusable login. let pending=directory.join("pending-update.json").exists(); - {let memory=TECHNICIAN.lock().unwrap(); - if memory.update_running || memory.update_handed_off{return Ok(json!({"handed_off":memory.update_handed_off,"pending":pending}));}} if !pending || !crate::flutter::sessions::get_sessions().is_empty(){return Ok(json!({"handed_off":false,"pending":pending}));} - let handed_off=hbb_common::tokio::task::spawn_blocking(move ||state.resume_pending_update(&directory,false)).await??; - return Ok(json!({"handed_off":handed_off,"pending":true})); + {let mut memory=TECHNICIAN.lock().unwrap(); + if memory.update_running || memory.update_handed_off{return Ok(json!({"handed_off":memory.update_handed_off,"pending":pending}));} + memory.update_running=true;memory.update_failed=false; + } + hbb_common::tokio::spawn(async move { + let result=state.resume_pending_update_online(&directory,false).await; + let mut memory=TECHNICIAN.lock().unwrap(); + memory.update_running=false;memory.update_failed=result.is_err(); + memory.update_handed_off=matches!(result,Ok(true)); + if memory.update_handed_off { + memory.generation=memory.generation.wrapping_add(1);memory.login=None;memory.tickets.clear();memory.reconnects.clear(); + } + }); + return Ok(json!({"handed_off":false,"pending":true,"started":true})); } if action=="sync" { let mut state=state;state.sync().await?;state.save(&directory)?; From 16165e57884a30e79b73f3e2ae331966270136a1 Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Fri, 2 Oct 2026 15:14:39 +0200 Subject: [PATCH 087/652] Preserve verified complete release snapshots before updates --- docs/ACCEPTANCE.md | 19 +++++++++++ docs/RELEASE_PAYLOAD.md | 21 +++++++++++++ product/agent/src/lib.rs | 8 +++++ product/agent/src/payload.rs | 55 ++++++++++++++++++++++++++++++++ product/agent/src/update.rs | 61 ++++++++++++++++++++++++++++++++++-- 5 files changed, 161 insertions(+), 3 deletions(-) diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index 5bd9f5619f8..8159c1c69eb 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -525,3 +525,22 @@ verification. GitHub native build run 36998413349 at `2eec008` is confirmed successful. Run 37005806648 at `4164964` remains queued as of the latest direct API check; neither run proves the subsequent online recovery implementation. + +The superseded queued run 37005806648 was subsequently cancelled after checking +its current queued status. Replacement native build 37010780710 targets +`111abe3`, covering online recovery and background technician handoff; it was +confirmed queued when dispatched. + +New update preparation now requires a recorded signed installed release and +preserves its full endpoint manifest and configuration agent in a verified, +immutable rollback snapshot before writing the update receipt. The receipt +includes the exact previous signed envelope. The helper rejects a mismatched or +damaged snapshot before changing installed files. Branding, credentials, identity +and consent remain outside the software snapshot. Windows agent compilation and +21 ordinary agent tests pass (the separate real-HTTPS transport test is explicitly +ignored in this ordinary run). Further targeted tests pass for complete snapshots, +tampered and missing sources, unsafe paths, cleanup, immutable publication, and +previous-release signature/edition/sequence checks after metadata expiry. This is +verified rollback preparation, not completed automatic rollback: restoration, +failed-release suppression, MSI/service consistency and native power-loss testing +remain unfinished. diff --git a/docs/RELEASE_PAYLOAD.md b/docs/RELEASE_PAYLOAD.md index 717533c440b..73c1dcf777a 100644 --- a/docs/RELEASE_PAYLOAD.md +++ b/docs/RELEASE_PAYLOAD.md @@ -103,3 +103,24 @@ its installed agent still has a valid signature; pending receipts continue to block sessions. Corrupt or missing canonical agents still require recovery from a verified staged helper or explicit setup/repair. Actual scheduled-task and power-loss behavior remain unverified. + +New automatic updates require a previously recorded signed installation. Before +writing their pending receipt, they verify the current installed release and +publish a complete rollback snapshot under `updates/SEQUENCE/rollback`: the +signed installed-release envelope, every manifest-listed endpoint file (including +customer libraries and assets), and the matching configuration agent. Snapshot +files are hash checked while copying, flushed, and verified before the complete +directory is renamed into place. The old metadata is accepted after expiry only +for its exact recorded sequence and edition under the existing release trust key. +Executable and agent publisher checks remain mandatory. The update receipt binds +that previous envelope; a helper verifies its snapshot before changing the payload. +Mutable branding, identity, credentials and consent are not copied into release +snapshots or restored to older values. Legacy receipts without a snapshot can +continue forward recovery, but do not gain rollback evidence retroactively. + +This preserves verified rollback inputs; automatic restoration of those inputs, +Windows service/MSI registration recovery, failed-release suppression and full +power-loss rollback remain unfinished. Component tests cover complete file +preservation, rejection of tampered/missing sources and unsafe paths, incomplete +snapshot cleanup, immutable published snapshots, and previous metadata trust and +sequence checks. Signed installed-app rehearsals remain required. diff --git a/product/agent/src/lib.rs b/product/agent/src/lib.rs index 9dbcb3d1f98..d9050d5ed5a 100644 --- a/product/agent/src/lib.rs +++ b/product/agent/src/lib.rs @@ -306,13 +306,19 @@ mod tests { let key=SigningKey::from_bytes(&[7;32]); let mut release=Release{schema:1,product:PRODUCT.into(),version:"1.5.0".into(),sequence:2,edition:Edition::Customer,architecture:"x64".into(),channel:"test".into(),expires_at:now()+3600,artifact_url:"https://releases.example/package.exe".into(),sha256:"a".repeat(64),installed_sha256:"b".repeat(64),installed_files:vec![InstalledFile{path:"Swan Remote Support.exe".into(),sha256:"b".repeat(64)},InstalledFile{path:"librustdesk.dll".into(),sha256:"c".repeat(64)},InstalledFile{path:"flutter_windows.dll".into(),sha256:"d".repeat(64)}],agent_url:"https://releases.example/agent.exe".into(),agent_sha256:"c".repeat(64),publisher:"Example".into(),publisher_certificate_sha256:"d".repeat(64),windows_versions:vec!["windows_11".into()],source_url:"https://releases.example/source.tar.gz".into(),format:"exe".into()}; let envelope=SignedEnvelope::sign(&release,&key).unwrap(); + assert!(update::validate_previous_release(&state,&envelope,2).is_ok()); + assert!(update::validate_previous_release(&state,&envelope,0).is_err()); + assert!(update::validate_previous_release(&state,&envelope,1).is_err()); + assert!(update::validate_previous_release(&state,&envelope,3).is_err()); assert!(update::validate_recovery_release(&state,&envelope,1,"installing").is_ok()); assert!(update::validate_recovery_release(&state,&envelope,0,"installing").is_err()); assert!(update::validate_recovery_release(&state,&envelope,2,"installing").is_err()); assert!(update::validate_recovery_release(&state,&envelope,1,"unknown").is_err()); let mut tampered=envelope.clone();tampered.signature=STANDARD.encode([0u8;64]); + assert!(update::validate_previous_release(&state,&tampered,2).is_err()); assert!(update::validate_recovery_release(&state,&tampered,1,"installing").is_err()); let foreign=SignedEnvelope::sign(&release,&SigningKey::from_bytes(&[9;32])).unwrap(); + assert!(update::validate_previous_release(&state,&foreign,2).is_err()); assert!(update::validate_recovery_release(&state,&foreign,1,"installing").is_err()); state.last_release_sequence=3; assert!(update::validate_recovery_release(&state,&envelope,1,"installing").is_err()); @@ -320,9 +326,11 @@ mod tests { assert!(update::validate_recovery_release(&state,&envelope,1,"installing").is_ok(),"Recovery can finish after the sequence was saved before a crash"); state.last_release_sequence=1;release.expires_at=now()-1; let expired=SignedEnvelope::sign(&release,&key).unwrap(); + assert!(update::validate_previous_release(&state,&expired,2).is_ok(),"A signed recorded previous release remains available after metadata expiry"); assert!(update::validate_recovery_release(&state,&expired,1,"installing").is_ok(),"Previously selected interrupted work can finish"); assert!(update::validate_release(&state,&expired).is_err(),"Expired metadata cannot start a new update"); state.bootstrap.edition=Edition::Technician; + assert!(update::validate_previous_release(&state,&expired,2).is_err()); assert!(update::validate_recovery_release(&state,&expired,1,"installing").is_err()); } #[test] diff --git a/product/agent/src/payload.rs b/product/agent/src/payload.rs index 93dc89da097..97fee30d91d 100644 --- a/product/agent/src/payload.rs +++ b/product/agent/src/payload.rs @@ -3,6 +3,40 @@ use anyhow::{ensure,Context,Result}; use std::path::Path; use swan_protocol::{digest,InstalledFile}; +/// Preserve a complete signed payload before an installer can modify it. +/// Only a fully verified, flushed directory becomes visible at destination. +pub(crate) fn snapshot(source:&Path,destination:&Path,files:&[InstalledFile])->Result<()> { + use std::io::Write; + ensure!(!destination.exists(),"Rollback snapshot already exists"); + ensure!(!files.is_empty() && files.len()<=4096,"Installed payload manifest required"); + for file in files {file.validate()?;} + let source=std::fs::canonicalize(source)?; + let temporary=destination.parent().context("Missing snapshot parent")?.join(format!("swan-snapshot-{}",swan_protocol::random_token())); + std::fs::create_dir(&temporary)?; + let result=(||->Result<()> { + let mut total=0u64; + for file in files { + let path=std::fs::canonicalize(source.join(&file.path))?; + ensure!(path.starts_with(&source) && path.is_file(),"Snapshot source escapes installation"); + let size=std::fs::metadata(&path)?.len(); + ensure!(size<=512*1024*1024,"Snapshot file exceeds size limit"); + total=total.checked_add(size).context("Snapshot size overflow")?; + ensure!(total<=2*1024*1024*1024,"Snapshot exceeds total size limit"); + let bytes=std::fs::read(path)?; + ensure!(bytes.len() as u64==size,"Snapshot source changed during copying"); + ensure!(digest(&bytes).eq_ignore_ascii_case(&file.sha256),"Snapshot source differs from signed manifest"); + let target=temporary.join(&file.path); + std::fs::create_dir_all(target.parent().context("Missing snapshot file parent")?)?; + let mut output=std::fs::OpenOptions::new().create_new(true).write(true).open(target)?; + output.write_all(&bytes)?;output.sync_all()?; + } + verify(&temporary,files)?; + std::fs::rename(&temporary,destination).context("Cannot publish complete rollback snapshot") + })(); + if result.is_err(){std::fs::remove_dir_all(&temporary).context("Cannot clean incomplete rollback snapshot")?;} + result +} + pub fn verify(root:&Path,files:&[InstalledFile])->Result<()> { ensure!(!files.is_empty() && files.len()<=4096,"Installed payload manifest required"); let root=std::fs::canonicalize(root)?; @@ -39,6 +73,27 @@ pub fn verify(root:&Path,files:&[InstalledFile])->Result<()> { mod tests { use super::*; #[test] + fn snapshot_preserves_complete_payload_and_never_publishes_tampered_or_partial_source(){ + let root=std::env::temp_dir().join(format!("swan-snapshot-test-{}",swan_protocol::random_token())); + let source=root.join("installed");std::fs::create_dir_all(source.join("data")).unwrap(); + let files:Vec<_>=[("endpoint.exe",b"signed executable".as_slice()),("library.dll",b"signed library".as_slice()),("data/assets.json",b"signed assets".as_slice())].into_iter().map(|(path,bytes)|{std::fs::write(source.join(path),bytes).unwrap();InstalledFile{path:path.into(),sha256:digest(bytes)}}).collect(); + let saved=root.join("rollback");snapshot(&source,&saved,&files).unwrap(); + std::fs::write(source.join("library.dll"),b"installer replaced library").unwrap(); + verify(&saved,&files).unwrap(); + assert!(snapshot(&source,&saved,&files).is_err()); + assert!(snapshot(&source,&root.join("tampered"),&files).is_err()); + assert!(!root.join("tampered").exists()); + std::fs::write(source.join("library.dll"),b"signed library").unwrap(); + std::fs::remove_file(source.join("data/assets.json")).unwrap(); + assert!(snapshot(&source,&root.join("incomplete"),&files).is_err()); + assert!(!root.join("incomplete").exists()); + let mut traversal=files.clone();traversal[0].path="../escape.exe".into(); + assert!(snapshot(&source,&root.join("unsafe"),&traversal).is_err()); + assert!(!root.join("unsafe").exists()); + assert!(!std::fs::read_dir(&root).unwrap().any(|entry|entry.unwrap().file_name().to_string_lossy().starts_with("swan-snapshot-"))); + std::fs::remove_dir_all(root).unwrap(); + } + #[test] fn payload_rejects_library_asset_tampering_missing_files_and_extra_native_code(){ let root=std::env::temp_dir().join(format!("swan-payload-{}",swan_protocol::random_token())); std::fs::create_dir_all(root.join("data")).unwrap(); diff --git a/product/agent/src/update.rs b/product/agent/src/update.rs index 1ec991466d5..8d78c0ad770 100644 --- a/product/agent/src/update.rs +++ b/product/agent/src/update.rs @@ -7,7 +7,13 @@ use std::path::Path; #[derive(Clone,Serialize,Deserialize)] #[serde(deny_unknown_fields)] -struct Receipt { release:SignedEnvelope, previous_sequence:u64, phase:String } +struct Receipt { + release:SignedEnvelope, + previous_sequence:u64, + phase:String, + #[serde(default)] + previous_release:Option, +} fn require_existing_application(target:&Path)->Result<()> { ensure!(target.is_file(),"Application was removed; automatic updates cannot reinstall it. Run explicit company setup or repair."); @@ -213,6 +219,51 @@ fn save_installed_metadata(directory:&Path,envelope:&SignedEnvelope)->Result<()> crate::replace_state(&temporary,&directory.join("installed-release.json")) } +#[cfg(any(windows,test))] +pub(crate) fn validate_previous_release(state:&AgentState,envelope:&SignedEnvelope,previous_sequence:u64)->Result { + ensure!(previous_sequence>0,"Rollback requires a recorded signed installation"); + let release:Release=envelope.verify(&public_key(&state.bootstrap.release_public_key)?)?; + release.validate(&state.bootstrap.edition,previous_sequence-1,now().min(release.expires_at.saturating_sub(1)))?; + ensure!(release.sequence==previous_sequence,"Rollback release differs from previous installation"); + Ok(release) +} + +#[cfg(windows)] +fn verify_rollback_snapshot(state:&AgentState,directory:&Path,envelope:&SignedEnvelope,previous_sequence:u64)->Result<()> { + let release=validate_previous_release(state,envelope,previous_sequence)?; + let stored:SignedEnvelope=serde_json::from_slice(&std::fs::read(directory.join("installed-release.json"))?)?; + ensure!(stored.payload==envelope.payload && stored.signature==envelope.signature,"Rollback metadata differs from pending receipt"); + crate::payload::verify(&directory.join("endpoint"),&release.installed_files)?; + crate::payload::verify(&directory.join("agent"),&[InstalledFile{path:"swan-agent.exe".into(),sha256:release.agent_sha256.clone()}])?; + let mut executable_release=release.clone();executable_release.sha256=release.installed_sha256.clone(); + let executable=if release.edition==Edition::Technician {"SwanRemoteSupport-Technician.exe"}else{"Swan Remote Support.exe"}; + verify_publisher(&directory.join("endpoint").join(executable),&executable_release)?; + let mut agent_release=release.clone();agent_release.sha256=release.agent_sha256.clone(); + verify_publisher(&directory.join("agent/swan-agent.exe"),&agent_release) +} + +#[cfg(windows)] +fn prepare_rollback_snapshot(state:&AgentState,directory:&Path,update_sequence:u64)->Result { + let envelope:SignedEnvelope=serde_json::from_slice(&std::fs::read(directory.join("installed-release.json"))?)?; + let previous=validate_previous_release(state,&envelope,state.last_release_sequence)?; + verify_compatibility(directory,&previous)?; + verify_installed(directory,&previous)?; + let folder=directory.join("updates").join(update_sequence.to_string()); + let destination=folder.join("rollback"); + if destination.exists(){verify_rollback_snapshot(state,&destination,&envelope,state.last_release_sequence)?;return Ok(envelope);} + let temporary=folder.join(format!("swan-rollback-{}",random_token()));std::fs::create_dir(&temporary)?; + let result=(||->Result<()> { + let target=installed_target(directory,&state.bootstrap.edition)?; + crate::payload::snapshot(target.parent().context("Missing installed directory")?,&temporary.join("endpoint"),&previous.installed_files)?; + crate::payload::snapshot(directory,&temporary.join("agent"),&[InstalledFile{path:"swan-agent.exe".into(),sha256:previous.agent_sha256.clone()}])?; + save_installed_metadata(&temporary,&envelope)?; + verify_rollback_snapshot(state,&temporary,&envelope,state.last_release_sequence)?; + std::fs::rename(&temporary,&destination).context("Cannot publish complete release rollback snapshot") + })(); + if result.is_err(){std::fs::remove_dir_all(&temporary).context("Cannot remove incomplete release rollback snapshot")?;} + result?;Ok(envelope) +} + #[cfg(windows)] fn launch_update_helper(directory:&Path,release:&Release)->Result<()> { use std::os::windows::process::CommandExt; @@ -294,7 +345,7 @@ impl AgentState { let latest=AgentState::load_for_refresh(directory)?; let receipt:Receipt=serde_json::from_slice(&std::fs::read(&receipt_path)?)?; if let Some(expected)=expected { - ensure!(receipt.release.payload==expected.release.payload && receipt.release.signature==expected.release.signature && receipt.previous_sequence==expected.previous_sequence && receipt.phase==expected.phase,"Pending update changed during staging recovery"); + ensure!(serde_json::to_vec(&receipt)?==serde_json::to_vec(expected)?,"Pending update changed during staging recovery"); } let release=validate_recovery_release(&latest,&receipt.release,receipt.previous_sequence,&receipt.phase)?; verify_compatibility(directory,&release)?; @@ -364,6 +415,9 @@ impl AgentState { verify_publisher(&package,&release)?; if let Err(error)=verify_installed(directory,&release) { eprintln!("Installed payload requires recovery: {error:#}"); + if let Some(previous)=receipt.previous_release.as_ref(){ + verify_rollback_snapshot(&latest,&folder.join("rollback"),previous,receipt.previous_sequence)?; + } if latest.bootstrap.edition==Edition::Technician { ensure!(release.format=="msi" || (release.format=="exe" && release.sha256.eq_ignore_ascii_case(&release.installed_sha256)),"Technician updates require an MSI or a portable EXE"); let target=installed_target(directory,&latest.bootstrap.edition)?; @@ -435,7 +489,8 @@ impl AgentState { // Removal during download must not be turned into a fresh install. require_existing_application(&installed_target(&directory,&latest.bootstrap.edition)?)?; validate_release(&latest,&envelope)?; - let receipt=Receipt{release:envelope,previous_sequence:latest.last_release_sequence,phase:"installing".into()}; + let previous_release=prepare_rollback_snapshot(&latest,&directory,release.sequence)?; + let receipt=Receipt{release:envelope,previous_sequence:latest.last_release_sequence,phase:"installing".into(),previous_release:Some(previous_release)}; let mut options=std::fs::OpenOptions::new();options.create_new(true).write(true); use std::io::Write; let mut file=options.open(&receipt_path)?;file.write_all(&serde_json::to_vec(&receipt)?)?;file.sync_all()?;drop(file); From f76b2bd6440d1d0d6729c195dc49e4cdd36a7721 Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Fri, 2 Oct 2026 15:36:28 +0200 Subject: [PATCH 088/652] Add resumable portable rollback and failed release quarantine --- docs/ACCEPTANCE.md | 26 +++- docs/RELEASE_PAYLOAD.md | 41 +++++- product/agent/src/lib.rs | 6 +- product/agent/src/main.rs | 1 + product/agent/src/update.rs | 220 +++++++++++++++++++++++++++--- product/management/src/service.rs | 2 +- product/protocol/src/lib.rs | 9 +- src/managed.rs | 2 +- 8 files changed, 273 insertions(+), 34 deletions(-) diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index 8159c1c69eb..be7a8821d90 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -83,19 +83,19 @@ remain unverified. | Requirement | Current evidence and remaining work | | --- | --- | -| Reproducible server, customer, technician and worker builds | Product components pass pinned builds. Native Windows build/package run 36970913876 passed at exact source `7e589ba`; the later manifest/installer-locking build 36973173582 remains in progress. Baseline rebuild and clean-machine installation are not newly proved. | +| Reproducible server, customer, technician and worker builds | Product components pass pinned builds. Native Windows build/package run 36998413349 passed at exact source `2eec008`. Later native source, including consent/reconnect/version and rollback changes, still needs CI and VM verification. Baseline rebuild and clean-machine installation are not newly proved. | | Fresh-company Windows/Linux setup | HTTPS/API setup works. Administrator network settings and server-vantage DNS/TCP/HTTPS company-signature diagnostics are implemented with access tests. Equivalent full setup wizards, transport provisioning, certificate management and external reachability checks remain incomplete; see NETWORK_DIAGNOSTICS.md. | | Roles, groups, permissions and revocation | MFA/group/device denial tests pass. Signed capability fields, administrator group policy APIs, and receiver capability bounds are implemented in source. Policy reduction denies lease renewal in component tests. Administration controls, native enforcement and direct-connection bypass tests remain unverified. | | Configurable customer and technician apps | Signed profile sync passes. Graphical login/inventory/history, logo/color rendering, contacts, company shortcuts, offline operation and native restart/upgrade preservation remain to verify or complete. | -| Signed profiles and rotation | Component tests reject tampering, wrong-company, expired and older profiles and untrusted key changes. Trusted cached key/endpoint rotation preserves enrollment in tests. Real HTTPS rotation and installed-app behavior still need verification. | +| Signed profiles and rotation | Component tests reject tampering, wrong-company, expired and older profiles and untrusted key changes. Trusted cached key/endpoint rotation preserves enrollment in tests. Real isolated HTTPS trusted key rotation, missed-transition rejection and restored rotated trust pass for both CLI agents. Graphical installed-app behavior remains unverified. | | Approved managed sessions without password/direct bypass | Grant signature, target, challenge and replay checks pass component/API tests. Actual receiving-device enforcement, direct/relay paths, lease expiry and live revocation are unverified. | | Customer approval and unattended consent | API consent denial and durable local revocation tests pass. Real attended prompts, stop/uninstall, unattended opt-in, restart, sign-in screen and UAC are unverified. | | Transfer, clipboard, multiple monitors | Native end-to-end tests pending. | | Public company transport without Tailscale | Source uses company transport and fails closed before setup. External NAT traversal, relay fallback and negative-access tests pending; no public endpoint has been changed. | | Windows worker and public/protected downloads | Queue, worker, fixed bundles and access checks are implemented. A real signed worker job, both EXE/MSI installation products, protected technician download and public customer website installation remain unverified or incomplete. | | Signing and exact corresponding source | Certificate/hash/publisher pins and source metadata are implemented. Provider approval, production signatures, company signing integration and production release checks remain gates. | -| Approved automatic updates | Metadata replay/expiry and session exclusion tests pass. Signed installed manifests now bound customer DLLs/assets and reject unlisted native code in component tests; real signed installation remains unverified. Real rollout, pause/windows, agent replacement, technician executable handoff, interruption repair/retry/rollback and recovery remain incomplete or unverified; see RELEASE_PAYLOAD.md. | -| Backup, restore and migrations | Encrypted round-trip/tamper checks cover management data, transport trust keys and SQLite state, deployment configuration and an explicit TLS identity. Actual CLI export/restore and a live isolated HTTPS management-server rehearsal preserve signing trust, MFA, accounts, device state and group policy; revoked access remains denied. Restored SQLite quick_check passes. Live transport recovery, complete certificate-service storage, post-backup reconciliation and migration rollback remain unverified or incomplete; see SERVER_BACKUP.md. | +| Approved automatic updates | Metadata replay/expiry, session exclusion, complete snapshots and signed failed-release quarantine tests pass. Real HTTPS download tamper/interruption rejection passes. Portable technician restoration and resumable rollback are implemented, but signed native restoration is unverified. Customer/MSI rollback, service/installer consistency, full rollout, interruption and clean-machine recovery remain incomplete or unverified; see RELEASE_PAYLOAD.md. | +| Backup, restore and migrations | Encrypted round-trip/tamper checks cover management, transport trust keys, SQLite, private deployment configuration and complete TLS storage trees. Real CLI export/restore and isolated HTTPS rehearsals preserve signing trust, MFA, accounts, devices, groups, consent and rotated keys; revoked grants remain denied. Restored SQLite quick_check passes. Schema upgrade requires encrypted pre-upgrade backup in component tests. Live transport/ACME renewal, post-backup reconciliation and full migration rollback remain unverified; see SERVER_BACKUP.md. | ## Clean Windows matrix @@ -544,3 +544,21 @@ previous-release signature/edition/sequence checks after metadata expiry. This i verified rollback preparation, not completed automatic rollback: restoration, failed-release suppression, MSI/service consistency and native power-loss testing remain unfinished. + +Portable technician EXE rollback is now implemented for a previous release whose +signed metadata declares `rollback_protocol: 1` and whose verified agent confirms +that support. It preserves the old installed sequence, writes a durable pending +rollback phase before restoring signed snapshot files, verifies the restored +identity, and publishes a signed-envelope failed-release quarantine before +removing the pending marker. New discovery/installation suppresses that sequence +and older ones; later approved releases remain eligible. Current branding, +enrollment and consent are not restored from software snapshots. Customer/MSI +restoration and service/registration consistency remain unfinished. + +All 39 ordinary product workspace tests pass for this change. The real-HTTPS +download test is explicitly ignored in that ordinary run. Targeted quarantine +and receipt tests additionally pass after initial receipt publication became +atomic and unable to overwrite an existing receipt. The Windows agent build +passes, and its read-only capability command returns protocol 1 without creating +company state. Native Rust syntax parses locally; native Rust/Flutter compilation +and actual signed rollback, reboot/interruption and installer tests remain gates. diff --git a/docs/RELEASE_PAYLOAD.md b/docs/RELEASE_PAYLOAD.md index 73c1dcf777a..3153ce38edd 100644 --- a/docs/RELEASE_PAYLOAD.md +++ b/docs/RELEASE_PAYLOAD.md @@ -7,6 +7,12 @@ The installed main executable hash must also equal `installed_sha256`. Technician manifests contain one entry for `SwanRemoteSupport-Technician.exe`; that hash covers its complete self-extracting payload. +Release metadata may declare `rollback_protocol: 1` only when both its endpoint +and configuration agent implement the failed-release quarantine and pending +rollback format described below. The release signer attests this compatibility; +do not add the declaration to old binaries. Omitted values default to zero and +permit forward recovery only. Unsupported protocol numbers are rejected. + Generate release inputs after signing the final binaries: ```text @@ -88,7 +94,7 @@ downloads send no company credentials, must match the receipt's hashes and pinned publisher, and do not select a newer release. Intact staging remains usable offline. Recovery rechecks the unchanged receipt and session exclusion before handoff. Permission and other staging I/O errors remain errors rather -than being treated as missing files. Automatic rollback and canonical-agent +than being treated as missing files. Complete customer/MSI rollback and canonical-agent recovery remain incomplete. Graphical technician update coordination and this staging recovery still require native end-to-end verification. Signed clean-machine tests are required before claiming interruption or self-update acceptance. @@ -118,9 +124,34 @@ Mutable branding, identity, credentials and consent are not copied into release snapshots or restored to older values. Legacy receipts without a snapshot can continue forward recovery, but do not gain rollback evidence retroactively. -This preserves verified rollback inputs; automatic restoration of those inputs, -Windows service/MSI registration recovery, failed-release suppression and full -power-loss rollback remain unfinished. Component tests cover complete file +Portable technician EXE updates now restore those verified inputs after an +installation or installed-identity failure, provided the previous signed metadata +declares rollback protocol 1 and the verified previous agent's `rollback-protocol` +command confirms support. A durable `rolling_back` receipt precedes restoration. +Automatic upgrades retain the installed EXE/MSI format; changing formats requires +explicit installation/migration so installer registrations are not silently lost. +Retries finish that same rollback instead of attempting forward installation; +rollback does not require the new installer package to remain available. The +old EXE and agent are replaced through verified atomic staging, and their full +installed identity is checked before the previous metadata becomes current. +Deleted applications are not resurrected, and a committed newer installation +cannot be downgraded by this recovery path. The unchanged state retains the +previous installed sequence and current company identity and consent. + +Before removing the pending receipt, rollback durably records `failed-update.json` +with the failed and previous signed envelopes. Discovery and fresh installation +reject sequences at or below that watermark; only a later approved sequence can +advance. Repair still requires the exact installed release. Invalid quarantine +metadata, wrong trust or edition, unknown phases/protocols and I/O errors fail +closed. Quarantine persists through subsequent successful installations. No +technician login token is passed to or restored by rollback. + +Windows service/MSI registration rollback and full power-loss acceptance remain +unfinished. The portable restoration path is implemented but not yet demonstrated +with signed installed binaries on clean Windows. Component tests cover complete file preservation, rejection of tampered/missing sources and unsafe paths, incomplete snapshot cleanup, immutable published snapshots, and previous metadata trust and -sequence checks. Signed installed-app rehearsals remain required. +sequence checks. Quarantine/receipt regressions cover replay suppression, eligibility +of later releases, metadata expiry, old protocol exclusion, wrong edition/key, +tampering, malformed storage and legacy forward recovery. Signed installed-app +rehearsals remain required; these tests do not substitute for native rollback. diff --git a/product/agent/src/lib.rs b/product/agent/src/lib.rs index d9050d5ed5a..af9de542583 100644 --- a/product/agent/src/lib.rs +++ b/product/agent/src/lib.rs @@ -304,13 +304,15 @@ mod tests { fn update_recovery_rejects_tampering_sequence_conflicts_and_new_install_replay(){ let mut state=fixture();state.last_release_sequence=1; let key=SigningKey::from_bytes(&[7;32]); - let mut release=Release{schema:1,product:PRODUCT.into(),version:"1.5.0".into(),sequence:2,edition:Edition::Customer,architecture:"x64".into(),channel:"test".into(),expires_at:now()+3600,artifact_url:"https://releases.example/package.exe".into(),sha256:"a".repeat(64),installed_sha256:"b".repeat(64),installed_files:vec![InstalledFile{path:"Swan Remote Support.exe".into(),sha256:"b".repeat(64)},InstalledFile{path:"librustdesk.dll".into(),sha256:"c".repeat(64)},InstalledFile{path:"flutter_windows.dll".into(),sha256:"d".repeat(64)}],agent_url:"https://releases.example/agent.exe".into(),agent_sha256:"c".repeat(64),publisher:"Example".into(),publisher_certificate_sha256:"d".repeat(64),windows_versions:vec!["windows_11".into()],source_url:"https://releases.example/source.tar.gz".into(),format:"exe".into()}; + let mut release=Release{schema:1,product:PRODUCT.into(),version:"1.5.0".into(),sequence:2,edition:Edition::Customer,architecture:"x64".into(),channel:"test".into(),expires_at:now()+3600,artifact_url:"https://releases.example/package.exe".into(),sha256:"a".repeat(64),installed_sha256:"b".repeat(64),installed_files:vec![InstalledFile{path:"Swan Remote Support.exe".into(),sha256:"b".repeat(64)},InstalledFile{path:"librustdesk.dll".into(),sha256:"c".repeat(64)},InstalledFile{path:"flutter_windows.dll".into(),sha256:"d".repeat(64)}],agent_url:"https://releases.example/agent.exe".into(),agent_sha256:"c".repeat(64),publisher:"Example".into(),publisher_certificate_sha256:"d".repeat(64),windows_versions:vec!["windows_11".into()],source_url:"https://releases.example/source.tar.gz".into(),format:"exe".into(),rollback_protocol:0}; let envelope=SignedEnvelope::sign(&release,&key).unwrap(); + update::test_failed_release_quarantine(&state,&release,&key); assert!(update::validate_previous_release(&state,&envelope,2).is_ok()); assert!(update::validate_previous_release(&state,&envelope,0).is_err()); assert!(update::validate_previous_release(&state,&envelope,1).is_err()); assert!(update::validate_previous_release(&state,&envelope,3).is_err()); assert!(update::validate_recovery_release(&state,&envelope,1,"installing").is_ok()); + assert!(update::validate_recovery_release(&state,&envelope,1,"rolling_back").is_ok()); assert!(update::validate_recovery_release(&state,&envelope,0,"installing").is_err()); assert!(update::validate_recovery_release(&state,&envelope,2,"installing").is_err()); assert!(update::validate_recovery_release(&state,&envelope,1,"unknown").is_err()); @@ -336,7 +338,7 @@ mod tests { #[test] fn explicit_repair_accepts_only_exact_pinned_release_without_weakening_updates() { let mut state=fixture();let key=SigningKey::from_bytes(&[7;32]); - let mut release=Release{schema:1,product:PRODUCT.into(),version:"1.5.0".into(),sequence:2,edition:Edition::Customer,architecture:"x64".into(),channel:"test".into(),expires_at:now()+3600,artifact_url:"https://releases.example/package.exe".into(),sha256:"a".repeat(64),installed_sha256:"b".repeat(64),installed_files:vec![InstalledFile{path:"Swan Remote Support.exe".into(),sha256:"b".repeat(64)},InstalledFile{path:"librustdesk.dll".into(),sha256:"c".repeat(64)},InstalledFile{path:"flutter_windows.dll".into(),sha256:"d".repeat(64)}],agent_url:"https://releases.example/agent.exe".into(),agent_sha256:"c".repeat(64),publisher:"Example".into(),publisher_certificate_sha256:"d".repeat(64),windows_versions:vec!["windows_11".into()],source_url:"https://releases.example/source.tar.gz".into(),format:"exe".into()}; + let mut release=Release{schema:1,product:PRODUCT.into(),version:"1.5.0".into(),sequence:2,edition:Edition::Customer,architecture:"x64".into(),channel:"test".into(),expires_at:now()+3600,artifact_url:"https://releases.example/package.exe".into(),sha256:"a".repeat(64),installed_sha256:"b".repeat(64),installed_files:vec![InstalledFile{path:"Swan Remote Support.exe".into(),sha256:"b".repeat(64)},InstalledFile{path:"librustdesk.dll".into(),sha256:"c".repeat(64)},InstalledFile{path:"flutter_windows.dll".into(),sha256:"d".repeat(64)}],agent_url:"https://releases.example/agent.exe".into(),agent_sha256:"c".repeat(64),publisher:"Example".into(),publisher_certificate_sha256:"d".repeat(64),windows_versions:vec!["windows_11".into()],source_url:"https://releases.example/source.tar.gz".into(),format:"exe".into(),rollback_protocol:0}; let pinned=SignedEnvelope::sign(&release,&key).unwrap(); assert!(update::validate_repair_release(&state,&pinned,&pinned).is_err(),"Unrecorded installation cannot use repair"); state.last_release_sequence=2; diff --git a/product/agent/src/main.rs b/product/agent/src/main.rs index bbe6df86462..cd08457cefb 100644 --- a/product/agent/src/main.rs +++ b/product/agent/src/main.rs @@ -12,6 +12,7 @@ async fn main()->Result<()> { let directory=state_directory(); let command=args.get(1).map(String::as_str).unwrap_or("help"); match command { + "rollback-protocol"=>println!("1"), #[cfg(windows)] "remove-configuration-task"|"restore-configuration-task"=>{ let executable=std::env::current_exe()?; diff --git a/product/agent/src/update.rs b/product/agent/src/update.rs index 8d78c0ad770..e02a4b30226 100644 --- a/product/agent/src/update.rs +++ b/product/agent/src/update.rs @@ -13,6 +13,8 @@ struct Receipt { phase:String, #[serde(default)] previous_release:Option, + #[serde(default)] + rollback_protocol:u32, } fn require_existing_application(target:&Path)->Result<()> { @@ -48,7 +50,7 @@ pub fn validate_repair_release(state:&AgentState,envelope:&SignedEnvelope,instal #[cfg(any(windows,test))] pub(crate) fn validate_recovery_release(state:&AgentState,envelope:&SignedEnvelope,previous_sequence:u64,phase:&str)->Result { - ensure!(phase=="installing","Unknown update recovery phase"); + ensure!(matches!(phase,"installing"|"rolling_back"),"Unknown update recovery phase"); let release:Release=envelope.verify(&public_key(&state.bootstrap.release_public_key)?)?; release.validate(&state.bootstrap.edition,previous_sequence,now().min(release.expires_at.saturating_sub(1)))?; ensure!(state.last_release_sequence==previous_sequence || state.last_release_sequence==release.sequence,"Recovery sequence conflict"); @@ -61,7 +63,43 @@ fn installation_release(state:&AgentState,directory:&Path,envelope:&SignedEnvelo if repair { let installed:SignedEnvelope=serde_json::from_slice(&std::fs::read(directory.join("installed-release.json"))?)?; validate_repair_release(state,envelope,&installed) - }else{validate_release(state,envelope)} + }else{ + let release=validate_release(state,envelope)?; + ensure!(release.sequence>failed_release_sequence(state,directory)?,"Failed release is quarantined; approve a newer release"); + Ok(release) + } +} + +#[cfg(any(windows,test))] +fn validate_pending_receipt(state:&AgentState,receipt:&Receipt)->Result { + ensure!(receipt.rollback_protocol<=1,"Unsupported pending rollback protocol"); + let release=validate_recovery_release(state,&receipt.release,receipt.previous_sequence,&receipt.phase)?; + if let Some(previous)=receipt.previous_release.as_ref(){ + let old=validate_previous_release(state,previous,receipt.previous_sequence)?; + ensure!(old.format==release.format,"Installer format migration requires explicit setup"); + } + if receipt.phase=="rolling_back" { + ensure!(state.last_release_sequence==receipt.previous_sequence,"Cannot roll back a committed newer installation"); + let previous=receipt.previous_release.as_ref().context("Rollback requires previous release metadata")?; + ensure!(receipt.rollback_protocol==1 && validate_previous_release(state,previous,receipt.previous_sequence)?.rollback_protocol==1,"Previous release cannot enforce rollback quarantine"); + } + Ok(release) +} + +fn failed_release_sequence(state:&AgentState,directory:&Path)->Result { + let path=directory.join("failed-update.json"); + let bytes=match std::fs::read(path) { + Ok(bytes)=>bytes, + Err(error) if error.kind()==std::io::ErrorKind::NotFound=>return Ok(0), + Err(error)=>return Err(error).context("Cannot read failed update quarantine"), + }; + let receipt:Receipt=serde_json::from_slice(&bytes)?; + ensure!(receipt.phase=="rolled_back" && receipt.rollback_protocol==1,"Invalid failed update quarantine"); + let release:Release=receipt.release.verify(&public_key(&state.bootstrap.release_public_key)?)?; + release.validate(&state.bootstrap.edition,receipt.previous_sequence,now().min(release.expires_at.saturating_sub(1)))?; + let previous=receipt.previous_release.context("Failed update lacks previous release")?; + ensure!(validate_previous_release(state,&previous,receipt.previous_sequence)?.rollback_protocol==1,"Previous release does not support quarantine"); + Ok(release.sequence) } async fn download(url:&str,hash:&str,path:&Path)->Result<()> { @@ -92,6 +130,28 @@ fn publish_download(path:&Path,bytes:&[u8])->Result<()> { result } +#[cfg(any(windows,test))] +fn publish_initial_receipt(path:&Path,receipt:&Receipt)->Result<()> { + use std::io::Write; + let temporary=path.parent().context("Missing receipt directory")?.join(format!("swan-receipt-{}.tmp",random_token())); + let mut file=std::fs::OpenOptions::new().create_new(true).write(true).open(&temporary)?; + let result=(||->Result<()> { + file.write_all(&serde_json::to_vec(receipt)?)?;file.sync_all()?;drop(file); + #[cfg(windows)] { + use std::os::windows::ffi::OsStrExt; + use windows_sys::Win32::Storage::FileSystem::{MoveFileExW,MOVEFILE_WRITE_THROUGH}; + let from:Vec=temporary.as_os_str().encode_wide().chain(Some(0)).collect(); + let to:Vec=path.as_os_str().encode_wide().chain(Some(0)).collect(); + // Do not replace a receipt another updater already published. + if unsafe{MoveFileExW(from.as_ptr(),to.as_ptr(),MOVEFILE_WRITE_THROUGH)}==0 {return Err(std::io::Error::last_os_error().into());} + } + #[cfg(not(windows))] {std::fs::hard_link(&temporary,path)?;std::fs::remove_file(&temporary)?;} + Ok(()) + })(); + if result.is_err(){std::fs::remove_file(&temporary).context("Cannot clean failed receipt staging")?;} + result +} + fn staged_hash_matches(path:&Path,hash:&str)->Result { match std::fs::read(path) { Ok(bytes)=>Ok(digest(bytes).eq_ignore_ascii_case(hash)), @@ -219,7 +279,6 @@ fn save_installed_metadata(directory:&Path,envelope:&SignedEnvelope)->Result<()> crate::replace_state(&temporary,&directory.join("installed-release.json")) } -#[cfg(any(windows,test))] pub(crate) fn validate_previous_release(state:&AgentState,envelope:&SignedEnvelope,previous_sequence:u64)->Result { ensure!(previous_sequence>0,"Rollback requires a recorded signed installation"); let release:Release=envelope.verify(&public_key(&state.bootstrap.release_public_key)?)?; @@ -228,6 +287,48 @@ pub(crate) fn validate_previous_release(state:&AgentState,envelope:&SignedEnvelo Ok(release) } +#[cfg(windows)] +fn save_receipt(path:&Path,receipt:&Receipt)->Result<()> { + publish_download(path,&serde_json::to_vec(receipt)?) +} + +#[cfg(windows)] +fn agent_rollback_protocol(agent:&Path,directory:&Path)->Result { + let output=std::process::Command::new(agent).arg("rollback-protocol") + .env("SWAN_STATE_DIR",directory).env_remove("SWAN_TECHNICIAN_TOKEN") + .env_remove("SWAN_SESSION_GRANT").env_remove("SWAN_SESSION_PROOF_KEY") + .output().context("Cannot inspect signed previous agent rollback support")?; + // Older signed agents may not implement the command. They can still upgrade, + // but cannot be automatically restored with an unsupported quarantine format. + Ok(if output.status.success() && std::str::from_utf8(&output.stdout)?.trim()=="1" {1}else{0}) +} + +#[cfg(windows)] +fn rollback_portable(state:&AgentState,directory:&Path,folder:&Path,receipt:&Receipt)->Result<()> { + ensure!(receipt.rollback_protocol==1 && receipt.phase=="rolling_back","Rollback was not durably prepared"); + ensure!(state.last_release_sequence==receipt.previous_sequence,"Cannot roll back a committed newer installation"); + let previous=receipt.previous_release.as_ref().context("Rollback requires a signed previous release")?; + let release=validate_previous_release(state,previous,receipt.previous_sequence)?; + ensure!(release.rollback_protocol==1 && release.edition==Edition::Technician && release.format=="exe" && release.sha256.eq_ignore_ascii_case(&release.installed_sha256),"Portable rollback requires a compatible portable release and cannot modify MSI or customer registrations"); + let snapshot=folder.join("rollback"); + verify_compatibility(directory,&release)?; + verify_rollback_snapshot(state,&snapshot,previous,receipt.previous_sequence)?; + ensure!(agent_rollback_protocol(&snapshot.join("agent/swan-agent.exe"),directory)?==1,"Previous agent cannot enforce failed-release quarantine"); + let target=installed_target(directory,&state.bootstrap.edition)?; + require_existing_application(&target)?; + replace_verified_file(&snapshot.join("endpoint/SwanRemoteSupport-Technician.exe"),&target,&release.installed_sha256)?; + replace_verified_file(&snapshot.join("agent/swan-agent.exe"),&directory.join("swan-agent.exe"),&release.agent_sha256)?; + verify_installed(directory,&release)?; + save_installed_metadata(directory,previous)?; + let mut failed=receipt.clone();failed.phase="rolled_back".into(); + ensure!(receipt.release.verify::(&public_key(&state.bootstrap.release_public_key)?)?.sequence>=failed_release_sequence(state,directory)?,"Cannot lower failed-release quarantine"); + // Persist quarantine before unblocking sessions. No enrollment or consent + // state is restored, and the installed sequence never advances on failure. + save_receipt(&directory.join("failed-update.json"),&failed)?; + std::fs::remove_file(directory.join("pending-update.json"))?; + Ok(()) +} + #[cfg(windows)] fn verify_rollback_snapshot(state:&AgentState,directory:&Path,envelope:&SignedEnvelope,previous_sequence:u64)->Result<()> { let release=validate_previous_release(state,envelope,previous_sequence)?; @@ -243,12 +344,13 @@ fn verify_rollback_snapshot(state:&AgentState,directory:&Path,envelope:&SignedEn } #[cfg(windows)] -fn prepare_rollback_snapshot(state:&AgentState,directory:&Path,update_sequence:u64)->Result { +fn prepare_rollback_snapshot(state:&AgentState,directory:&Path,update:&Release)->Result { let envelope:SignedEnvelope=serde_json::from_slice(&std::fs::read(directory.join("installed-release.json"))?)?; let previous=validate_previous_release(state,&envelope,state.last_release_sequence)?; + ensure!(previous.format==update.format,"Installer format migration requires explicit setup"); verify_compatibility(directory,&previous)?; verify_installed(directory,&previous)?; - let folder=directory.join("updates").join(update_sequence.to_string()); + let folder=directory.join("updates").join(update.sequence.to_string()); let destination=folder.join("rollback"); if destination.exists(){verify_rollback_snapshot(state,&destination,&envelope,state.last_release_sequence)?;return Ok(envelope);} let temporary=folder.join(format!("swan-rollback-{}",random_token()));std::fs::create_dir(&temporary)?; @@ -284,7 +386,7 @@ fn launch_update_helper(directory:&Path,release:&Release)->Result<()> { } impl AgentState { - pub async fn approved_update(&self,technician_token:Option<&str>)->Result> { + pub async fn approved_update(&self,directory:&Path,technician_token:Option<&str>)->Result> { let profile=self.company_profile()?; if !update_policy_open(self)?{return Ok(None);} let (path,token)=if self.bootstrap.edition==Edition::Customer { @@ -297,7 +399,10 @@ impl AgentState { release.validate(&self.bootstrap.edition,0,now())?; ensure!(release.channel==profile.update_channel,"Wrong update channel"); // An already installed release is not an installation request. - if release.sequence<=self.last_release_sequence{return Ok(None);} + let state=self.clone();let directory=directory.to_owned(); + let failed=tokio::task::spawn_blocking(move ||failed_release_sequence(&state,&directory)).await.context("Failed-release quarantine check failed")??; + let minimum=self.last_release_sequence.max(failed); + if release.sequence<=minimum{return Ok(None);} validate_release(self,&envelope)?; Ok(Some(envelope)) } @@ -323,7 +428,7 @@ impl AgentState { let activity=activity_file(directory)?;fs2::FileExt::try_lock_exclusive(&activity).context("Recovery requires all sessions to close")?; let receipt_path=directory.join("pending-update.json"); let receipt:Receipt=serde_json::from_slice(&std::fs::read(&receipt_path)?)?; - let release=validate_recovery_release(self,&receipt.release,receipt.previous_sequence,&receipt.phase)?; + let release=validate_pending_receipt(self,&receipt)?; verify_compatibility(directory,&release)?; verify_installed(directory,&release)?; save_installed_metadata(directory,&receipt.release)?; @@ -344,10 +449,11 @@ impl AgentState { ensure!(!directory.join("pending-install.json").exists(),"Company setup recovery must finish first"); let latest=AgentState::load_for_refresh(directory)?; let receipt:Receipt=serde_json::from_slice(&std::fs::read(&receipt_path)?)?; + ensure!(receipt.phase=="installing","Use resume-update to finish pending rollback"); if let Some(expected)=expected { ensure!(serde_json::to_vec(&receipt)?==serde_json::to_vec(expected)?,"Pending update changed during staging recovery"); } - let release=validate_recovery_release(&latest,&receipt.release,receipt.previous_sequence,&receipt.phase)?; + let release=validate_pending_receipt(&latest,&receipt)?; verify_compatibility(directory,&release)?; let attempt=directory.join("updates").join(release.sequence.to_string()).join("last-attempt.txt"); if !retry_now && attempt.exists(){ @@ -367,7 +473,7 @@ impl AgentState { ensure!(!check_directory.join("pending-install.json").exists(),"Company setup recovery must finish first"); let latest=AgentState::load_for_refresh(&check_directory)?; let receipt:Receipt=serde_json::from_slice(&std::fs::read(receipt_path)?)?; - let release=validate_recovery_release(&latest,&receipt.release,receipt.previous_sequence,&receipt.phase)?; + let release=validate_pending_receipt(&latest,&receipt)?; verify_compatibility(&check_directory,&release)?; let attempt=check_directory.join("updates").join(release.sequence.to_string()).join("last-attempt.txt"); if !retry_now && attempt.exists(){ @@ -381,7 +487,9 @@ impl AgentState { // No credentials accompany release downloads, including redirects. let folder=directory.join("updates").join(release.sequence.to_string()); tokio::fs::create_dir_all(&folder).await?; - restore_staged_download(&folder.join(format!("SwanRemoteSupport-install.{}",release.format)),&release.artifact_url,&release.sha256,&release).await?; + if receipt.phase=="installing" { + restore_staged_download(&folder.join(format!("SwanRemoteSupport-install.{}",release.format)),&release.artifact_url,&release.sha256,&release).await?; + } restore_staged_download(&folder.join("swan-agent.exe"),&release.agent_url,&release.agent_sha256,&release).await?; let state=self.clone();let directory=directory.to_owned(); tokio::task::spawn_blocking(move ||state.resume_prepared_update(&directory,retry_now,Some(&receipt))).await.context("Recovery handoff task failed")? @@ -401,18 +509,23 @@ impl AgentState { let receipt_path=directory.join("pending-update.json"); if !receipt_path.exists(){return Ok(false);} ensure!(!directory.join("pending-install.json").exists(),"Company setup recovery must finish first"); - let receipt:Receipt=serde_json::from_slice(&std::fs::read(&receipt_path)?)?; + let mut receipt:Receipt=serde_json::from_slice(&std::fs::read(&receipt_path)?)?; let mut latest=AgentState::load_for_refresh(directory)?; - let release=validate_recovery_release(&latest,&receipt.release,receipt.previous_sequence,&receipt.phase)?; + let release=validate_pending_receipt(&latest,&receipt)?; verify_compatibility(directory,&release)?; let helper=std::env::current_exe()?; let mut agent_release=release.clone();agent_release.sha256=release.agent_sha256.clone(); ensure!(digest(std::fs::read(&helper)?).eq_ignore_ascii_case(&release.agent_sha256),"Update helper differs from signed release"); verify_publisher(&helper,&agent_release)?; let folder=directory.join("updates").join(release.sequence.to_string()); + if receipt.phase=="rolling_back" { + rollback_portable(&latest,directory,&folder,&receipt)?; + *self=latest;return Ok(true); + } let package=folder.join(format!("SwanRemoteSupport-install.{}",release.format)); ensure!(digest(std::fs::read(&package)?).eq_ignore_ascii_case(&release.sha256),"Staged installer hash mismatch"); verify_publisher(&package,&release)?; + let installation=(||->Result<()> { if let Err(error)=verify_installed(directory,&release) { eprintln!("Installed payload requires recovery: {error:#}"); if let Some(previous)=receipt.previous_release.as_ref(){ @@ -443,6 +556,17 @@ impl AgentState { } } verify_installed(directory,&release)?; + Ok(()) + })(); + if let Err(error)=installation { + let previous=receipt.previous_release.as_ref().map(|envelope|validate_previous_release(&latest,envelope,receipt.previous_sequence)).transpose()?; + let can_rollback=receipt.rollback_protocol==1 && latest.last_release_sequence==receipt.previous_sequence && release.edition==Edition::Technician && release.format=="exe" && previous.as_ref().map(|old|old.rollback_protocol==1 && old.format=="exe" && old.sha256.eq_ignore_ascii_case(&old.installed_sha256)).unwrap_or(false); + if !can_rollback{return Err(error).context("Update failed; signed recovery remains pending");} + eprintln!("Update failed; restoring the verified previous portable release: {error:#}"); + receipt.phase="rolling_back".into();save_receipt(&receipt_path,&receipt)?; + rollback_portable(&latest,directory,&folder,&receipt)?; + *self=latest;return Ok(true); + } save_installed_metadata(directory,&receipt.release)?; latest.last_release_sequence=release.sequence;latest.save(directory)?; std::fs::remove_file(receipt_path)?;*self=latest;Ok(true) @@ -460,7 +584,7 @@ impl AgentState { // An interrupted installation is deliberately not guessed successful. // Keep the signed receipt so recovery can verify the installed build. ensure!(!receipt_path.exists(),"Interrupted update requires recovery before another installation"); - let Some(envelope)=self.approved_update(technician_token).await? else{return Ok(false);}; + let Some(envelope)=self.approved_update(directory,technician_token).await? else{return Ok(false);}; let release=validate_release(self,&envelope)?; ensure!(release.edition!=Edition::Technician || (release.format=="msi" || (release.format=="exe" && release.installed_sha256.eq_ignore_ascii_case(&release.sha256))),"Technician updates require an MSI or a portable EXE with matching installed identity"); let folder=directory.join("updates").join(release.sequence.to_string());tokio::fs::create_dir_all(&folder).await?; @@ -476,24 +600,26 @@ impl AgentState { // Downloads may take minutes. Require a fresh signed policy and // current server approval before creating an installation receipt. self.sync().await?;self.save(directory)?; - let Some(current)=self.approved_update(technician_token).await? else{return Ok(false);}; + let Some(current)=self.approved_update(directory,technician_token).await? else{return Ok(false);}; if current.payload!=envelope.payload || current.signature!=envelope.signature{return Ok(false);} let directory=directory.to_owned(); let prepared=tokio::task::spawn_blocking(move ||->Result> { let activity=activity_file(&directory)?; fs2::FileExt::try_lock_exclusive(&activity).context("Update deferred while a session or connection attempt is active")?; ensure!(!directory.join("pending-install.json").exists(),"Company setup started while the update was downloading"); + ensure!(!receipt_path.exists(),"Another update already requires recovery"); // Re-read consent and enrollment immediately before installation. let latest=AgentState::load(&directory)?; if !update_policy_open(&latest)?{return Ok(None);} // Removal during download must not be turned into a fresh install. require_existing_application(&installed_target(&directory,&latest.bootstrap.edition)?)?; validate_release(&latest,&envelope)?; - let previous_release=prepare_rollback_snapshot(&latest,&directory,release.sequence)?; - let receipt=Receipt{release:envelope,previous_sequence:latest.last_release_sequence,phase:"installing".into(),previous_release:Some(previous_release)}; - let mut options=std::fs::OpenOptions::new();options.create_new(true).write(true); - use std::io::Write; - let mut file=options.open(&receipt_path)?;file.write_all(&serde_json::to_vec(&receipt)?)?;file.sync_all()?;drop(file); + ensure!(release.sequence>failed_release_sequence(&latest,&directory)?,"Failed release is quarantined"); + let previous_release=prepare_rollback_snapshot(&latest,&directory,&release)?; + let previous=validate_previous_release(&latest,&previous_release,latest.last_release_sequence)?; + let rollback_protocol=if previous.rollback_protocol==1 {agent_rollback_protocol(&directory.join("updates").join(release.sequence.to_string()).join("rollback/agent/swan-agent.exe"),&directory)?}else{0}; + let receipt=Receipt{release:envelope,previous_sequence:latest.last_release_sequence,phase:"installing".into(),previous_release:Some(previous_release),rollback_protocol}; + publish_initial_receipt(&receipt_path,&receipt)?; // The signed replacement runs from staging. This caller must exit so // Windows releases the old executable before it is overwritten. launch_update_helper(&directory,&release)?; @@ -527,6 +653,60 @@ fn verify_installed(directory:&Path,release:&Release)->Result<()> { let mut installed=release.clone();installed.sha256=release.installed_sha256.clone();verify_publisher(&target,&installed) } +#[cfg(test)] +pub(crate) fn test_failed_release_quarantine(state:&AgentState,release:&Release,key:&SigningKey) { + let folder=std::env::temp_dir().join(format!("swan-quarantine-test-{}",random_token()));std::fs::create_dir(&folder).unwrap(); + assert_eq!(failed_release_sequence(state,&folder).unwrap(),0); + let mut previous=release.clone();previous.sequence=1;previous.expires_at=now()-1;previous.rollback_protocol=1; + let mut failed=release.clone();failed.expires_at=now()-1; + let receipt=Receipt{release:SignedEnvelope::sign(&failed,key).unwrap(),previous_sequence:1,phase:"rolled_back".into(),previous_release:Some(SignedEnvelope::sign(&previous,key).unwrap()),rollback_protocol:1}; + let path=folder.join("failed-update.json"); + let pending_path=folder.join("pending-update.json"); + publish_initial_receipt(&pending_path,&receipt).unwrap(); + let saved=std::fs::read(&pending_path).unwrap(); + let mut competing=receipt.clone();competing.phase="installing".into(); + assert!(publish_initial_receipt(&pending_path,&competing).is_err()); + assert_eq!(std::fs::read(&pending_path).unwrap(),saved,"Never overwrite another durable recovery receipt"); + assert!(!std::fs::read_dir(&folder).unwrap().any(|entry|entry.unwrap().file_name().to_string_lossy().starts_with("swan-receipt-"))); + let write=|receipt:&Receipt|std::fs::write(&path,serde_json::to_vec(receipt).unwrap()).unwrap(); + write(&receipt);assert_eq!(failed_release_sequence(state,&folder).unwrap(),2); + assert!(release.validate(&state.bootstrap.edition,failed_release_sequence(state,&folder).unwrap(),now()).is_err(),"The failed sequence cannot be selected again"); + let mut later=release.clone();later.sequence=3; + assert!(later.validate(&state.bootstrap.edition,failed_release_sequence(state,&folder).unwrap(),now()).is_ok()); + let mut pending=receipt.clone();pending.phase="rolling_back".into(); + assert!(validate_pending_receipt(state,&pending).is_ok()); + let mut committed=state.clone();committed.last_release_sequence=2; + assert!(validate_pending_receipt(&committed,&pending).is_err()); + let mut different_format=previous.clone();different_format.format="msi".into(); + pending.previous_release=Some(SignedEnvelope::sign(&different_format,key).unwrap()); + assert!(validate_pending_receipt(state,&pending).is_err(),"Automatic rollback must not ignore installer registrations"); + let mut legacy=previous.clone();legacy.rollback_protocol=0; + pending.previous_release=Some(SignedEnvelope::sign(&legacy,key).unwrap()); + assert!(validate_pending_receipt(state,&pending).is_err(),"Never restore an endpoint lacking quarantine support"); + pending.previous_release=None;assert!(validate_pending_receipt(state,&pending).is_err()); + pending.phase="installing".into();pending.rollback_protocol=0; + assert!(validate_pending_receipt(state,&pending).is_ok(),"Legacy receipts can still finish forward recovery"); + pending.rollback_protocol=2;assert!(validate_pending_receipt(state,&pending).is_err()); + let mut newer=state.clone();newer.last_release_sequence=3; + assert_eq!(failed_release_sequence(&newer,&folder).unwrap(),2,"Installing a later release retains failed-release history"); + let mut foreign=state.clone();foreign.bootstrap.edition=Edition::Technician; + assert!(failed_release_sequence(&foreign,&folder).is_err()); + foreign.bootstrap.edition=state.bootstrap.edition.clone();foreign.bootstrap.release_public_key=STANDARD.encode(SigningKey::from_bytes(&[9;32]).verifying_key().as_bytes()); + assert!(failed_release_sequence(&foreign,&folder).is_err()); + let mut bad=receipt.clone();bad.release.signature=STANDARD.encode([0u8;64]);write(&bad); + assert!(failed_release_sequence(state,&folder).is_err()); + let mut bad=receipt.clone();bad.previous_sequence=2;write(&bad); + assert!(failed_release_sequence(state,&folder).is_err()); + let mut bad=receipt.clone();bad.rollback_protocol=0;write(&bad); + assert!(failed_release_sequence(state,&folder).is_err()); + let mut bad=receipt.clone();bad.phase="rolling_back".into();write(&bad); + assert!(failed_release_sequence(state,&folder).is_err()); + std::fs::write(&path,b"interrupted metadata").unwrap();assert!(failed_release_sequence(state,&folder).is_err()); + std::fs::remove_file(&path).unwrap();std::fs::create_dir(&path).unwrap(); + assert!(failed_release_sequence(state,&folder).is_err(),"Quarantine I/O failures must not clear suppression"); + std::fs::remove_dir_all(folder).unwrap(); +} + #[cfg(test)] mod tests { use super::*; diff --git a/product/management/src/service.rs b/product/management/src/service.rs index cf4d7a29c47..af982ce7e30 100644 --- a/product/management/src/service.rs +++ b/product/management/src/service.rs @@ -599,7 +599,7 @@ mod worker_completion_tests { let store=std::sync::Arc::new(Store::open(&directory).unwrap()); let public=STANDARD.encode(store.key.verifying_key().as_bytes()); let profile=CompanyProfile{schema:1,company_id:"release-test".into(),revision:1,issued_at:now()-1,expires_at:now()+3600,management_url:"https://support.example.com".into(),rendezvous:"support.example.com".into(),relay:"support.example.com".into(),transport_public_key:public,customer:Default::default(),technician:Default::default(),allow_unattended:false,updates_paused:false,rollout_percent:100,maintenance_start_utc:0,maintenance_end_utc:0,update_channel:"stable".into(),next_profile_public_key:None}; - let release=Release{schema:1,product:"swan-remote-support".into(),version:"2.0.0".into(),sequence:1,edition:Edition::Technician,architecture:"x86_64".into(),channel:"stable".into(),expires_at:now()+3600,artifact_url:"https://releases.example/install.msi".into(),sha256:"a".repeat(64),installed_sha256:"b".repeat(64),installed_files:vec![],agent_url:"https://releases.example/agent.exe".into(),agent_sha256:"c".repeat(64),publisher:"Example".into(),publisher_certificate_sha256:"d".repeat(64),windows_versions:vec!["windows_11".into()],source_url:"https://releases.example/source.tar.gz".into(),format:"msi".into()}; + let release=Release{schema:1,product:"swan-remote-support".into(),version:"2.0.0".into(),sequence:1,edition:Edition::Technician,architecture:"x86_64".into(),channel:"stable".into(),expires_at:now()+3600,artifact_url:"https://releases.example/install.msi".into(),sha256:"a".repeat(64),installed_sha256:"b".repeat(64),installed_files:vec![],agent_url:"https://releases.example/agent.exe".into(),agent_sha256:"c".repeat(64),publisher:"Example".into(),publisher_certificate_sha256:"d".repeat(64),windows_versions:vec!["windows_11".into()],source_url:"https://releases.example/source.tar.gz".into(),format:"msi".into(),rollback_protocol:0}; let envelope=SignedEnvelope::sign(&release,&store.key).unwrap(); let admin_token=random_token();let technician_token=random_token(); let completed=Uuid::new_v4().to_string(); diff --git a/product/protocol/src/lib.rs b/product/protocol/src/lib.rs index 63866b7e8a1..c2ab3f297d3 100644 --- a/product/protocol/src/lib.rs +++ b/product/protocol/src/lib.rs @@ -218,12 +218,15 @@ pub struct Release { pub windows_versions: Vec, pub source_url: String, pub format: String, + #[serde(default)] + pub rollback_protocol:u32, } impl Release { pub fn validate(&self, edition: &Edition, minimum_sequence: u64, now: u64) -> Result<()> { ensure!(self.schema == SCHEMA && self.product == PRODUCT && &self.edition == edition, "Wrong product or edition"); ensure!(self.architecture == "x64" && self.sequence > minimum_sequence && self.expires_at > now, "Incompatible, expired or replayed release"); ensure!(["stable", "test"].contains(&self.channel.as_str()) && ["exe", "msi"].contains(&self.format.as_str()), "Invalid release type"); + ensure!(self.rollback_protocol<=1,"Unsupported rollback protocol"); ensure!(self.sha256.len() == 64 && self.sha256.bytes().all(|x| x.is_ascii_hexdigit()) && !self.publisher.is_empty(), "Invalid artifact identity"); ensure!(self.installed_sha256.len()==64 && self.installed_sha256.bytes().all(|x|x.is_ascii_hexdigit()),"Invalid installed executable identity"); ensure!(!self.installed_files.is_empty() && self.installed_files.len()<=4096,"Installed payload manifest required"); @@ -355,8 +358,12 @@ mod tests { } #[test] fn releases_reject_replay_wrong_edition_expiry_and_missing_installed_identity() { - let mut release=Release {schema:1,product:PRODUCT.into(),version:"1.5.0".into(),sequence:2,edition:Edition::Customer,architecture:"x64".into(),channel:"stable".into(),expires_at:200,artifact_url:"https://releases.example/package.exe".into(),sha256:"a".repeat(64),installed_sha256:"b".repeat(64),installed_files:vec![InstalledFile{path:"Swan Remote Support.exe".into(),sha256:"b".repeat(64)},InstalledFile{path:"librustdesk.dll".into(),sha256:"c".repeat(64)},InstalledFile{path:"flutter_windows.dll".into(),sha256:"d".repeat(64)}],agent_url:"https://releases.example/agent.exe".into(),agent_sha256:"c".repeat(64),publisher:"Example".into(),publisher_certificate_sha256:"d".repeat(64),windows_versions:vec!["windows_11".into()],source_url:"https://releases.example/source.tar.gz".into(),format:"exe".into()}; + let mut release=Release {schema:1,product:PRODUCT.into(),version:"1.5.0".into(),sequence:2,edition:Edition::Customer,architecture:"x64".into(),channel:"stable".into(),expires_at:200,artifact_url:"https://releases.example/package.exe".into(),sha256:"a".repeat(64),installed_sha256:"b".repeat(64),installed_files:vec![InstalledFile{path:"Swan Remote Support.exe".into(),sha256:"b".repeat(64)},InstalledFile{path:"librustdesk.dll".into(),sha256:"c".repeat(64)},InstalledFile{path:"flutter_windows.dll".into(),sha256:"d".repeat(64)}],agent_url:"https://releases.example/agent.exe".into(),agent_sha256:"c".repeat(64),publisher:"Example".into(),publisher_certificate_sha256:"d".repeat(64),windows_versions:vec!["windows_11".into()],source_url:"https://releases.example/source.tar.gz".into(),format:"exe".into(),rollback_protocol:0}; assert!(release.validate(&Edition::Customer,1,100).is_ok()); + let mut legacy=serde_json::to_value(&release).unwrap();legacy.as_object_mut().unwrap().remove("rollback_protocol"); + assert_eq!(serde_json::from_value::(legacy).unwrap().rollback_protocol,0); + release.rollback_protocol=2;assert!(release.validate(&Edition::Customer,1,100).is_err()); + release.rollback_protocol=1;assert!(release.validate(&Edition::Customer,1,100).is_ok()); assert!(release.validate(&Edition::Customer,2,100).is_err()); assert!(release.validate(&Edition::Technician,0,100).is_err()); assert!(release.validate(&Edition::Customer,0,200).is_err()); diff --git a/src/managed.rs b/src/managed.rs index 479f19fab23..cec0c26947b 100644 --- a/src/managed.rs +++ b/src/managed.rs @@ -126,7 +126,7 @@ async fn technician_request_inner(request:&str)->ResultType { return Ok(json!({"handed_off":false,"started":true})); } if action=="update-status" { - let envelope=state.approved_update(Some(&token)).await?; + let envelope=state.approved_update(&directory,Some(&token)).await?; let memory=TECHNICIAN.lock().unwrap(); if memory.generation!=generation || memory.login.is_none(){bail!("Login changed during update discovery");} let Some(envelope)=envelope else{return Ok(serde_json::Value::Null);}; From ea5cfe2ac60d4a3255c0b32f35762b7fa8727f5d Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Fri, 2 Oct 2026 15:41:39 +0200 Subject: [PATCH 089/652] Select approved updates for the installed package format --- docs/ACCEPTANCE.md | 10 +++++++ docs/RELEASE_PAYLOAD.md | 7 +++++ product/agent/src/update.rs | 8 +++++- product/management/src/service.rs | 47 +++++++++++++++++++++---------- 4 files changed, 56 insertions(+), 16 deletions(-) diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index be7a8821d90..28093a37697 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -562,3 +562,13 @@ atomic and unable to overwrite an existing receipt. The Windows agent build passes, and its read-only capability command returns protocol 1 without creating company state. Native Rust syntax parses locally; native Rust/Flutter compilation and actual signed rollback, reboot/interruption and installer tests remain gates. + +Update discovery now filters approved releases by the installed EXE/MSI format. +Clients derive the query from the verified signed installed-release envelope and +independently reject a mismatched server response. Server component regressions +select the older matching MSI despite a newer EXE, select the EXE when requested, +retain legacy unfiltered discovery, reject unsupported formats and unauthenticated +requests, and stop offering a withdrawn matching release. These checks use valid +release metadata. The targeted management test and Windows product workspace +compilation pass. Real signed mixed-format rollout and native upgrades remain +unverified. diff --git a/docs/RELEASE_PAYLOAD.md b/docs/RELEASE_PAYLOAD.md index 3153ce38edd..1b518886453 100644 --- a/docs/RELEASE_PAYLOAD.md +++ b/docs/RELEASE_PAYLOAD.md @@ -130,6 +130,13 @@ declares rollback protocol 1 and the verified previous agent's `rollback-protoco command confirms support. A durable `rolling_back` receipt precedes restoration. Automatic upgrades retain the installed EXE/MSI format; changing formats requires explicit installation/migration so installer registrations are not silently lost. +Clients derive that format from their exact signed installed-release metadata +and send `format=exe` or `format=msi` when requesting an approved update. The +management server selects the latest approved, unexpired release matching that +format, edition and channel, while still applying rollout and maintenance policy. +Legacy requests without a format retain discovery compatibility. New clients +also reject a mismatched response, including one from an older server that ignores +the format query. Missing or invalid installed metadata requires explicit repair. Retries finish that same rollback instead of attempting forward installation; rollback does not require the new installer package to remain available. The old EXE and agent are replaced through verified atomic staging, and their full diff --git a/product/agent/src/update.rs b/product/agent/src/update.rs index e02a4b30226..fd8627e09bc 100644 --- a/product/agent/src/update.rs +++ b/product/agent/src/update.rs @@ -392,12 +392,18 @@ impl AgentState { let (path,token)=if self.bootstrap.edition==Edition::Customer { ("device/update",self.device_token.as_deref().context("Not enrolled")?) }else{("user/update",technician_token.context("Technician authentication required")?)}; - let value:Value=self.client()?.get(self.endpoint(path)?).bearer_auth(token).send().await?.error_for_status()?.json().await?; + let state=self.clone();let installed_directory=directory.to_owned(); + let format=tokio::task::spawn_blocking(move ||->Result { + let installed:SignedEnvelope=serde_json::from_slice(&std::fs::read(installed_directory.join("installed-release.json"))?)?; + Ok(validate_previous_release(&state,&installed,state.last_release_sequence)?.format) + }).await.context("Installed release discovery failed")??; + let value:Value=self.client()?.get(self.endpoint(path)?).query(&[("format",&format)]).bearer_auth(token).send().await?.error_for_status()?.json().await?; if value.is_null(){return Ok(None);} let envelope:SignedEnvelope=serde_json::from_value(value)?; let release:Release=envelope.verify(&public_key(&self.bootstrap.release_public_key)?)?; release.validate(&self.bootstrap.edition,0,now())?; ensure!(release.channel==profile.update_channel,"Wrong update channel"); + ensure!(release.format==format,"Server offered an installer format migration; explicit setup is required"); // An already installed release is not an installation request. let state=self.clone();let directory=directory.to_owned(); let failed=tokio::task::spawn_blocking(move ||failed_release_sequence(&state,&directory)).await.context("Failed-release quarantine check failed")??; diff --git a/product/management/src/service.rs b/product/management/src/service.rs index af982ce7e30..a192258d243 100644 --- a/product/management/src/service.rs +++ b/product/management/src/service.rs @@ -1,6 +1,6 @@ use anyhow::ensure; use argon2::{password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString}, Argon2}; -use axum::{extract::{DefaultBodyLimit, Path, State}, http::{header, HeaderMap, StatusCode}, response::{Html, IntoResponse, Response}, routing::{get, post, put}, Json, Router}; +use axum::{extract::{DefaultBodyLimit, Path, Query, State}, http::{header, HeaderMap, StatusCode}, response::{Html, IntoResponse, Response}, routing::{get, post, put}, Json, Router}; use base64::{engine::general_purpose::STANDARD, Engine}; use ed25519_dalek::SigningKey; use rand::rngs::OsRng; @@ -506,13 +506,17 @@ fn list_json(s:&Store,sql:&str,approved:bool)->ApiResult { for (id,raw,approved) in rows {values.push(json!({"id":id,"data":serde_json::from_str::(&raw)?,"approved":approved}));} Ok(Json(json!(values))) } -async fn device_update(State(s):State,headers:HeaderMap)->ApiResult { - let device=s.device(&headers)?;select_update(&s,&device,Edition::Customer) +#[derive(Default,Deserialize)] +#[serde(deny_unknown_fields)] +struct UpdateQuery {format:Option} +async fn device_update(State(s):State,headers:HeaderMap,Query(query):Query)->ApiResult { + let device=s.device(&headers)?;select_update(&s,&device,Edition::Customer,query.format.as_deref()) } -async fn technician_update(State(s):State,headers:HeaderMap)->ApiResult { - let user=s.user(&headers,false)?;select_update(&s,&user,Edition::Technician) +async fn technician_update(State(s):State,headers:HeaderMap,Query(query):Query)->ApiResult { + let user=s.user(&headers,false)?;select_update(&s,&user,Edition::Technician,query.format.as_deref()) } -fn select_update(s:&Store,device:&str,edition:Edition)->ApiResult { +fn select_update(s:&Store,device:&str,edition:Edition,format:Option<&str>)->ApiResult { + if format.map(|format|!["exe","msi"].contains(&format)).unwrap_or(false){return Err(bad("Invalid installer format"));} let profile=s.profile()?; let cohort=u8::from_str_radix(&digest(&device)[..2],16).map_err(|_|bad("Invalid device"))? as u32 * 100 / 256; if profile.updates_paused || cohort>=profile.rollout_percent as u32 || !maintenance_open(profile.maintenance_start_utc,profile.maintenance_end_utc,now()) {return Ok(Json(Value::Null));} @@ -520,7 +524,7 @@ fn select_update(s:&Store,device:&str,edition:Edition)->ApiResult { let rows=stmt.query_map([],|r|Ok((r.get::<_,String>(0)?,r.get::<_,String>(1)?)))?.collect::,_>>()?; let mut best:Option<(u64,Value)>=None; for (body,envelope) in rows {let release:Release=serde_json::from_str(&body)?; - if release.edition==edition && release.channel==profile.update_channel && release.expires_at>now() && best.as_ref().map(|b|b.0now() && best.as_ref().map(|b|b.0,headers:HeaderMap)->ApiResult { @@ -599,7 +603,7 @@ mod worker_completion_tests { let store=std::sync::Arc::new(Store::open(&directory).unwrap()); let public=STANDARD.encode(store.key.verifying_key().as_bytes()); let profile=CompanyProfile{schema:1,company_id:"release-test".into(),revision:1,issued_at:now()-1,expires_at:now()+3600,management_url:"https://support.example.com".into(),rendezvous:"support.example.com".into(),relay:"support.example.com".into(),transport_public_key:public,customer:Default::default(),technician:Default::default(),allow_unattended:false,updates_paused:false,rollout_percent:100,maintenance_start_utc:0,maintenance_end_utc:0,update_channel:"stable".into(),next_profile_public_key:None}; - let release=Release{schema:1,product:"swan-remote-support".into(),version:"2.0.0".into(),sequence:1,edition:Edition::Technician,architecture:"x86_64".into(),channel:"stable".into(),expires_at:now()+3600,artifact_url:"https://releases.example/install.msi".into(),sha256:"a".repeat(64),installed_sha256:"b".repeat(64),installed_files:vec![],agent_url:"https://releases.example/agent.exe".into(),agent_sha256:"c".repeat(64),publisher:"Example".into(),publisher_certificate_sha256:"d".repeat(64),windows_versions:vec!["windows_11".into()],source_url:"https://releases.example/source.tar.gz".into(),format:"msi".into(),rollback_protocol:0}; + let release=Release{schema:1,product:PRODUCT.into(),version:"2.0.0".into(),sequence:1,edition:Edition::Technician,architecture:"x64".into(),channel:"stable".into(),expires_at:now()+3600,artifact_url:"https://releases.example/install.msi".into(),sha256:"a".repeat(64),installed_sha256:"b".repeat(64),installed_files:vec![InstalledFile{path:"SwanRemoteSupport-Technician.exe".into(),sha256:"b".repeat(64)}],agent_url:"https://releases.example/agent.exe".into(),agent_sha256:"c".repeat(64),publisher:"Example".into(),publisher_certificate_sha256:"d".repeat(64),windows_versions:vec!["windows_11".into()],source_url:"https://releases.example/source.tar.gz".into(),format:"msi".into(),rollback_protocol:0}; let envelope=SignedEnvelope::sign(&release,&store.key).unwrap(); let admin_token=random_token();let technician_token=random_token(); let completed=Uuid::new_v4().to_string(); @@ -620,20 +624,33 @@ mod worker_completion_tests { } persist_verified_artifact(&store,&in_flight,&worker,b"previously validated bundle").ok().unwrap(); let headers=|credential:&str| {let mut h=HeaderMap::new();h.insert(header::AUTHORIZATION,format!("Bearer {credential}").parse().unwrap());h}; - assert!(!select_update(&store,"technician",Edition::Technician).ok().unwrap().0.is_null()); - assert!(select_update(&store,"device",Edition::Customer).ok().unwrap().0.is_null()); + assert!(!select_update(&store,"technician",Edition::Technician,None).ok().unwrap().0.is_null()); + assert!(select_update(&store,"device",Edition::Customer,None).ok().unwrap().0.is_null()); + let mut portable=release.clone();portable.sequence=2;portable.format="exe".into();portable.sha256=portable.installed_sha256.clone(); + release.validate(&Edition::Technician,0,now()).unwrap();portable.validate(&Edition::Technician,0,now()).unwrap(); + let portable_envelope=SignedEnvelope::sign(&portable,&store.key).unwrap(); + store.db.lock().unwrap().execute("INSERT INTO releases VALUES('portable-release',?1,?2,1)",params![serde_json::to_string(&portable).unwrap(),serde_json::to_string(&portable_envelope).unwrap()]).unwrap(); + assert_eq!(select_update(&store,"technician",Edition::Technician,Some("msi")).ok().unwrap().0,serde_json::to_value(&envelope).unwrap()); + assert_eq!(select_update(&store,"technician",Edition::Technician,Some("exe")).ok().unwrap().0,serde_json::to_value(&portable_envelope).unwrap()); + assert_eq!(select_update(&store,"technician",Edition::Technician,None).ok().unwrap().0,serde_json::to_value(&portable_envelope).unwrap()); + assert!(select_update(&store,"technician",Edition::Technician,Some("zip")).is_err()); + assert!(technician_update(State(store.clone()),HeaderMap::new(),Query(UpdateQuery{format:Some("exe".into())})).await.is_err()); + assert!(device_update(State(store.clone()),HeaderMap::new(),Query(UpdateQuery{format:Some("msi".into())})).await.is_err()); + assert_eq!(technician_update(State(store.clone()),headers(&technician_token),Query(UpdateQuery{format:Some("msi".into())})).await.ok().unwrap().0,serde_json::to_value(&envelope).unwrap()); + store.db.lock().unwrap().execute("UPDATE releases SET approved=0 WHERE id='portable-release'",[]).unwrap(); + assert!(select_update(&store,"technician",Edition::Technician,Some("exe")).ok().unwrap().0.is_null()); let save_policy=|policy:&CompanyProfile| {store.db.lock().unwrap().execute("UPDATE profile SET body=?1 WHERE id=1",[serde_json::to_string(policy).unwrap()]).unwrap();}; let mut paused=profile.clone();paused.updates_paused=true;save_policy(&paused); - assert!(select_update(&store,"technician",Edition::Technician).ok().unwrap().0.is_null()); + assert!(select_update(&store,"technician",Edition::Technician,None).ok().unwrap().0.is_null()); let mut staged=profile.clone();staged.rollout_percent=0;save_policy(&staged); - assert!(select_update(&store,"technician",Edition::Technician).ok().unwrap().0.is_null()); + assert!(select_update(&store,"technician",Edition::Technician,None).ok().unwrap().0.is_null()); let mut channel=profile.clone();channel.update_channel="test".into();save_policy(&channel); - assert!(select_update(&store,"technician",Edition::Technician).ok().unwrap().0.is_null()); + assert!(select_update(&store,"technician",Edition::Technician,None).ok().unwrap().0.is_null()); save_policy(&profile); assert!(withdraw_release(State(store.clone()),headers(&technician_token),Path("release".into())).await.is_err()); - assert!(!select_update(&store,"technician",Edition::Technician).ok().unwrap().0.is_null()); + assert!(!select_update(&store,"technician",Edition::Technician,None).ok().unwrap().0.is_null()); assert!(withdraw_release(State(store.clone()),headers(&admin_token),Path("release".into())).await.is_ok()); - assert!(select_update(&store,"technician",Edition::Technician).ok().unwrap().0.is_null()); + assert!(select_update(&store,"technician",Edition::Technician,None).ok().unwrap().0.is_null()); assert!(withdraw_release(State(store.clone()),headers(&admin_token),Path("missing".into())).await.is_err()); let cancelled:i64=store.db.lock().unwrap().query_row("SELECT COUNT(*) FROM builds WHERE state='failed'",[],|r|r.get(0)).unwrap();assert_eq!(cancelled,4); assert!(persist_verified_artifact(&store,&in_flight,&worker,b"late replacement").is_err()); From f8a1828913a54d4765b3e9f320e38dc48d8607ce Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Fri, 2 Oct 2026 16:01:35 +0200 Subject: [PATCH 090/652] Retain verified installer packages and fix rollback handoff --- deployment/windows/Install-Company.ps1 | 2 +- docs/ACCEPTANCE.md | 31 ++++++ docs/RELEASE_PAYLOAD.md | 26 +++++ product/agent/src/main.rs | 16 ++-- product/agent/src/update.rs | 125 ++++++++++++++++++++++--- product/worker/src/main.rs | 8 +- 6 files changed, 187 insertions(+), 21 deletions(-) diff --git a/deployment/windows/Install-Company.ps1 b/deployment/windows/Install-Company.ps1 index a3a32b939f8..f84eb3a1bdc 100644 --- a/deployment/windows/Install-Company.ps1 +++ b/deployment/windows/Install-Company.ps1 @@ -40,7 +40,7 @@ if ($LASTEXITCODE -ne 0) { throw 'Installer does not match the configured compan $installers = @(Get-ChildItem -LiteralPath $root -File | Where-Object { $_.Name -match '^SwanRemoteSupport-install\.(exe|msi)$' }) if ($installers.Count -ne 1) { throw 'Expected one company installer.' } $packageArguments = @('verify-package',(Join-Path $root 'release.json'),$installers[0].FullName) -$recordArguments = @('record-installation',(Join-Path $root 'release.json')) +$recordArguments = @('record-installation',(Join-Path $root 'release.json'),$installers[0].FullName) if ($Repair) { $packageArguments += '--repair'; $recordArguments += '--repair' } & $agent @packageArguments if ($LASTEXITCODE -ne 0) { throw 'Installer metadata, hash or publisher validation failed.' } diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index 28093a37697..5b50421acd0 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -572,3 +572,34 @@ requests, and stop offering a withdrawn matching release. These checks use valid release metadata. The targeted management test and Windows product workspace compilation pass. Real signed mixed-format rollout and native upgrades remain unverified. + +Installer retention now preserves exact hash/publisher-verified installation +packages during setup, repair and completed update/recovery. New software +snapshots include the previous installer alongside the verified files, allowing +future MSI registration restoration to use its actual original package. Missing +old caches can re-fetch only the exact recorded artifact before update handoff. +Wrong hashes and unsigned installers are rejected before cache publication in a +local Windows negative test; actual signed Swan installer retention remains unverified. + +The same retention regression subsequently passes a positive Authenticode check +using a Windows-signed executable copied to the private temporary fixture, with +the actual publisher and certificate fingerprint. Wrong publisher and certificate +pins retain the previous valid cache. That executable is never executed and no +certificate is created or installed. Actual signed Swan installation packages +and MSI registration recovery remain unverified. + +Review also corrected the misplaced installing-only phase check that prevented +rollback handoff after restart. Completion-only recovery now owns that check; +pending rollback remains eligible for helper handoff. The shared phase/quarantine +regression passes, but native reboot/rollback still needs testing. Windows agent +and worker builds and workspace compilation pass. Their Windows PowerShell +commands now use the OS system directory and built-in module path; actual built-in +hash/signature command availability and unsigned-package rejection pass when +invoked from the PowerShell 7 development environment. Company installer script +syntax passes. No signed installer, MSI restoration or clean Windows acceptance +is implied by these component checks. + +After these corrections, all 39 ordinary product workspace component tests pass +again on Windows. The separate HTTPS download test is explicitly ignored in that +run. This includes actual Authenticode positive retention and wrong-certificate +rejection for the copied OS fixture, not execution of an installation package. diff --git a/docs/RELEASE_PAYLOAD.md b/docs/RELEASE_PAYLOAD.md index 1b518886453..054f1b26071 100644 --- a/docs/RELEASE_PAYLOAD.md +++ b/docs/RELEASE_PAYLOAD.md @@ -162,3 +162,29 @@ sequence checks. Quarantine/receipt regressions cover replay suppression, eligib of later releases, metadata expiry, old protocol exclusion, wrong edition/key, tampering, malformed storage and legacy forward recovery. Signed installed-app rehearsals remain required; these tests do not substitute for native rollback. + +Initial installation, explicit repair, completed updates and completion-only +recovery now retain the exact installer under `releases/SEQUENCE/installer.exe` +or `installer.msi`. Retention rechecks its size, signed hash and publisher before +and after atomic copying. `record-installation` therefore requires the original +installer path as well as `release.json`; the company setup wrapper supplies both. +Installed sequence advancement and removal of recovery markers wait for this +retention step. New snapshots also preserve the original installer in `package/`. +Older installed versions without a cache can fetch only their exact previously +recorded artifact through verified HTTPS before an update is prepared. Expiry +does not authorize a different package. A missing, corrupt or unverifiable old +artifact defers the update before installation; keep corresponding release files +available on company-controlled storage. Retained packages are software artifacts, +not copies of mutable enrollment or consent. MSI/service restoration still needs +implementation and signed native tests. + +The agent and Windows worker launch Windows PowerShell from the OS-resolved +system directory and restrict module discovery to its built-in modules. This +prevents inherited PowerShell 7 module paths from breaking hash/signature checks. +Local regressions confirm the built-in commands are present and that an unsigned +installer with a correct hash is rejected without publishing a retained package. +An actual Windows-signed executable copied into the isolated fixture passes +positive retention; wrong publisher and certificate pins are rejected while +retaining the previous complete cache. The fixture is never executed and no +certificate is installed. This checks Authenticode retention, not a signed Swan +installer or Windows Installer registration recovery. diff --git a/product/agent/src/main.rs b/product/agent/src/main.rs index cd08457cefb..e269f6cfa50 100644 --- a/product/agent/src/main.rs +++ b/product/agent/src/main.rs @@ -25,8 +25,7 @@ async fn main()->Result<()> { }else{("Restore-Configuration.ps1",include_str!("../../../deployment/windows/Restore-Configuration.ps1"))}; let script=installed_directory.join(name); std::fs::write(&script,contents)?; - let powershell=PathBuf::from(std::env::var_os("SystemRoot").context("Missing Windows directory")?).join("System32/WindowsPowerShell/v1.0/powershell.exe"); - let status=std::process::Command::new(powershell).args(["-NoLogo","-NoProfile","-NonInteractive","-ExecutionPolicy","Bypass","-File"]).arg(&script).arg("-Directory").arg(installed_directory).status()?; + let status=swan_agent::update::powershell_command()?.args(["-NoLogo","-NoProfile","-NonInteractive","-ExecutionPolicy","Bypass","-File"]).arg(&script).arg("-Directory").arg(installed_directory).status()?; std::fs::remove_file(&script)?; ensure!(status.success(),"Company configuration task cleanup failed"); } @@ -84,7 +83,7 @@ async fn main()->Result<()> { if customer && (matches!(&outcome,Ok(true)) || outcome.is_err()) { let script=directory.join("Restart-Configuration.ps1"); std::fs::write(&script,include_str!("../../../deployment/windows/Restart-Configuration.ps1"))?; - let status=std::process::Command::new("powershell.exe").args(["-NoLogo","-NoProfile","-NonInteractive","-ExecutionPolicy","Bypass","-File"]).arg(script).arg("-Directory").arg(&directory).status()?; + let status=swan_agent::update::powershell_command()?.args(["-NoLogo","-NoProfile","-NonInteractive","-ExecutionPolicy","Bypass","-File"]).arg(script).arg("-Directory").arg(&directory).status()?; if !status.success(){ if let Err(error)=&outcome {eprintln!("Update remains pending: {error:#}");} bail!("Configuration task restart failed; inspect the protected update log and resume recovery"); @@ -106,9 +105,14 @@ async fn main()->Result<()> { } #[cfg(windows)] "record-installation"=>{ - let file=args.get(2).context("Usage: swan-agent record-installation release.json")?; - let envelope:SignedEnvelope=serde_json::from_slice(&std::fs::read(file)?)?; - let mut state=AgentState::load(&directory)?;state.record_installation(&directory,&envelope,args.iter().any(|value|value=="--repair"))?; + let file=PathBuf::from(args.get(2).context("Usage: swan-agent record-installation release.json INSTALLER [--repair]")?); + let package=PathBuf::from(args.get(3).context("Missing original installer path")?); + let record_directory=directory.clone();let repair=args.iter().any(|value|value=="--repair"); + tokio::task::spawn_blocking(move ||->Result<()> { + let envelope:SignedEnvelope=serde_json::from_slice(&std::fs::read(file)?)?; + let mut state=AgentState::load(&record_directory)?; + state.record_installation(&record_directory,&envelope,&package,repair) + }).await.context("Installation recording task failed")??; println!("Installed application and agent identities verified; release sequence recorded."); } #[cfg(windows)] diff --git a/product/agent/src/update.rs b/product/agent/src/update.rs index fd8627e09bc..b526593dd0c 100644 --- a/product/agent/src/update.rs +++ b/product/agent/src/update.rs @@ -86,6 +86,12 @@ fn validate_pending_receipt(state:&AgentState,receipt:&Receipt)->Result Ok(release) } +#[cfg(any(windows,test))] +fn validate_completion_receipt(state:&AgentState,receipt:&Receipt)->Result { + ensure!(receipt.phase=="installing","Use resume-update to finish pending rollback"); + validate_pending_receipt(state,receipt) +} + fn failed_release_sequence(state:&AgentState,directory:&Path)->Result { let path=directory.join("failed-update.json"); let bytes=match std::fs::read(path) { @@ -169,11 +175,29 @@ async fn restore_staged_download(path:&Path,url:&str,hash:&str,release:&Release) verify_downloaded_publisher(path,&artifact_release).await } +#[cfg(windows)] +fn system_directory()->Result { + let mut directory=[0u16;32768]; + let length=unsafe{windows_sys::Win32::System::SystemInformation::GetSystemDirectoryW(directory.as_mut_ptr(),directory.len() as u32)} as usize; + ensure!(length>0 && lengthResult { + let root=system_directory()?.join("WindowsPowerShell/v1.0"); + let mut command=std::process::Command::new(root.join("powershell.exe")); + // PowerShell 7 can pass its module paths through a Rust child to Windows + // PowerShell. Use only Windows' built-ins for trust and compatibility checks. + command.env("PSModulePath",root.join("Modules")); + Ok(command) +} + #[cfg(windows)] fn verify_publisher(path:&Path,release:&Release)->Result<()> { let script=path.parent().context("Missing update directory")?.join("Verify-Package.ps1"); std::fs::write(&script,include_str!("../../../deployment/windows/Verify-Package.ps1"))?; - let status=std::process::Command::new("powershell.exe").args(["-NoLogo","-NoProfile","-NonInteractive","-ExecutionPolicy","Bypass","-File"]) + let status=powershell_command()?.args(["-NoLogo","-NoProfile","-NonInteractive","-ExecutionPolicy","Bypass","-File"]) .arg(script).arg("-Path").arg(path).arg("-Publisher").arg(&release.publisher).arg("-CertificateSha256").arg(&release.publisher_certificate_sha256).arg("-Sha256").arg(&release.sha256).status()?; ensure!(status.success(),"Package publisher verification failed");Ok(()) } @@ -221,7 +245,7 @@ fn replace_verified_file(source:&Path,target:&Path,expected_hash:&str)->Result<( fn msi_install_command(package:&Path)->Result { let script=package.parent().context("Missing update directory")?.join("Get-MsiInstallMode.ps1"); std::fs::write(&script,include_str!("../../../deployment/windows/Get-MsiInstallMode.ps1"))?; - let output=std::process::Command::new("powershell.exe") + let output=powershell_command()? .args(["-NoLogo","-NoProfile","-NonInteractive","-ExecutionPolicy","Bypass","-File"]) .arg(script).arg("-Package").arg(package).output()?; ensure!(output.status.success(),"Unable to determine MSI installation or repair mode"); @@ -252,7 +276,7 @@ pub fn verify_repair_package(state:&AgentState,directory:&Path,envelope:&SignedE fn verify_compatibility(directory:&Path,release:&Release)->Result<()> { let script=directory.join("Get-WindowsCompatibility.ps1"); std::fs::write(&script,include_str!("../../../deployment/windows/Get-WindowsCompatibility.ps1"))?; - let output=std::process::Command::new("powershell.exe").args(["-NoLogo","-NoProfile","-NonInteractive","-ExecutionPolicy","Bypass","-File"]).arg(script).output()?; + let output=powershell_command()?.args(["-NoLogo","-NoProfile","-NonInteractive","-ExecutionPolicy","Bypass","-File"]).arg(script).output()?; ensure!(output.status.success(),"Unsupported Windows version or installation type"); let platform=String::from_utf8(output.stdout)?; ensure!(release.windows_versions.iter().any(|v|v==platform.trim()),"Release does not support this Windows version");Ok(()) @@ -292,6 +316,35 @@ fn save_receipt(path:&Path,receipt:&Receipt)->Result<()> { publish_download(path,&serde_json::to_vec(receipt)?) } +#[cfg(windows)] +fn installer_cache(directory:&Path,release:&Release)->std::path::PathBuf { + directory.join("releases").join(release.sequence.to_string()).join(format!("installer.{}",release.format)) +} + +#[cfg(windows)] +fn retain_installer(source:&Path,target:&Path,release:&Release)->Result<()> { + ensure!(std::fs::metadata(source)?.len()<=512*1024*1024,"Installer exceeds cache size limit"); + ensure!(staged_hash_matches(source,&release.sha256)?,"Installer cache source differs from signed release"); + verify_publisher(source,release)?; + std::fs::create_dir_all(target.parent().context("Missing installer cache directory")?)?; + replace_verified_file(source,target,&release.sha256)?; + verify_publisher(target,release) +} + +#[cfg(windows)] +async fn ensure_previous_installer(state:&AgentState,directory:&Path)->Result<()> { + let state=state.clone();let installed_directory=directory.to_owned(); + let release=tokio::task::spawn_blocking(move ||->Result { + let envelope:SignedEnvelope=serde_json::from_slice(&std::fs::read(installed_directory.join("installed-release.json"))?)?; + validate_previous_release(&state,&envelope,state.last_release_sequence) + }).await.context("Previous installer discovery failed")??; + let cache=installer_cache(directory,&release); + tokio::fs::create_dir_all(cache.parent().context("Missing installer cache directory")?).await?; + // Previously recorded metadata may be expired. It authorizes retaining only + // the exact old package bytes, never selecting another software release. + restore_staged_download(&cache,&release.artifact_url,&release.sha256,&release).await +} + #[cfg(windows)] fn agent_rollback_protocol(agent:&Path,directory:&Path)->Result { let output=std::process::Command::new(agent).arg("rollback-protocol") @@ -340,7 +393,15 @@ fn verify_rollback_snapshot(state:&AgentState,directory:&Path,envelope:&SignedEn let executable=if release.edition==Edition::Technician {"SwanRemoteSupport-Technician.exe"}else{"Swan Remote Support.exe"}; verify_publisher(&directory.join("endpoint").join(executable),&executable_release)?; let mut agent_release=release.clone();agent_release.sha256=release.agent_sha256.clone(); - verify_publisher(&directory.join("agent/swan-agent.exe"),&agent_release) + verify_publisher(&directory.join("agent/swan-agent.exe"),&agent_release)?; + let package=directory.join("package").join(format!("installer.{}",release.format)); + // Older snapshots did not preserve installers. Forward recovery remains + // available; future MSI rollback must separately require this package. + if package.exists(){ + ensure!(staged_hash_matches(&package,&release.sha256)?,"Rollback installer differs from signed release"); + verify_publisher(&package,&release)?; + } + Ok(()) } #[cfg(windows)] @@ -352,12 +413,16 @@ fn prepare_rollback_snapshot(state:&AgentState,directory:&Path,update:&Release)- verify_installed(directory,&previous)?; let folder=directory.join("updates").join(update.sequence.to_string()); let destination=folder.join("rollback"); - if destination.exists(){verify_rollback_snapshot(state,&destination,&envelope,state.last_release_sequence)?;return Ok(envelope);} + if destination.exists(){ + ensure!(destination.join("package").join(format!("installer.{}",previous.format)).is_file(),"Existing rollback snapshot lacks its original installer; explicit recovery is required"); + verify_rollback_snapshot(state,&destination,&envelope,state.last_release_sequence)?;return Ok(envelope); + } let temporary=folder.join(format!("swan-rollback-{}",random_token()));std::fs::create_dir(&temporary)?; let result=(||->Result<()> { let target=installed_target(directory,&state.bootstrap.edition)?; crate::payload::snapshot(target.parent().context("Missing installed directory")?,&temporary.join("endpoint"),&previous.installed_files)?; crate::payload::snapshot(directory,&temporary.join("agent"),&[InstalledFile{path:"swan-agent.exe".into(),sha256:previous.agent_sha256.clone()}])?; + retain_installer(&installer_cache(directory,&previous),&temporary.join("package").join(format!("installer.{}",previous.format)),&previous)?; save_installed_metadata(&temporary,&envelope)?; verify_rollback_snapshot(state,&temporary,&envelope,state.last_release_sequence)?; std::fs::rename(&temporary,&destination).context("Cannot publish complete release rollback snapshot") @@ -413,7 +478,7 @@ impl AgentState { Ok(Some(envelope)) } #[cfg(windows)] - pub fn record_installation(&mut self,directory:&Path,envelope:&SignedEnvelope,repair:bool)->Result<()> { + pub fn record_installation(&mut self,directory:&Path,envelope:&SignedEnvelope,package:&Path,repair:bool)->Result<()> { let activity=activity_file(directory)?;fs2::FileExt::try_lock_exclusive(&activity).context("Installation verification requires all sessions to close")?; let mut latest=AgentState::load(directory)?; let release=installation_release(&latest,directory,envelope,repair)?; @@ -424,6 +489,7 @@ impl AgentState { } verify_compatibility(directory,&release)?; verify_installed(directory,&release)?; + retain_installer(package,&installer_cache(directory,&release),&release)?; save_installed_metadata(directory,envelope)?; latest.last_release_sequence=release.sequence;latest.save(directory)?; if setup_marker.exists(){std::fs::remove_file(setup_marker)?;} @@ -434,9 +500,12 @@ impl AgentState { let activity=activity_file(directory)?;fs2::FileExt::try_lock_exclusive(&activity).context("Recovery requires all sessions to close")?; let receipt_path=directory.join("pending-update.json"); let receipt:Receipt=serde_json::from_slice(&std::fs::read(&receipt_path)?)?; - let release=validate_pending_receipt(self,&receipt)?; + let release=validate_completion_receipt(self,&receipt)?; verify_compatibility(directory,&release)?; verify_installed(directory,&release)?; + let cache=installer_cache(directory,&release); + let package=if cache.is_file(){cache.clone()}else{directory.join("updates").join(release.sequence.to_string()).join(format!("SwanRemoteSupport-install.{}",release.format))}; + retain_installer(&package,&cache,&release)?; save_installed_metadata(directory,&receipt.release)?; // A crashed updater may have installed successfully or saved state before // removing the receipt. Exact signed executable identity proves either case. @@ -455,7 +524,6 @@ impl AgentState { ensure!(!directory.join("pending-install.json").exists(),"Company setup recovery must finish first"); let latest=AgentState::load_for_refresh(directory)?; let receipt:Receipt=serde_json::from_slice(&std::fs::read(&receipt_path)?)?; - ensure!(receipt.phase=="installing","Use resume-update to finish pending rollback"); if let Some(expected)=expected { ensure!(serde_json::to_vec(&receipt)?==serde_json::to_vec(expected)?,"Pending update changed during staging recovery"); } @@ -574,6 +642,7 @@ impl AgentState { *self=latest;return Ok(true); } save_installed_metadata(directory,&receipt.release)?; + retain_installer(&package,&installer_cache(directory,&release),&release)?; latest.last_release_sequence=release.sequence;latest.save(directory)?; std::fs::remove_file(receipt_path)?;*self=latest;Ok(true) } @@ -603,6 +672,7 @@ impl AgentState { download(&release.agent_url,&release.agent_sha256,&replacement_agent).await?; let mut agent_release=release.clone();agent_release.sha256=release.agent_sha256.clone(); verify_downloaded_publisher(&replacement_agent,&agent_release).await?; + ensure_previous_installer(self,directory).await?; // Downloads may take minutes. Require a fresh signed policy and // current server approval before creating an installation receipt. self.sync().await?;self.save(directory)?; @@ -648,11 +718,7 @@ fn verify_installed(directory:&Path,release:&Release)->Result<()> { let mut files=release.installed_files.clone(); // RustDesk copies this Windows-owned process for input/privacy support. // It varies with Windows updates and is not a project release artifact. - let mut system_directory=[0u16;32768]; - let length=unsafe{windows_sys::Win32::System::SystemInformation::GetSystemDirectoryW(system_directory.as_mut_ptr(),system_directory.len() as u32)} as usize; - ensure!(length>0 && lengthRe ensure!(digest(&bytes).eq_ignore_ascii_case(hash),"Artifact hash mismatch"); tokio::fs::write(path,bytes).await?;Ok(()) } +#[cfg(windows)] fn verify_windows(path:&Path,release:&Release,directory:&Path)->Result<()> { let verifier=directory.join("Verify-Package.ps1");std::fs::write(&verifier,include_bytes!("../../../deployment/windows/Verify-Package.ps1"))?; - let status=std::process::Command::new("powershell.exe").args(["-NoProfile","-NonInteractive","-ExecutionPolicy","Bypass","-File"]).arg(verifier).arg("-Path").arg(path).arg("-Publisher").arg(&release.publisher).arg("-CertificateSha256").arg(&release.publisher_certificate_sha256).status()?; + let status=swan_agent::update::powershell_command()?.args(["-NoProfile","-NonInteractive","-ExecutionPolicy","Bypass","-File"]).arg(verifier).arg("-Path").arg(path).arg("-Publisher").arg(&release.publisher).arg("-CertificateSha256").arg(&release.publisher_certificate_sha256).status()?; ensure!(status.success(),"Artifact signature or publisher rejected");Ok(()) } + +#[cfg(not(windows))] +fn verify_windows(_path:&Path,_release:&Release,_directory:&Path)->Result<()> { + anyhow::bail!("Artifact signature verification requires a Windows worker") +} From 69f71babb1fa28fc137d41b90bf4c73fc9bf1d4f Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Fri, 2 Oct 2026 16:20:22 +0200 Subject: [PATCH 091/652] Validate MSI edition and release identity before installation --- deployment/windows/Get-MsiIdentity.ps1 | 23 ++++++++ docs/ACCEPTANCE.md | 11 ++++ product/agent/src/update.rs | 75 ++++++++++++++++++++++++-- product/worker/src/main.rs | 4 +- 4 files changed, 107 insertions(+), 6 deletions(-) create mode 100644 deployment/windows/Get-MsiIdentity.ps1 diff --git a/deployment/windows/Get-MsiIdentity.ps1 b/deployment/windows/Get-MsiIdentity.ps1 new file mode 100644 index 00000000000..f657bc7fb36 --- /dev/null +++ b/deployment/windows/Get-MsiIdentity.ps1 @@ -0,0 +1,23 @@ +[CmdletBinding()] +param([Parameter(Mandatory=$true)][string]$Package) +$ErrorActionPreference = 'Stop' +# Database mode 0 is read-only. No installation or custom action is invoked. +$installer = New-Object -ComObject WindowsInstaller.Installer +$database = $installer.OpenDatabase((Resolve-Path -LiteralPath $Package).Path, 0) +function Read-Property([string]$Name) { + if ($Name -notin @('ProductCode','UpgradeCode','ProductVersion')) { throw 'Unsupported MSI property.' } + $view = $database.OpenView('SELECT `Value` FROM `Property` WHERE `Property` = ''' + $Name + '''') + try { + $null = $view.Execute() + $row = $view.Fetch() + if ($null -eq $row) { throw "MSI lacks $Name." } + return $row.StringData(1) + } finally { $null = $view.Close() } +} +$summary = $database.SummaryInformation(0) +@{ + product_code = Read-Property 'ProductCode' + upgrade_code = Read-Property 'UpgradeCode' + product_version = Read-Property 'ProductVersion' + template = $summary.Property(7) +} | ConvertTo-Json -Compress diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index 5b50421acd0..e1f2d22d9e3 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -603,3 +603,14 @@ After these corrections, all 39 ordinary product workspace component tests pass again on Windows. The separate HTTPS download test is explicitly ignored in that run. This includes actual Authenticode positive retention and wrong-certificate rejection for the copied OS fixture, not execution of an installation package. + +MSI installation, repair, update commands and Windows worker packaging now +check the package database identity after signature validation. The edition's +fixed UpgradeCode, canonical ProductCode, exact signed ProductVersion and x64 +summary template must match. The database is opened read-only; inspection does +not run installer actions. Explicit fixture tests passed against the actual +unsigned customer and technician 1.4.9 MSIs, rejecting the opposite edition and +wrong version and confirming unchanged artifact hashes. Synthetic checks also +reject malformed product identifiers and non-x64 packages. These identity-only +fixture tests do not bypass publication signature requirements. Agent and worker +Windows builds pass; signed installation and native MSI rollback remain gates. diff --git a/product/agent/src/update.rs b/product/agent/src/update.rs index b526593dd0c..20a04724083 100644 --- a/product/agent/src/update.rs +++ b/product/agent/src/update.rs @@ -242,7 +242,47 @@ fn replace_verified_file(source:&Path,target:&Path,expected_hash:&str)->Result<( } #[cfg(windows)] -fn msi_install_command(package:&Path)->Result { +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct MsiIdentity {product_code:String,upgrade_code:String,product_version:String,template:String} + +#[cfg(windows)] +fn validate_msi_identity(identity:&MsiIdentity,edition:&Edition,version:&str)->Result<()> { + let guid=|value:&str|value.len()==38 && value.starts_with('{') && value.ends_with('}') && value.bytes().enumerate().all(|(index,byte)|match index {0|37=>true,9|14|19|24=>byte==b'-',_=>byte.is_ascii_hexdigit()}); + ensure!(guid(&identity.product_code) && guid(&identity.upgrade_code),"Invalid MSI product or upgrade identity"); + let upgrade=if *edition==Edition::Customer {"{32A585D7-9A78-4AD2-AF72-D0266EFC709D}"}else{"{A4374699-436F-4917-9A4E-223F62A9E634}"}; + ensure!(identity.upgrade_code.eq_ignore_ascii_case(upgrade),"MSI belongs to another product or edition"); + ensure!(identity.product_version==version,"MSI version differs from signed release"); + ensure!(identity.template.split(';').next()==Some("x64"),"MSI must target Windows x64"); + Ok(()) +} + +#[cfg(windows)] +fn read_msi_identity(package:&Path)->Result { + let script=package.parent().context("Missing MSI directory")?.join("Get-MsiIdentity.ps1"); + std::fs::write(&script,include_str!("../../../deployment/windows/Get-MsiIdentity.ps1"))?; + let output=powershell_command()?.args(["-NoLogo","-NoProfile","-NonInteractive","-ExecutionPolicy","Bypass","-File"]).arg(script).arg("-Package").arg(package).output()?; + ensure!(output.status.success(),"Cannot inspect MSI identity without installation"); + let identity:MsiIdentity=serde_json::from_slice(&output.stdout)?; + Ok(identity) +} + +#[cfg(windows)] +fn verify_msi_identity(package:&Path,release:&Release)->Result { + ensure!(release.format=="msi","Expected a signed MSI release"); + let identity=read_msi_identity(package)?; + validate_msi_identity(&identity,&release.edition,&release.version)?; + Ok(identity) +} + +#[cfg(windows)] +pub fn verify_msi_release_identity(package:&Path,release:&Release)->Result<()> { + verify_msi_identity(package,release)?;Ok(()) +} + +#[cfg(windows)] +fn msi_install_command(package:&Path,release:&Release)->Result { + verify_msi_identity(package,release)?; let script=package.parent().context("Missing update directory")?.join("Get-MsiInstallMode.ps1"); std::fs::write(&script,include_str!("../../../deployment/windows/Get-MsiInstallMode.ps1"))?; let output=powershell_command()? @@ -261,7 +301,9 @@ pub fn verify_package(state:&AgentState,directory:&Path,envelope:&SignedEnvelope let release=installation_release(state,directory,envelope,false)?; verify_compatibility(path.parent().context("Missing package directory")?,&release)?; ensure!(digest(std::fs::read(path)?).eq_ignore_ascii_case(&release.sha256),"Installer hash differs from signed metadata"); - verify_publisher(path,&release) + verify_publisher(path,&release)?; + if release.format=="msi" {verify_msi_identity(path,&release)?;} + Ok(()) } #[cfg(windows)] @@ -269,7 +311,9 @@ pub fn verify_repair_package(state:&AgentState,directory:&Path,envelope:&SignedE let release=installation_release(state,directory,envelope,true)?; verify_compatibility(path.parent().context("Missing package directory")?,&release)?; ensure!(digest(std::fs::read(path)?).eq_ignore_ascii_case(&release.sha256),"Repair installer hash differs from signed metadata"); - verify_publisher(path,&release) + verify_publisher(path,&release)?; + if release.format=="msi" {verify_msi_identity(path,&release)?;} + Ok(()) } #[cfg(windows)] @@ -612,12 +656,12 @@ impl AgentState { let previous=folder.join("previous-technician.exe"); if !previous.exists(){std::fs::copy(&target,&previous)?;} if release.format=="msi" { - let status=msi_install_command(&package)?.arg(format!("INSTALLFOLDER={}",directory.display())).status()?; + let status=msi_install_command(&package,&release)?.arg(format!("INSTALLFOLDER={}",directory.display())).status()?; ensure!(matches!(status.code(),Some(0|3010)),"Technician MSI installation failed; retain signed recovery receipt"); }else{replace_verified_file(&package,&target,&release.installed_sha256)?;} }else{ require_existing_application(&installed_target(directory,&latest.bootstrap.edition)?)?; - let mut command=if release.format=="msi" {msi_install_command(&package)?}else{let mut c=std::process::Command::new(&package);c.args(["--silent-install","printer=0"]);c}; + let mut command=if release.format=="msi" {msi_install_command(&package,&release)?}else{let mut c=std::process::Command::new(&package);c.args(["--silent-install","printer=0"]);c}; let status=command.status()?; ensure!(matches!(status.code(),Some(0|3010)),"Installation failed; retain signed recovery receipt"); } @@ -731,6 +775,12 @@ pub(crate) fn test_failed_release_quarantine(state:&AgentState,release:&Release, assert_eq!(failed_release_sequence(state,&folder).unwrap(),0); let mut previous=release.clone();previous.sequence=1;previous.expires_at=now()-1;previous.rollback_protocol=1; #[cfg(windows)] { + let mut identity=MsiIdentity{product_code:"{00112233-4455-6677-8899-AABBCCDDEEFF}".into(),upgrade_code:"{32A585D7-9A78-4AD2-AF72-D0266EFC709D}".into(),product_version:release.version.clone(),template:"x64;1033".into()}; + validate_msi_identity(&identity,&Edition::Customer,&release.version).unwrap(); + assert!(validate_msi_identity(&identity,&Edition::Technician,&release.version).is_err()); + assert!(validate_msi_identity(&identity,&Edition::Customer,"99.0.0").is_err()); + identity.template="Intel;1033".into();assert!(validate_msi_identity(&identity,&Edition::Customer,&release.version).is_err()); + identity.template="x64;1033".into();identity.product_code="invalid product".into();assert!(validate_msi_identity(&identity,&Edition::Customer,&release.version).is_err()); let builtins=powershell_command().unwrap().args(["-NoLogo","-NoProfile","-NonInteractive","-Command","$ErrorActionPreference='Stop'; Get-Command Get-FileHash,Get-AuthenticodeSignature | ForEach-Object Name"]).output().unwrap(); assert!(builtins.status.success()); let commands=String::from_utf8(builtins.stdout).unwrap(); @@ -815,6 +865,21 @@ pub(crate) fn test_failed_release_quarantine(state:&AgentState,release:&Release, #[cfg(test)] mod tests { use super::*; + #[cfg(windows)] + #[test] + #[ignore="Requires an explicitly selected isolated MSI artifact; reads its database without installation"] + fn real_msi_identity_matches_release_policy_without_installation(){ + let package=std::path::PathBuf::from(std::env::var_os("SWAN_TEST_MSI_PACKAGE").expect("Set private fixture MSI path")); + let edition=match std::env::var("SWAN_TEST_MSI_EDITION").unwrap().as_str(){"customer"=>Edition::Customer,"technician"=>Edition::Technician,_=>panic!("Explicit fixture edition required")}; + let version=std::env::var("SWAN_TEST_MSI_VERSION").expect("Explicit fixture version required"); + let hash=digest(std::fs::read(&package).unwrap()); + let identity=read_msi_identity(&package).unwrap(); + validate_msi_identity(&identity,&edition,&version).unwrap(); + let wrong=if edition==Edition::Customer {Edition::Technician}else{Edition::Customer}; + assert!(validate_msi_identity(&identity,&wrong,&version).is_err()); + assert!(validate_msi_identity(&identity,&edition,"99.0.0").is_err()); + assert_eq!(hash,digest(std::fs::read(package).unwrap()),"Read-only MSI inspection must not change its bytes"); + } #[tokio::test] #[ignore="Requires the isolated loopback HTTPS download-recovery harness"] async fn https_download_recovery_preserves_staging_on_tamper_and_interruption(){ diff --git a/product/worker/src/main.rs b/product/worker/src/main.rs index 226f3ff481d..af044cda668 100644 --- a/product/worker/src/main.rs +++ b/product/worker/src/main.rs @@ -125,7 +125,9 @@ pub async fn download(client:&reqwest::Client,url:&str,path:&Path,hash:&str)->Re fn verify_windows(path:&Path,release:&Release,directory:&Path)->Result<()> { let verifier=directory.join("Verify-Package.ps1");std::fs::write(&verifier,include_bytes!("../../../deployment/windows/Verify-Package.ps1"))?; let status=swan_agent::update::powershell_command()?.args(["-NoProfile","-NonInteractive","-ExecutionPolicy","Bypass","-File"]).arg(verifier).arg("-Path").arg(path).arg("-Publisher").arg(&release.publisher).arg("-CertificateSha256").arg(&release.publisher_certificate_sha256).status()?; - ensure!(status.success(),"Artifact signature or publisher rejected");Ok(()) + ensure!(status.success(),"Artifact signature or publisher rejected"); + if release.format=="msi" {swan_agent::update::verify_msi_release_identity(path,release)?;} + Ok(()) } #[cfg(not(windows))] From f0ef7815a2ef28d4e5bb0753afccc4d7e68157e6 Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Fri, 2 Oct 2026 16:26:07 +0200 Subject: [PATCH 092/652] Add resumable technician MSI registration rollback --- deployment/windows/Restore-TechnicianMsi.ps1 | 65 ++++++++++++++++++++ deployment/windows/TECHNICIAN-MSI.md | 20 +++++- docs/ACCEPTANCE.md | 14 +++++ product/agent/src/update.rs | 45 +++++++++++--- 4 files changed, 133 insertions(+), 11 deletions(-) create mode 100644 deployment/windows/Restore-TechnicianMsi.ps1 diff --git a/deployment/windows/Restore-TechnicianMsi.ps1 b/deployment/windows/Restore-TechnicianMsi.ps1 new file mode 100644 index 00000000000..70d4ee1d0b7 --- /dev/null +++ b/deployment/windows/Restore-TechnicianMsi.ps1 @@ -0,0 +1,65 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory=$true)][string]$PreviousPackage, + [Parameter(Mandatory=$true)][string]$NextProductCode, + [Parameter(Mandatory=$true)][string]$NextVersion, + [Parameter(Mandatory=$true)][string]$PreviousProductCode, + [Parameter(Mandatory=$true)][string]$PreviousVersion, + [Parameter(Mandatory=$true)][string]$Directory, + [switch]$InspectOnly +) +$ErrorActionPreference = 'Stop' +$upgrade = '{A4374699-436F-4917-9A4E-223F62A9E634}' +$installer = New-Object -ComObject WindowsInstaller.Installer +function Read-Identity([string]$Path) { + $database = $installer.OpenDatabase((Resolve-Path -LiteralPath $Path).Path, 0) + $result = @{} + foreach ($name in @('ProductCode','UpgradeCode','ProductVersion')) { + $view = $database.OpenView('SELECT `Value` FROM `Property` WHERE `Property` = ''' + $name + '''') + try { + $null = $view.Execute() + $row = $view.Fetch() + if ($null -eq $row) { throw 'Missing MSI identity.' } + $result[$name] = $row.StringData(1) + } finally { $null = $view.Close() } + } + if ($database.SummaryInformation(0).Property(7).Split(';')[0] -cne 'x64') { throw 'Unexpected MSI platform.' } + return $result +} +function Assert-Identity($Identity, [string]$Code, [string]$Version) { + if ($Identity.ProductCode -ine $Code -or $Identity.UpgradeCode -ine $upgrade -or $Identity.ProductVersion -cne $Version) { throw 'MSI registration differs from the signed recovery identity.' } +} +foreach ($code in @($PreviousProductCode,$NextProductCode)) { + if ($code -notmatch '^\{[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}\}$') { throw 'Invalid recovery product code.' } +} +Assert-Identity (Read-Identity $PreviousPackage) $PreviousProductCode $PreviousVersion +# Enumerate only this edition's upgrade family in the caller's installer context. +# Never remove an unexpected product or another user's installation. +foreach ($code in @($installer.RelatedProducts($upgrade))) { + if ($code -ine $PreviousProductCode -and $code -ine $NextProductCode) { throw 'Unexpected installed technician release; explicit recovery required.' } + $version = if ($code -ieq $NextProductCode) { $NextVersion } else { $PreviousVersion } + Assert-Identity (Read-Identity ($installer.ProductInfo($code,'LocalPackage'))) $code $version +} +$msiexec = Join-Path ([Environment]::GetFolderPath('System')) 'msiexec.exe' +if ($PreviousProductCode -ieq $NextProductCode -and $PreviousVersion -cne $NextVersion) { throw 'Different versions must use different product codes.' } +$nextState = $installer.ProductState($NextProductCode) +if ($nextState -notin @(-1,1,5)) { throw 'Ambiguous next product registration.' } +$previousState = $installer.ProductState($PreviousProductCode) +if ($previousState -notin @(-1,1,5)) { throw 'Ambiguous previous product registration.' } +if ($InspectOnly) { + @{ previous_product_code=$PreviousProductCode; next_product_code=$NextProductCode; next_state=$nextState; would_remove_next=($NextProductCode -ine $PreviousProductCode -and $nextState -eq 5); installer_executed=$false } | ConvertTo-Json -Compress + return +} +if ($NextProductCode -ine $PreviousProductCode -and $installer.ProductState($NextProductCode) -eq 5) { + # Recheck the exact cached database immediately before narrowly removing it. + Assert-Identity (Read-Identity ($installer.ProductInfo($NextProductCode,'LocalPackage'))) $NextProductCode $NextVersion + & $msiexec /x $NextProductCode /qn /norestart + if ($LASTEXITCODE -ne 0) { throw "Removal requires recovery or restart (exit $LASTEXITCODE)." } +} +$state = $installer.ProductState($PreviousProductCode) +$mode = if ($state -eq 5) { '/fvamus' } elseif ($state -in @(-1,1)) { '/i' } else { throw 'Ambiguous previous product registration.' } +# Quote paths as installer arguments; profile data never supplies commands. +& $msiexec $mode ('"' + (Resolve-Path -LiteralPath $PreviousPackage).Path + '"') /qn /norestart ('INSTALLFOLDER="' + [IO.Path]::GetFullPath($Directory) + '"') +if ($LASTEXITCODE -ne 0) { throw "Restoration requires recovery or restart (exit $LASTEXITCODE)." } +Assert-Identity (Read-Identity ($installer.ProductInfo($PreviousProductCode,'LocalPackage'))) $PreviousProductCode $PreviousVersion +if ($installer.ProductState($PreviousProductCode) -ne 5) { throw 'Previous product registration was not restored.' } diff --git a/deployment/windows/TECHNICIAN-MSI.md b/deployment/windows/TECHNICIAN-MSI.md index 74abde08615..d13134a5be0 100644 --- a/deployment/windows/TECHNICIAN-MSI.md +++ b/deployment/windows/TECHNICIAN-MSI.md @@ -54,6 +54,22 @@ artifact and installed executable hashes are distinct signed fields. MSI installation uses the canonical per-user folder and requires full installed application/agent verification before the pending receipt is cleared. +Failed MSI updates can now enter durable technician rollback when the previous +signed release and agent both support rollback protocol 1. Recovery requires the +retained original MSI and complete verified snapshot. It checks the current +user's edition-specific upgrade family and cached product identities before +removing only the exact failed ProductCode, then installs or repairs the verified +previous package. Unexpected registrations stop recovery. Interrupted removal +or restoration leaves the signed receipt pending for retry; no company identity +or consent is restored from software snapshots. Hash, publisher, installed-file +and agent checks must pass before quarantining the failed release and clearing +the receipt. Customer MSI/service rollback remains separate unfinished work. + +`Restore-TechnicianMsi.ps1 -InspectOnly` performs read-only identity and +registration planning and exits before invoking Windows Installer. It is a +diagnostic, not evidence that native rollback succeeds. Production recovery +uses the signed agent's prior hash/publisher checks and does not select this mode. + ## Evidence and remaining work An actual unsigned MSI was built locally with WiX `5.0.2+aa65968c` from the @@ -66,8 +82,8 @@ PowerShell parsing pass. Clean-machine installation, company setup, repair, versioned major upgrades, uninstall cleanup, signed automatic MSI updates and interruption rollback remain -unverified. Customer MSI authoring and end-to-end company publication remain -incomplete. The bare MSI launch conditions do not replace the signed agent's +unverified. Customer MSI authoring is available separately; end-to-end company +publication remains unverified. The bare MSI launch conditions do not replace the signed agent's Windows compatibility preflight. Advertised Windows compatibility requires the full acceptance matrix. diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index e1f2d22d9e3..1e55b76f6a6 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -614,3 +614,17 @@ wrong version and confirming unchanged artifact hashes. Synthetic checks also reject malformed product identifiers and non-x64 packages. These identity-only fixture tests do not bypass publication signature requirements. Agent and worker Windows builds pass; signed installation and native MSI rollback remain gates. + +Technician rollback now includes MSI registration recovery using the verified +previous installer. Recovery validates edition-specific related registrations +and their cached product identities before narrowly removing the failed product +and reinstalling or repairing the previous one. Unexpected registrations fail +closed. Durable rollback retries re-fetch only the exact signed failed MSI when +its staged copy is missing, because its ProductCode is needed for narrow removal. +Previous software identity must verify before failed-release quarantine clears +session exclusion; enrollment and consent remain current. All 21 ordinary agent +tests pass (two fixture tests excluded); the actual technician MSI read-only +planning fixture separately passes, including incorrect previous-version +rejection and no installer execution. Script syntax and Windows compilation +pass. Native MSI rollback, reboot/interruption, customer service rollback and +the complete clean Windows matrix remain unverified. diff --git a/product/agent/src/update.rs b/product/agent/src/update.rs index 20a04724083..0c6176c0f20 100644 --- a/product/agent/src/update.rs +++ b/product/agent/src/update.rs @@ -401,19 +401,35 @@ fn agent_rollback_protocol(agent:&Path,directory:&Path)->Result { } #[cfg(windows)] -fn rollback_portable(state:&AgentState,directory:&Path,folder:&Path,receipt:&Receipt)->Result<()> { +fn rollback_technician(state:&AgentState,directory:&Path,folder:&Path,receipt:&Receipt)->Result<()> { ensure!(receipt.rollback_protocol==1 && receipt.phase=="rolling_back","Rollback was not durably prepared"); ensure!(state.last_release_sequence==receipt.previous_sequence,"Cannot roll back a committed newer installation"); let previous=receipt.previous_release.as_ref().context("Rollback requires a signed previous release")?; let release=validate_previous_release(state,previous,receipt.previous_sequence)?; - ensure!(release.rollback_protocol==1 && release.edition==Edition::Technician && release.format=="exe" && release.sha256.eq_ignore_ascii_case(&release.installed_sha256),"Portable rollback requires a compatible portable release and cannot modify MSI or customer registrations"); + let failed_release=receipt.release.verify::(&public_key(&state.bootstrap.release_public_key)?)?; + ensure!(release.rollback_protocol==1 && release.edition==Edition::Technician && failed_release.edition==Edition::Technician && release.format==failed_release.format && (release.format=="msi" || (release.format=="exe" && release.sha256.eq_ignore_ascii_case(&release.installed_sha256))),"Rollback requires compatible technician releases"); let snapshot=folder.join("rollback"); verify_compatibility(directory,&release)?; verify_rollback_snapshot(state,&snapshot,previous,receipt.previous_sequence)?; ensure!(agent_rollback_protocol(&snapshot.join("agent/swan-agent.exe"),directory)?==1,"Previous agent cannot enforce failed-release quarantine"); let target=installed_target(directory,&state.bootstrap.edition)?; - require_existing_application(&target)?; - replace_verified_file(&snapshot.join("endpoint/SwanRemoteSupport-Technician.exe"),&target,&release.installed_sha256)?; + if release.format=="msi" { + let package=snapshot.join("package/installer.msi"); + let previous_identity=verify_msi_identity(&package,&release)?; + let next_package=folder.join("SwanRemoteSupport-install.msi"); + ensure!(staged_hash_matches(&next_package,&failed_release.sha256)?,"Failed installer identity is unavailable for narrow MSI recovery"); + verify_publisher(&next_package,&failed_release)?; + let next_identity=verify_msi_identity(&next_package,&failed_release)?; + let script=folder.join("Restore-TechnicianMsi.ps1"); + std::fs::write(&script,include_str!("../../../deployment/windows/Restore-TechnicianMsi.ps1"))?; + let status=powershell_command()?.args(["-NoLogo","-NoProfile","-NonInteractive","-ExecutionPolicy","Bypass","-File"]).arg(script) + .arg("-PreviousPackage").arg(&package).arg("-NextProductCode").arg(next_identity.product_code).arg("-NextVersion").arg(&failed_release.version) + .arg("-PreviousProductCode").arg(previous_identity.product_code).arg("-PreviousVersion").arg(&release.version).arg("-Directory").arg(directory).status()?; + ensure!(status.success(),"MSI rollback remains pending; explicit recovery or restart may be required"); + }else{ + require_existing_application(&target)?; + replace_verified_file(&snapshot.join("endpoint/SwanRemoteSupport-Technician.exe"),&target,&release.installed_sha256)?; + } replace_verified_file(&snapshot.join("agent/swan-agent.exe"),&directory.join("swan-agent.exe"),&release.agent_sha256)?; verify_installed(directory,&release)?; save_installed_metadata(directory,previous)?; @@ -605,7 +621,7 @@ impl AgentState { // No credentials accompany release downloads, including redirects. let folder=directory.join("updates").join(release.sequence.to_string()); tokio::fs::create_dir_all(&folder).await?; - if receipt.phase=="installing" { + if receipt.phase=="installing" || (receipt.phase=="rolling_back" && release.format=="msi") { restore_staged_download(&folder.join(format!("SwanRemoteSupport-install.{}",release.format)),&release.artifact_url,&release.sha256,&release).await?; } restore_staged_download(&folder.join("swan-agent.exe"),&release.agent_url,&release.agent_sha256,&release).await?; @@ -637,7 +653,7 @@ impl AgentState { verify_publisher(&helper,&agent_release)?; let folder=directory.join("updates").join(release.sequence.to_string()); if receipt.phase=="rolling_back" { - rollback_portable(&latest,directory,&folder,&receipt)?; + rollback_technician(&latest,directory,&folder,&receipt)?; *self=latest;return Ok(true); } let package=folder.join(format!("SwanRemoteSupport-install.{}",release.format)); @@ -678,11 +694,11 @@ impl AgentState { })(); if let Err(error)=installation { let previous=receipt.previous_release.as_ref().map(|envelope|validate_previous_release(&latest,envelope,receipt.previous_sequence)).transpose()?; - let can_rollback=receipt.rollback_protocol==1 && latest.last_release_sequence==receipt.previous_sequence && release.edition==Edition::Technician && release.format=="exe" && previous.as_ref().map(|old|old.rollback_protocol==1 && old.format=="exe" && old.sha256.eq_ignore_ascii_case(&old.installed_sha256)).unwrap_or(false); + let can_rollback=receipt.rollback_protocol==1 && latest.last_release_sequence==receipt.previous_sequence && release.edition==Edition::Technician && previous.as_ref().map(|old|old.rollback_protocol==1 && old.edition==Edition::Technician && old.format==release.format && (old.format=="msi" || (old.format=="exe" && old.sha256.eq_ignore_ascii_case(&old.installed_sha256)))).unwrap_or(false); if !can_rollback{return Err(error).context("Update failed; signed recovery remains pending");} - eprintln!("Update failed; restoring the verified previous portable release: {error:#}"); + eprintln!("Update failed; restoring the verified previous technician release: {error:#}"); receipt.phase="rolling_back".into();save_receipt(&receipt_path,&receipt)?; - rollback_portable(&latest,directory,&folder,&receipt)?; + rollback_technician(&latest,directory,&folder,&receipt)?; *self=latest;return Ok(true); } save_installed_metadata(directory,&receipt.release)?; @@ -878,6 +894,17 @@ mod tests { let wrong=if edition==Edition::Customer {Edition::Technician}else{Edition::Customer}; assert!(validate_msi_identity(&identity,&wrong,&version).is_err()); assert!(validate_msi_identity(&identity,&edition,"99.0.0").is_err()); + if edition==Edition::Technician { + let script=package.parent().unwrap().join("Restore-TechnicianMsi-read-only-test.ps1"); + std::fs::write(&script,include_str!("../../../deployment/windows/Restore-TechnicianMsi.ps1")).unwrap(); + let inspect=|previous_version:&str|powershell_command().unwrap().args(["-NoLogo","-NoProfile","-NonInteractive","-ExecutionPolicy","Bypass","-File"]).arg(&script) + .arg("-PreviousPackage").arg(&package).arg("-NextProductCode").arg(&identity.product_code).arg("-NextVersion").arg(&version) + .arg("-PreviousProductCode").arg(&identity.product_code).arg("-PreviousVersion").arg(previous_version).arg("-Directory").arg(package.parent().unwrap()).arg("-InspectOnly").output().unwrap(); + let result=inspect(&version);assert!(result.status.success(),"{}",String::from_utf8_lossy(&result.stderr)); + let plan:serde_json::Value=serde_json::from_slice(&result.stdout).unwrap(); + assert_eq!(plan["installer_executed"],false);assert_eq!(plan["would_remove_next"],false); + assert!(!inspect("99.0.0").status.success(),"Read-only recovery planning must reject incorrect previous identity"); + } assert_eq!(hash,digest(std::fs::read(package).unwrap()),"Read-only MSI inspection must not change its bytes"); } #[tokio::test] From a963546975a29e35bf3265e132e2e0182a555ae5 Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Fri, 2 Oct 2026 16:33:30 +0200 Subject: [PATCH 093/652] Restore customer MSI service and configuration task during rollback --- .github/workflows/swan-windows-build.yml | 2 + deployment/windows/CUSTOMER-MSI.md | 13 ++++++ deployment/windows/Restore-Configuration.ps1 | 13 ++++-- ...chnicianMsi.ps1 => Restore-ReleaseMsi.ps1} | 18 +++++++- deployment/windows/TECHNICIAN-MSI.md | 5 +- deployment/windows/test_msi_service_guard.ps1 | 22 +++++++++ docs/ACCEPTANCE.md | 15 ++++++ product/agent/src/update.rs | 46 +++++++++++++------ 8 files changed, 112 insertions(+), 22 deletions(-) rename deployment/windows/{Restore-TechnicianMsi.ps1 => Restore-ReleaseMsi.ps1} (76%) create mode 100644 deployment/windows/test_msi_service_guard.ps1 diff --git a/.github/workflows/swan-windows-build.yml b/.github/workflows/swan-windows-build.yml index ca70cdd91fe..c2d89ae8adf 100644 --- a/.github/workflows/swan-windows-build.yml +++ b/.github/workflows/swan-windows-build.yml @@ -168,6 +168,8 @@ jobs: 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 + & "$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.' } 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 diff --git a/deployment/windows/CUSTOMER-MSI.md b/deployment/windows/CUSTOMER-MSI.md index 693993a7406..99c638596ed 100644 --- a/deployment/windows/CUSTOMER-MSI.md +++ b/deployment/windows/CUSTOMER-MSI.md @@ -72,5 +72,18 @@ tests pass. Pinned WiX compiled a real unsigned package and its Installer tables confirm rollback, cleanup and file-removal order. Actual task deletion, MSI rollback and reinstall on clean guests remain unverified. +Failed software updates can now restore the previous customer MSI when both +previous signed release metadata and its agent support rollback protocol 1. +Recovery uses the complete verified snapshot and original installer, restricts +registration removal to the exact failed ProductCode and checks the customer +service's exact executable path and LocalSystem account before removal. The +restored service must run automatically. The verified previous agent restores +only its owned SYSTEM configuration task, deferring startup until recovery +finishes. Enrollment and consent files remain current. Unexpected registration, +service or task ownership leaves recovery pending. Customer EXE/service rollback +and autonomous recovery after reboot between MSI removal and reinstall remain +unfinished. This path has component and read-only planning evidence only; native +signed installation, removal, restore and restart remain acceptance gates. + References: [WiX service installation](https://docs.firegiant.com/wix/schema/wxs/serviceinstall/) and [Windows-owned file copying](https://docs.firegiant.com/wix/schema/wxs/copyfile/). diff --git a/deployment/windows/Restore-Configuration.ps1 b/deployment/windows/Restore-Configuration.ps1 index dfc3f249f33..2c3d75e3c27 100644 --- a/deployment/windows/Restore-Configuration.ps1 +++ b/deployment/windows/Restore-Configuration.ps1 @@ -1,10 +1,13 @@ [CmdletBinding()] -param([Parameter(Mandatory=$true)][string]$Directory) +param([Parameter(Mandatory=$true)][string]$Directory, [switch]$DeferStart, [switch]$RequireTask) $ErrorActionPreference = 'Stop' $expectedAgent = [IO.Path]::GetFullPath((Join-Path $Directory 'swan-agent.exe')) $rollbackPath = Join-Path $Directory 'configuration-task-rollback.json' -if (-not (Test-Path -LiteralPath $rollbackPath)) { return } -$previous = Get-Content -LiteralPath $rollbackPath -Raw | ConvertFrom-Json +if (Test-Path -LiteralPath $rollbackPath) { + $previous = Get-Content -LiteralPath $rollbackPath -Raw | ConvertFrom-Json +} elseif ($RequireTask) { + $previous = @{enabled=$true;running=$false} +} else { return } if ($previous.enabled -isnot [bool] -or $previous.running -isnot [bool]) { throw 'Invalid configuration task rollback state.' } $tasks = @(Get-ScheduledTask | Where-Object { $_.TaskName -eq 'Swan Company Configuration' }) if ($tasks.Count -gt 1) { throw 'Ambiguous company configuration task.' } @@ -19,5 +22,5 @@ $settings = New-ScheduledTaskSettingsSet -RestartCount 3 -RestartInterval (New-T Register-ScheduledTask -TaskName 'Swan Company Configuration' -Action $action -Trigger $trigger -Settings $settings -User 'SYSTEM' -RunLevel Highest | Out-Null } if ($previous.enabled) { Enable-ScheduledTask -TaskName 'Swan Company Configuration' | Out-Null } else { Disable-ScheduledTask -TaskName 'Swan Company Configuration' | Out-Null } -if ($previous.running) { Start-ScheduledTask -TaskName 'Swan Company Configuration' } -Remove-Item -LiteralPath $rollbackPath +if ($previous.running -and -not $DeferStart) { Start-ScheduledTask -TaskName 'Swan Company Configuration' } +if (Test-Path -LiteralPath $rollbackPath) { Remove-Item -LiteralPath $rollbackPath } diff --git a/deployment/windows/Restore-TechnicianMsi.ps1 b/deployment/windows/Restore-ReleaseMsi.ps1 similarity index 76% rename from deployment/windows/Restore-TechnicianMsi.ps1 rename to deployment/windows/Restore-ReleaseMsi.ps1 index 70d4ee1d0b7..573fc87c2c7 100644 --- a/deployment/windows/Restore-TechnicianMsi.ps1 +++ b/deployment/windows/Restore-ReleaseMsi.ps1 @@ -6,10 +6,11 @@ param( [Parameter(Mandatory=$true)][string]$PreviousProductCode, [Parameter(Mandatory=$true)][string]$PreviousVersion, [Parameter(Mandatory=$true)][string]$Directory, + [Parameter(Mandatory=$true)][ValidateSet('customer','technician')][string]$Edition, [switch]$InspectOnly ) $ErrorActionPreference = 'Stop' -$upgrade = '{A4374699-436F-4917-9A4E-223F62A9E634}' +$upgrade = if ($Edition -eq 'customer') { '{32A585D7-9A78-4AD2-AF72-D0266EFC709D}' } else { '{A4374699-436F-4917-9A4E-223F62A9E634}' } $installer = New-Object -ComObject WindowsInstaller.Installer function Read-Identity([string]$Path) { $database = $installer.OpenDatabase((Resolve-Path -LiteralPath $Path).Path, 0) @@ -36,7 +37,7 @@ Assert-Identity (Read-Identity $PreviousPackage) $PreviousProductCode $PreviousV # Enumerate only this edition's upgrade family in the caller's installer context. # Never remove an unexpected product or another user's installation. foreach ($code in @($installer.RelatedProducts($upgrade))) { - if ($code -ine $PreviousProductCode -and $code -ine $NextProductCode) { throw 'Unexpected installed technician release; explicit recovery required.' } + if ($code -ine $PreviousProductCode -and $code -ine $NextProductCode) { throw 'Unexpected installed release; explicit recovery required.' } $version = if ($code -ieq $NextProductCode) { $NextVersion } else { $PreviousVersion } Assert-Identity (Read-Identity ($installer.ProductInfo($code,'LocalPackage'))) $code $version } @@ -50,6 +51,18 @@ if ($InspectOnly) { @{ previous_product_code=$PreviousProductCode; next_product_code=$NextProductCode; next_state=$nextState; would_remove_next=($NextProductCode -ine $PreviousProductCode -and $nextState -eq 5); installer_executed=$false } | ConvertTo-Json -Compress return } +function Assert-CustomerServiceRecord($Service, [string]$Expected, [bool]$Required) { + if ($Service.PathName -ine $Expected -or $Service.StartName -ne 'LocalSystem') { throw 'Customer service belongs to another installation.' } + if ($Required -and ($Service.State -ne 'Running' -or $Service.StartMode -ne 'Auto')) { throw 'Restored customer service is not running automatically.' } +} +function Assert-CustomerService([bool]$Required) { + $services = @(Get-CimInstance -ClassName Win32_Service -Filter "Name='Swan Remote Support'") + if ($services.Count -eq 0 -and -not $Required) { return } + if ($services.Count -ne 1) { throw 'Expected exactly one customer service.' } + $expected = '"' + [IO.Path]::GetFullPath((Join-Path $Directory 'Swan Remote Support.exe')) + '" --service' + Assert-CustomerServiceRecord $services[0] $expected $Required +} +if ($Edition -eq 'customer') { Assert-CustomerService $false } if ($NextProductCode -ine $PreviousProductCode -and $installer.ProductState($NextProductCode) -eq 5) { # Recheck the exact cached database immediately before narrowly removing it. Assert-Identity (Read-Identity ($installer.ProductInfo($NextProductCode,'LocalPackage'))) $NextProductCode $NextVersion @@ -63,3 +76,4 @@ $mode = if ($state -eq 5) { '/fvamus' } elseif ($state -in @(-1,1)) { '/i' } els if ($LASTEXITCODE -ne 0) { throw "Restoration requires recovery or restart (exit $LASTEXITCODE)." } Assert-Identity (Read-Identity ($installer.ProductInfo($PreviousProductCode,'LocalPackage'))) $PreviousProductCode $PreviousVersion if ($installer.ProductState($PreviousProductCode) -ne 5) { throw 'Previous product registration was not restored.' } +if ($Edition -eq 'customer') { Assert-CustomerService $true } diff --git a/deployment/windows/TECHNICIAN-MSI.md b/deployment/windows/TECHNICIAN-MSI.md index d13134a5be0..65e2e53f22a 100644 --- a/deployment/windows/TECHNICIAN-MSI.md +++ b/deployment/windows/TECHNICIAN-MSI.md @@ -63,9 +63,10 @@ previous package. Unexpected registrations stop recovery. Interrupted removal or restoration leaves the signed receipt pending for retry; no company identity or consent is restored from software snapshots. Hash, publisher, installed-file and agent checks must pass before quarantining the failed release and clearing -the receipt. Customer MSI/service rollback remains separate unfinished work. +the receipt. The same registration recovery now supports customer MSI releases, +with additional service and configuration-task ownership checks. -`Restore-TechnicianMsi.ps1 -InspectOnly` performs read-only identity and +`Restore-ReleaseMsi.ps1 -Edition technician -InspectOnly` performs read-only identity and registration planning and exits before invoking Windows Installer. It is a diagnostic, not evidence that native rollback succeeds. Production recovery uses the signed agent's prior hash/publisher checks and does not select this mode. diff --git a/deployment/windows/test_msi_service_guard.ps1 b/deployment/windows/test_msi_service_guard.ps1 new file mode 100644 index 00000000000..52d1d4e17f3 --- /dev/null +++ b/deployment/windows/test_msi_service_guard.ps1 @@ -0,0 +1,22 @@ +$ErrorActionPreference = 'Stop' +# Load only the pure guard function. Do not execute the recovery script, query +# services, open MSI databases, or invoke installation/removal on this host. +$tokens=$null; $errors=$null +$ast=[System.Management.Automation.Language.Parser]::ParseFile((Join-Path $PSScriptRoot 'Restore-ReleaseMsi.ps1'),[ref]$tokens,[ref]$errors) +if ($errors.Count) { throw 'Recovery script failed parsing.' } +$function=$ast.Find({param($node) $node -is [System.Management.Automation.Language.FunctionDefinitionAst] -and $node.Name -eq 'Assert-CustomerServiceRecord'},$true) +if ($null -eq $function) { throw 'Service identity guard missing.' } +. ([ScriptBlock]::Create($function.Extent.Text)) +$expected='"C:\IsolatedFixture\Swan Remote Support.exe" --service' +$valid=@{PathName=$expected;StartName='LocalSystem';State='Running';StartMode='Auto'} +Assert-CustomerServiceRecord $valid $expected $true +foreach ($change in @(@{PathName='"C:\OtherInstallation\Swan Remote Support.exe" --service'},@{StartName='OtherAccount'},@{State='Stopped'},@{StartMode='Manual'},@{PathName=($expected+' --other')})) { + $record=$valid.Clone() + foreach ($key in $change.Keys) { $record[$key]=$change[$key] } + $rejected=$false + try { Assert-CustomerServiceRecord $record $expected $true } catch { $rejected=$true } + if (-not $rejected) { throw 'Unsafe service identity or startup state accepted.' } +} +$stopped=$valid.Clone();$stopped.State='Stopped' +Assert-CustomerServiceRecord $stopped $expected $false +Write-Output 'Service guard component checks passed; no native service or installer operations executed.' diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index 1e55b76f6a6..c71c974f244 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -628,3 +628,18 @@ planning fixture separately passes, including incorrect previous-version rejection and no installer execution. Script syntax and Windows compilation pass. Native MSI rollback, reboot/interruption, customer service rollback and the complete clean Windows matrix remain unverified. + +Customer MSI recovery now shares the verified registration path, with exact +customer service executable/account checks before removal, automatic/running +service verification after restoration, and owned configuration-task recreation +after signed installed-file verification. Task startup is deferred until helper +completion. Component tests reject another installation's service, a different +account, stopped/manual service and unexpected executable arguments without +querying or modifying any native service. Both actual MSI fixtures pass +read-only planning and reject incorrect previous versions. Rollback eligibility +regressions accept both MSI editions and reject cross-edition, protocol-0, +format-changing and customer-EXE rollback. Windows compilation, script syntax +and customer MSI authoring tests pass. No installer or service operation ran on +the host. Customer EXE rollback and autonomous reboot recovery between removal +and restoration remain unfinished; native MSI/service/task restoration and all +clean-machine acceptance remain unverified. diff --git a/product/agent/src/update.rs b/product/agent/src/update.rs index 0c6176c0f20..3b1ff79409b 100644 --- a/product/agent/src/update.rs +++ b/product/agent/src/update.rs @@ -401,13 +401,19 @@ fn agent_rollback_protocol(agent:&Path,directory:&Path)->Result { } #[cfg(windows)] -fn rollback_technician(state:&AgentState,directory:&Path,folder:&Path,receipt:&Receipt)->Result<()> { +fn supports_rollback(previous:&Release,failed:&Release)->bool { + previous.rollback_protocol==1 && previous.edition==failed.edition && previous.format==failed.format && + (previous.format=="msi" || (previous.edition==Edition::Technician && previous.format=="exe" && previous.sha256.eq_ignore_ascii_case(&previous.installed_sha256))) +} + +#[cfg(windows)] +fn rollback_release(state:&AgentState,directory:&Path,folder:&Path,receipt:&Receipt)->Result<()> { ensure!(receipt.rollback_protocol==1 && receipt.phase=="rolling_back","Rollback was not durably prepared"); ensure!(state.last_release_sequence==receipt.previous_sequence,"Cannot roll back a committed newer installation"); let previous=receipt.previous_release.as_ref().context("Rollback requires a signed previous release")?; let release=validate_previous_release(state,previous,receipt.previous_sequence)?; let failed_release=receipt.release.verify::(&public_key(&state.bootstrap.release_public_key)?)?; - ensure!(release.rollback_protocol==1 && release.edition==Edition::Technician && failed_release.edition==Edition::Technician && release.format==failed_release.format && (release.format=="msi" || (release.format=="exe" && release.sha256.eq_ignore_ascii_case(&release.installed_sha256))),"Rollback requires compatible technician releases"); + ensure!(release.edition==state.bootstrap.edition && supports_rollback(&release,&failed_release),"Rollback requires compatible releases"); let snapshot=folder.join("rollback"); verify_compatibility(directory,&release)?; verify_rollback_snapshot(state,&snapshot,previous,receipt.previous_sequence)?; @@ -420,11 +426,11 @@ fn rollback_technician(state:&AgentState,directory:&Path,folder:&Path,receipt:&R ensure!(staged_hash_matches(&next_package,&failed_release.sha256)?,"Failed installer identity is unavailable for narrow MSI recovery"); verify_publisher(&next_package,&failed_release)?; let next_identity=verify_msi_identity(&next_package,&failed_release)?; - let script=folder.join("Restore-TechnicianMsi.ps1"); - std::fs::write(&script,include_str!("../../../deployment/windows/Restore-TechnicianMsi.ps1"))?; + let script=folder.join("Restore-ReleaseMsi.ps1"); + std::fs::write(&script,include_str!("../../../deployment/windows/Restore-ReleaseMsi.ps1"))?; let status=powershell_command()?.args(["-NoLogo","-NoProfile","-NonInteractive","-ExecutionPolicy","Bypass","-File"]).arg(script) .arg("-PreviousPackage").arg(&package).arg("-NextProductCode").arg(next_identity.product_code).arg("-NextVersion").arg(&failed_release.version) - .arg("-PreviousProductCode").arg(previous_identity.product_code).arg("-PreviousVersion").arg(&release.version).arg("-Directory").arg(directory).status()?; + .arg("-PreviousProductCode").arg(previous_identity.product_code).arg("-PreviousVersion").arg(&release.version).arg("-Directory").arg(target.parent().context("Missing installation directory")?).arg("-Edition").arg(if release.edition==Edition::Customer {"customer"}else{"technician"}).status()?; ensure!(status.success(),"MSI rollback remains pending; explicit recovery or restart may be required"); }else{ require_existing_application(&target)?; @@ -432,6 +438,12 @@ fn rollback_technician(state:&AgentState,directory:&Path,folder:&Path,receipt:&R } replace_verified_file(&snapshot.join("agent/swan-agent.exe"),&directory.join("swan-agent.exe"),&release.agent_sha256)?; verify_installed(directory,&release)?; + if release.edition==Edition::Customer { + let script=folder.join("Restore-Configuration.ps1"); + std::fs::write(&script,include_str!("../../../deployment/windows/Restore-Configuration.ps1"))?; + let status=powershell_command()?.args(["-NoLogo","-NoProfile","-NonInteractive","-ExecutionPolicy","Bypass","-File"]).arg(script).arg("-Directory").arg(directory).args(["-DeferStart","-RequireTask"]).status()?; + ensure!(status.success(),"Customer configuration task restoration remains pending"); + } save_installed_metadata(directory,previous)?; let mut failed=receipt.clone();failed.phase="rolled_back".into(); ensure!(receipt.release.verify::(&public_key(&state.bootstrap.release_public_key)?)?.sequence>=failed_release_sequence(state,directory)?,"Cannot lower failed-release quarantine"); @@ -653,7 +665,7 @@ impl AgentState { verify_publisher(&helper,&agent_release)?; let folder=directory.join("updates").join(release.sequence.to_string()); if receipt.phase=="rolling_back" { - rollback_technician(&latest,directory,&folder,&receipt)?; + rollback_release(&latest,directory,&folder,&receipt)?; *self=latest;return Ok(true); } let package=folder.join(format!("SwanRemoteSupport-install.{}",release.format)); @@ -694,11 +706,11 @@ impl AgentState { })(); if let Err(error)=installation { let previous=receipt.previous_release.as_ref().map(|envelope|validate_previous_release(&latest,envelope,receipt.previous_sequence)).transpose()?; - let can_rollback=receipt.rollback_protocol==1 && latest.last_release_sequence==receipt.previous_sequence && release.edition==Edition::Technician && previous.as_ref().map(|old|old.rollback_protocol==1 && old.edition==Edition::Technician && old.format==release.format && (old.format=="msi" || (old.format=="exe" && old.sha256.eq_ignore_ascii_case(&old.installed_sha256)))).unwrap_or(false); + let can_rollback=receipt.rollback_protocol==1 && latest.last_release_sequence==receipt.previous_sequence && previous.as_ref().map(|old|supports_rollback(old,&release)).unwrap_or(false); if !can_rollback{return Err(error).context("Update failed; signed recovery remains pending");} - eprintln!("Update failed; restoring the verified previous technician release: {error:#}"); + eprintln!("Update failed; restoring the verified previous release: {error:#}"); receipt.phase="rolling_back".into();save_receipt(&receipt_path,&receipt)?; - rollback_technician(&latest,directory,&folder,&receipt)?; + rollback_release(&latest,directory,&folder,&receipt)?; *self=latest;return Ok(true); } save_installed_metadata(directory,&receipt.release)?; @@ -791,6 +803,14 @@ pub(crate) fn test_failed_release_quarantine(state:&AgentState,release:&Release, assert_eq!(failed_release_sequence(state,&folder).unwrap(),0); let mut previous=release.clone();previous.sequence=1;previous.expires_at=now()-1;previous.rollback_protocol=1; #[cfg(windows)] { + let mut old=previous.clone();old.format="msi".into();old.edition=Edition::Customer; + let mut failed=old.clone();assert!(supports_rollback(&old,&failed)); + failed.edition=Edition::Technician;assert!(!supports_rollback(&old,&failed)); + old.edition=Edition::Technician;assert!(supports_rollback(&old,&failed)); + old.rollback_protocol=0;assert!(!supports_rollback(&old,&failed));old.rollback_protocol=1; + old.format="exe".into();assert!(!supports_rollback(&old,&failed)); + failed.format="exe".into();old.installed_sha256=old.sha256.clone();assert!(supports_rollback(&old,&failed)); + old.edition=Edition::Customer;failed.edition=Edition::Customer;assert!(!supports_rollback(&old,&failed)); let mut identity=MsiIdentity{product_code:"{00112233-4455-6677-8899-AABBCCDDEEFF}".into(),upgrade_code:"{32A585D7-9A78-4AD2-AF72-D0266EFC709D}".into(),product_version:release.version.clone(),template:"x64;1033".into()}; validate_msi_identity(&identity,&Edition::Customer,&release.version).unwrap(); assert!(validate_msi_identity(&identity,&Edition::Technician,&release.version).is_err()); @@ -894,12 +914,12 @@ mod tests { let wrong=if edition==Edition::Customer {Edition::Technician}else{Edition::Customer}; assert!(validate_msi_identity(&identity,&wrong,&version).is_err()); assert!(validate_msi_identity(&identity,&edition,"99.0.0").is_err()); - if edition==Edition::Technician { - let script=package.parent().unwrap().join("Restore-TechnicianMsi-read-only-test.ps1"); - std::fs::write(&script,include_str!("../../../deployment/windows/Restore-TechnicianMsi.ps1")).unwrap(); + { + let script=package.parent().unwrap().join("Restore-ReleaseMsi-read-only-test.ps1"); + std::fs::write(&script,include_str!("../../../deployment/windows/Restore-ReleaseMsi.ps1")).unwrap(); let inspect=|previous_version:&str|powershell_command().unwrap().args(["-NoLogo","-NoProfile","-NonInteractive","-ExecutionPolicy","Bypass","-File"]).arg(&script) .arg("-PreviousPackage").arg(&package).arg("-NextProductCode").arg(&identity.product_code).arg("-NextVersion").arg(&version) - .arg("-PreviousProductCode").arg(&identity.product_code).arg("-PreviousVersion").arg(previous_version).arg("-Directory").arg(package.parent().unwrap()).arg("-InspectOnly").output().unwrap(); + .arg("-PreviousProductCode").arg(&identity.product_code).arg("-PreviousVersion").arg(previous_version).arg("-Directory").arg(package.parent().unwrap()).arg("-Edition").arg(if edition==Edition::Customer {"customer"}else{"technician"}).arg("-InspectOnly").output().unwrap(); let result=inspect(&version);assert!(result.status.success(),"{}",String::from_utf8_lossy(&result.stderr)); let plan:serde_json::Value=serde_json::from_slice(&result.stdout).unwrap(); assert_eq!(plan["installer_executed"],false);assert_eq!(plan["would_remove_next"],false); From 078e0f8690a6b1e82b5d2543ea18d0919ad69c5b Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Fri, 2 Oct 2026 16:57:04 +0200 Subject: [PATCH 094/652] Persist staged update recovery across reboot and respect uninstall cancellation --- .github/workflows/swan-windows-build.yml | 2 + deployment/windows/CUSTOMER-MSI.md | 7 ++- deployment/windows/Restore-ReleaseMsi.ps1 | 3 +- deployment/windows/TECHNICIAN-MSI.md | 6 ++ deployment/windows/UPDATE-RECOVERY.md | 51 +++++++++++++++++ deployment/windows/Update-RecoveryTask.ps1 | 46 +++++++++++++++ .../windows/Update-UninstallCancellation.ps1 | 25 +++++++++ deployment/windows/generate-customer-msi.py | 4 +- deployment/windows/msi/Technician.wxs | 6 ++ deployment/windows/test_customer_msi.py | 2 +- .../windows/test_update_recovery_task.ps1 | 49 ++++++++++++++++ docs/ACCEPTANCE.md | 23 ++++++++ product/agent/src/lib.rs | 1 + product/agent/src/main.rs | 32 ++++++++++- product/agent/src/update.rs | 56 +++++++++++++++++++ 15 files changed, 305 insertions(+), 8 deletions(-) create mode 100644 deployment/windows/UPDATE-RECOVERY.md create mode 100644 deployment/windows/Update-RecoveryTask.ps1 create mode 100644 deployment/windows/Update-UninstallCancellation.ps1 create mode 100644 deployment/windows/test_update_recovery_task.ps1 diff --git a/.github/workflows/swan-windows-build.yml b/.github/workflows/swan-windows-build.yml index c2d89ae8adf..2a74dd6f7eb 100644 --- a/.github/workflows/swan-windows-build.yml +++ b/.github/workflows/swan-windows-build.yml @@ -170,6 +170,8 @@ jobs: python3 -m unittest discover -s deployment/windows -p test_customer_msi.py & "$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.' } 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 diff --git a/deployment/windows/CUSTOMER-MSI.md b/deployment/windows/CUSTOMER-MSI.md index 99c638596ed..3e078aa3193 100644 --- a/deployment/windows/CUSTOMER-MSI.md +++ b/deployment/windows/CUSTOMER-MSI.md @@ -80,9 +80,10 @@ service's exact executable path and LocalSystem account before removal. The restored service must run automatically. The verified previous agent restores only its owned SYSTEM configuration task, deferring startup until recovery finishes. Enrollment and consent files remain current. Unexpected registration, -service or task ownership leaves recovery pending. Customer EXE/service rollback -and autonomous recovery after reboot between MSI removal and reinstall remain -unfinished. This path has component and read-only planning evidence only; native +service or task ownership leaves recovery pending. A separate staged recovery +task now implements startup and periodic retry across removal of the installed +agent; see [update recovery](UPDATE-RECOVERY.md). Customer EXE/service rollback +remains unfinished. This path has component and read-only planning evidence only; native signed installation, removal, restore and restart remain acceptance gates. References: [WiX service installation](https://docs.firegiant.com/wix/schema/wxs/serviceinstall/) diff --git a/deployment/windows/Restore-ReleaseMsi.ps1 b/deployment/windows/Restore-ReleaseMsi.ps1 index 573fc87c2c7..f150072af1c 100644 --- a/deployment/windows/Restore-ReleaseMsi.ps1 +++ b/deployment/windows/Restore-ReleaseMsi.ps1 @@ -66,7 +66,8 @@ if ($Edition -eq 'customer') { Assert-CustomerService $false } if ($NextProductCode -ine $PreviousProductCode -and $installer.ProductState($NextProductCode) -eq 5) { # Recheck the exact cached database immediately before narrowly removing it. Assert-Identity (Read-Identity ($installer.ProductInfo($NextProductCode,'LocalPackage'))) $NextProductCode $NextVersion - & $msiexec /x $NextProductCode /qn /norestart + # Suppress user-uninstall cancellation only for this signed recovery path. + & $msiexec /x $NextProductCode /qn /norestart SWAN_RECOVERY=1 if ($LASTEXITCODE -ne 0) { throw "Removal requires recovery or restart (exit $LASTEXITCODE)." } } $state = $installer.ProductState($PreviousProductCode) diff --git a/deployment/windows/TECHNICIAN-MSI.md b/deployment/windows/TECHNICIAN-MSI.md index 65e2e53f22a..f65d8e38b8f 100644 --- a/deployment/windows/TECHNICIAN-MSI.md +++ b/deployment/windows/TECHNICIAN-MSI.md @@ -71,6 +71,12 @@ registration planning and exits before invoking Windows Installer. It is a diagnostic, not evidence that native rollback succeeds. Production recovery uses the signed agent's prior hash/publisher checks and does not select this mode. +A separate staged recovery task implements logon and periodic retry even when +the installed agent has been removed during rollback. Explicit MSI uninstall +cancels recovery before removing its files; the paired uninstall rollback +restores the prior cancellation state. See [update recovery](UPDATE-RECOVERY.md). +Task execution and reboot recovery remain native acceptance gates. + ## Evidence and remaining work An actual unsigned MSI was built locally with WiX `5.0.2+aa65968c` from the diff --git a/deployment/windows/UPDATE-RECOVERY.md b/deployment/windows/UPDATE-RECOVERY.md new file mode 100644 index 00000000000..64ded8b32cd --- /dev/null +++ b/deployment/windows/UPDATE-RECOVERY.md @@ -0,0 +1,51 @@ +# Durable endpoint update recovery + +Before launching an update helper, the agent registers a separate scheduled task +pointing to the exact hash/publisher-verified helper under +`updates//swan-agent.exe`. It stores no password, technician +token, device token or grant in task arguments. The only argument is +`recover-update-task`; the helper derives the company state directory from its +own staged location rather than inherited environment settings. + +Customer recovery runs as SYSTEM at startup and every five minutes. Technician +recovery runs as the current user, with limited privileges, at logon and every +five minutes. Tasks use a per-directory, edition and sequence name. Registration +or deletion refuses unexpected task paths, actions, arguments or principals. +Failed registration stops handoff before installation. The staged folder is +outside the MSI-owned installed files; customer setup protects its state tree +with SYSTEM/administrator ACLs. + +On each attempt, the staged helper checks its location, the signed pending +receipt, edition, sequence and its own hash/publisher. It resumes only that exact +release or its durably prepared rollback. Public artifact retries send no +credentials. Existing activity locks prevent concurrent installation or support +sessions. Logs remain in the private update folder. When the receipt is absent, +or uninstall cancellation is present, the task removes only its own verified +task registration and does not install software. + +Explicit MSI uninstall writes `pending-uninstall` before file removal, blocking +new sessions and automatic recovery. Paired uninstall rollback restores the +previous cancellation state, including an earlier cancellation. Software +rollback narrowly uninstalls its verified failed MSI with `SWAN_RECOVERY=1`, +excluding user-uninstall cancellation actions. Only explicit verified setup or +repair clears the cancellation marker. A cancelled pending update receipt may +still require additional explicit setup/recovery handling; this is not yet a +verified uninstall/reinstall workflow. + +For diagnostics, `Update-RecoveryTask.ps1 -InspectOnly` reads task ownership and +outputs a proposed identity without registering or removing a task. It does not +prove executable trust or successful registration. `test_update_recovery_task.ps1` +tests pure ownership rejection and cancellation files in an isolated temporary +directory, without native task changes or installer execution. + +## Verification boundary + +Windows agent/worker compilation, agent component tests, read-only task planning, +Scheduled Task object creation and ownership/cancellation regressions pass. +Unsigned customer and technician packaging rehearsals using historical 1.4.9 +native inputs compile; read-only MSI tables confirm rollback/cancellation/file +removal order and upgrade/recovery exclusions. These are authoring checks, not +the current native release or successful installation. Actual signed task +registration, startup/logon execution, reboot between removal and restoration, +uninstall/reinstall, task cleanup, customer EXE rollback and the full supported +Windows matrix remain acceptance gates. diff --git a/deployment/windows/Update-RecoveryTask.ps1 b/deployment/windows/Update-RecoveryTask.ps1 new file mode 100644 index 00000000000..7042ddbf774 --- /dev/null +++ b/deployment/windows/Update-RecoveryTask.ps1 @@ -0,0 +1,46 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory=$true)][ValidateSet('register','remove')][string]$Operation, + [Parameter(Mandatory=$true)][string]$Directory, + [Parameter(Mandatory=$true)][ValidateSet('customer','technician')][string]$Edition, + [Parameter(Mandatory=$true)][UInt64]$Sequence, + [switch]$InspectOnly +) +$ErrorActionPreference='Stop' +function Assert-RecoveryTask($Task,[string]$Helper,[string[]]$Accounts) { + if ($Task.TaskPath -cne '\' -or $Task.Actions.Count -ne 1 -or [IO.Path]::GetFullPath($Task.Actions[0].Execute) -ine $Helper -or $Task.Actions[0].Arguments -cne 'recover-update-task' -or $Task.Principal.UserId -notin $Accounts) { throw 'Update recovery task belongs to another installation.' } +} +$directoryPath=[IO.Path]::GetFullPath($Directory).TrimEnd('\') +if ($Sequence -eq 0) { throw 'A positive recovery sequence is required.' } +$helper=Join-Path $directoryPath ("updates\$Sequence\swan-agent.exe") +$hash=[Security.Cryptography.SHA256]::Create() +try { $identifier=([BitConverter]::ToString($hash.ComputeHash([Text.Encoding]::UTF8.GetBytes($directoryPath.ToLowerInvariant())))).Replace('-','').Substring(0,16) } finally { $hash.Dispose() } +$name="Swan Update Recovery $Edition $identifier $Sequence" +$user=if ($Edition -eq 'customer') { 'S-1-5-18' } else { [Security.Principal.WindowsIdentity]::GetCurrent().User.Value } +$tasks=@(Get-ScheduledTask | Where-Object { $_.TaskName -ceq $name }) +if ($tasks.Count -gt 1) { throw 'Ambiguous update recovery task.' } +if ($tasks.Count -eq 1) { + $task=$tasks[0] + $account=if ($Edition -eq 'customer') { @('SYSTEM','S-1-5-18','NT AUTHORITY\SYSTEM') } else { @($user,[Security.Principal.WindowsIdentity]::GetCurrent().Name) } + Assert-RecoveryTask $task $helper $account +} +if ($InspectOnly) { + @{task_name=$name;helper=$helper;principal=$user;operation=$Operation;task_mutated=$false} | ConvertTo-Json -Compress + return +} +if ($Operation -eq 'remove') { + if ($tasks.Count -eq 1) { $tasks[0] | Unregister-ScheduledTask -Confirm:$false } + return +} +if ((Get-AuthenticodeSignature -LiteralPath $helper).Status -ne 'Valid') { throw 'Recovery helper signature is invalid.' } +$action=New-ScheduledTaskAction -Execute $helper -Argument 'recover-update-task' +$repeat=New-ScheduledTaskTrigger -Once -At ([DateTime]::Now.AddMinutes(5)) -RepetitionInterval (New-TimeSpan -Minutes 5) +$settings=New-ScheduledTaskSettingsSet -StartWhenAvailable -ExecutionTimeLimit (New-TimeSpan -Hours 2) -MultipleInstances IgnoreNew +if ($Edition -eq 'customer') { + $startup=New-ScheduledTaskTrigger -AtStartup + $principal=New-ScheduledTaskPrincipal -UserId $user -LogonType ServiceAccount -RunLevel Highest +} else { + $startup=New-ScheduledTaskTrigger -AtLogOn -User $user + $principal=New-ScheduledTaskPrincipal -UserId $user -LogonType Interactive -RunLevel Limited +} +Register-ScheduledTask -TaskName $name -Action $action -Trigger @($startup,$repeat) -Settings $settings -Principal $principal -Force | Out-Null diff --git a/deployment/windows/Update-UninstallCancellation.ps1 b/deployment/windows/Update-UninstallCancellation.ps1 new file mode 100644 index 00000000000..897ebf7aafc --- /dev/null +++ b/deployment/windows/Update-UninstallCancellation.ps1 @@ -0,0 +1,25 @@ +[CmdletBinding()] +param([Parameter(Mandatory=$true)][string]$Directory,[Parameter(Mandatory=$true)][ValidateSet('cancel','restore')][string]$Operation) +$ErrorActionPreference='Stop' +$marker=Join-Path $Directory 'pending-uninstall' +$rollback=Join-Path $Directory 'uninstall-cancellation-rollback.json' +if ($Operation -eq 'cancel') { + if (Test-Path -LiteralPath $rollback) { throw 'Previous uninstall cancellation requires explicit recovery.' } + $bytes=[Text.Encoding]::UTF8.GetBytes((@{marker_existed=(Test-Path -LiteralPath $marker)} | ConvertTo-Json -Compress)) + $temporary=Join-Path $Directory ('uninstall-cancellation-'+[Guid]::NewGuid().ToString('N')+'.tmp') + try { + $stream=[IO.File]::Open($temporary,[IO.FileMode]::CreateNew,[IO.FileAccess]::Write,[IO.FileShare]::None) + try { $stream.Write($bytes,0,$bytes.Length);$stream.Flush($true) } finally { $stream.Dispose() } + [IO.File]::Move($temporary,$rollback) + } finally { if (Test-Path -LiteralPath $temporary) { Remove-Item -LiteralPath $temporary } } + if (-not (Test-Path -LiteralPath $marker)) { + $stream=[IO.File]::Open($marker,[IO.FileMode]::CreateNew,[IO.FileAccess]::Write,[IO.FileShare]::None) + try { $stream.Flush($true) } finally { $stream.Dispose() } + } +} else { + if (-not (Test-Path -LiteralPath $rollback)) { return } + $previous=Get-Content -LiteralPath $rollback -Raw | ConvertFrom-Json + if ($previous.marker_existed -isnot [bool]) { throw 'Invalid uninstall cancellation rollback state.' } + if (-not $previous.marker_existed -and (Test-Path -LiteralPath $marker)) { Remove-Item -LiteralPath $marker } + Remove-Item -LiteralPath $rollback +} diff --git a/deployment/windows/generate-customer-msi.py b/deployment/windows/generate-customer-msi.py index 620f5ac9e75..f0e5b395b36 100644 --- a/deployment/windows/generate-customer-msi.py +++ b/deployment/windows/generate-customer-msi.py @@ -94,9 +94,9 @@ def author(source, agent, version): ExeCommand="restore-configuration-task", Execute="rollback", Impersonate="no", Return="check") sequence = element(package, "InstallExecuteSequence") element(sequence, "Custom", Action="RestoreCompanyConfigurationTask", Before="RemoveCompanyConfigurationTask", - Condition='REMOVE = "ALL" AND NOT UPGRADINGPRODUCTCODE') + Condition='REMOVE = "ALL" AND NOT UPGRADINGPRODUCTCODE AND NOT SWAN_RECOVERY') element(sequence, "Custom", Action="RemoveCompanyConfigurationTask", Before="RemoveFiles", - Condition='REMOVE = "ALL" AND NOT UPGRADINGPRODUCTCODE') + Condition='REMOVE = "ALL" AND NOT UPGRADINGPRODUCTCODE AND NOT SWAN_RECOVERY') return ET.ElementTree(root), files diff --git a/deployment/windows/msi/Technician.wxs b/deployment/windows/msi/Technician.wxs index 1759345bb7c..864d91805e5 100644 --- a/deployment/windows/msi/Technician.wxs +++ b/deployment/windows/msi/Technician.wxs @@ -2,6 +2,12 @@ + + + + + + diff --git a/deployment/windows/test_customer_msi.py b/deployment/windows/test_customer_msi.py index 1a857b35e0e..e8168e5cd0c 100644 --- a/deployment/windows/test_customer_msi.py +++ b/deployment/windows/test_customer_msi.py @@ -49,7 +49,7 @@ def test_full_manifest_license_and_service_lifecycle(self): self.assertEqual(cleanup.attrib["Impersonate"], "no") scheduling = tree.find(".//w:InstallExecuteSequence/w:Custom[@Action='RemoveCompanyConfigurationTask']", ns) self.assertEqual(scheduling.attrib["Before"], "RemoveFiles") - self.assertEqual(scheduling.attrib["Condition"], 'REMOVE = "ALL" AND NOT UPGRADINGPRODUCTCODE') + self.assertEqual(scheduling.attrib["Condition"], 'REMOVE = "ALL" AND NOT UPGRADINGPRODUCTCODE AND NOT SWAN_RECOVERY') rollback = tree.find(".//w:CustomAction[@Id='RestoreCompanyConfigurationTask']", ns) self.assertEqual(rollback.attrib["Execute"], "rollback") rollback_scheduling = tree.find(".//w:InstallExecuteSequence/w:Custom[@Action='RestoreCompanyConfigurationTask']", ns) diff --git a/deployment/windows/test_update_recovery_task.ps1 b/deployment/windows/test_update_recovery_task.ps1 new file mode 100644 index 00000000000..697e39fda0a --- /dev/null +++ b/deployment/windows/test_update_recovery_task.ps1 @@ -0,0 +1,49 @@ +$ErrorActionPreference='Stop' +# Execute only the pure ownership guard, never task registration or removal. +$tokens=$null; $errors=$null +$ast=[System.Management.Automation.Language.Parser]::ParseFile((Join-Path $PSScriptRoot 'Update-RecoveryTask.ps1'),[ref]$tokens,[ref]$errors) +if ($errors.Count) { throw 'Recovery task script failed parsing.' } +$guard=$ast.Find({param($node) $node -is [System.Management.Automation.Language.FunctionDefinitionAst] -and $node.Name -eq 'Assert-RecoveryTask'},$true) +if ($null -eq $guard) { throw 'Recovery task ownership guard missing.' } +. ([ScriptBlock]::Create($guard.Extent.Text)) +$helper='C:\IsolatedFixture\updates\2\swan-agent.exe' +$action=@{Execute=$helper;Arguments='recover-update-task'} +$valid=@{TaskPath='\';Actions=@($action);Principal=@{UserId='S-1-5-18'}} +Assert-RecoveryTask $valid $helper @('S-1-5-18') +foreach ($change in @( + @{TaskPath='\OtherProduct\'}, + @{Actions=@(@{Execute='C:\OtherInstallation\swan-agent.exe';Arguments='recover-update-task'})}, + @{Actions=@(@{Execute=$helper;Arguments='watch'})}, + @{Actions=@($action,$action)}, + @{Principal=@{UserId='OtherAccount'}} +)) { + $record=$valid.Clone() + foreach ($key in $change.Keys) { $record[$key]=$change[$key] } + $rejected=$false + try { Assert-RecoveryTask $record $helper @('S-1-5-18') } catch { $rejected=$true } + if (-not $rejected) { throw 'Recovery task ownership mismatch was accepted.' } +} +Write-Output 'Recovery task guard checks passed; no native task operations executed.' +$fixture=Join-Path ([IO.Path]::GetTempPath()) ('swan-uninstall-cancellation-'+[Guid]::NewGuid().ToString('N')) +$null=New-Item -ItemType Directory -Path $fixture +$marker=Join-Path $fixture 'pending-uninstall' +$rollback=Join-Path $fixture 'uninstall-cancellation-rollback.json' +try { + $script=Join-Path $PSScriptRoot 'Update-UninstallCancellation.ps1' + & $script -Directory $fixture -Operation cancel + if (-not (Test-Path -LiteralPath $marker)) { throw 'Uninstall did not cancel recovery.' } + & $script -Directory $fixture -Operation restore + if (Test-Path -LiteralPath $marker) { throw 'Uninstall rollback did not restore recovery eligibility.' } + [IO.File]::WriteAllText($marker,'earlier explicit cancellation') + & $script -Directory $fixture -Operation cancel + & $script -Directory $fixture -Operation restore + if ([IO.File]::ReadAllText($marker) -cne 'earlier explicit cancellation') { throw 'Uninstall rollback lost earlier cancellation.' } + [IO.File]::WriteAllText($rollback,'{"marker_existed":"invalid"}') + $rejected=$false + try { & $script -Directory $fixture -Operation restore } catch { $rejected=$true } + if (-not $rejected -or -not (Test-Path -LiteralPath $marker)) { throw 'Invalid rollback state cleared cancellation.' } +} finally { + foreach ($file in @($marker,$rollback)) { if (Test-Path -LiteralPath $file) { Remove-Item -LiteralPath $file } } + Remove-Item -LiteralPath $fixture +} +Write-Output 'Uninstall cancellation file checks passed in an isolated temporary directory.' diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index c71c974f244..f0a43fa8b44 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -643,3 +643,26 @@ and customer MSI authoring tests pass. No installer or service operation ran on the host. Customer EXE rollback and autonomous reboot recovery between removal and restoration remain unfinished; native MSI/service/task restoration and all clean-machine acceptance remain unverified. + +Durable update task handoff now registers the verified staged helper outside +MSI-owned installed files. Customer startup/SYSTEM and technician logon/limited +user tasks retry every five minutes, derive state from their staged location, +and revalidate signed pending metadata and helper identity. Own-task cleanup +refuses mismatched task path, actions, arguments or principal. Explicit MSI +uninstall cancels recovery and sessions before file removal; paired uninstall +rollback preserves any earlier cancellation. Signed MSI recovery excludes +those cancellation actions. This implements the reboot retry mechanism but +does not demonstrate native reboot recovery. + +All 21 ordinary agent tests pass again, including cancellation session rejection; +agent and worker Windows builds pass. Task ownership and isolated cancellation +file tests pass, including malformed rollback rejection. Read-only task planning +and Scheduled Task object creation confirm intended principals and five-minute +repetition without registering any task. Both unsigned MSI recipes compile +against historical 1.4.9 native inputs; read-only tables confirm cancellation +rollback at 3498, cancellation at 3499 and file removal at 3500, excluded during +upgrades and signed recovery. No product installer, service or native task +mutation ran on this host. Signed task registration, startup/logon, reboot, +explicit cancelled-update reinstall handling and customer EXE rollback still +need implementation/verification as applicable; the full clean Windows matrix +and current native build remain gates. diff --git a/product/agent/src/lib.rs b/product/agent/src/lib.rs index af9de542583..236e1a39a4d 100644 --- a/product/agent/src/lib.rs +++ b/product/agent/src/lib.rs @@ -238,6 +238,7 @@ pub fn activity_file(directory:&Path)->Result { } pub fn lock_session(directory:&Path)->Result { let file=activity_file(directory)?;fs2::FileExt::try_lock_shared(&file).context("An update or installation is in progress")?; + ensure!(!directory.join("pending-uninstall").exists(),"Customer uninstall prevents new support sessions"); ensure!(!directory.join("pending-install.json").exists(),"Company installation requires recovery before support sessions can start"); ensure!(!directory.join("pending-update.json").exists(),"Software update requires recovery before support sessions can start");Ok(file) } diff --git a/product/agent/src/main.rs b/product/agent/src/main.rs index e269f6cfa50..2557311dacc 100644 --- a/product/agent/src/main.rs +++ b/product/agent/src/main.rs @@ -9,15 +9,44 @@ use swan_protocol::*; #[tokio::main] async fn main()->Result<()> { let args:Vec=std::env::args().collect(); - let directory=state_directory(); let command=args.get(1).map(String::as_str).unwrap_or("help"); + let directory=state_directory(); + #[cfg(windows)] + let directory=if command=="recover-update-task" { + let executable=std::env::current_exe()?; + let updates=executable.parent().and_then(|folder|folder.parent()).context("Missing staged updates directory")?; + ensure!(updates.file_name().is_some_and(|name|name.to_string_lossy().eq_ignore_ascii_case("updates")),"Recovery task is outside staged updates"); + updates.parent().context("Missing company recovery directory")?.to_owned() + }else{directory}; match command { "rollback-protocol"=>println!("1"), #[cfg(windows)] + "cancel-update-recovery"|"restore-update-recovery"=>{ + let executable=std::env::current_exe()?; + ensure!(executable.file_name().is_some_and(|name|name.to_string_lossy().eq_ignore_ascii_case("swan-agent.exe")),"Uninstall cancellation requires the installed agent"); + let installed_directory=executable.parent().context("Missing installed agent directory")?; + let script=installed_directory.join("Update-UninstallCancellation.ps1"); + std::fs::write(&script,include_str!("../../../deployment/windows/Update-UninstallCancellation.ps1"))?; + let status=swan_agent::update::powershell_command()?.args(["-NoLogo","-NoProfile","-NonInteractive","-ExecutionPolicy","Bypass","-File"]).arg(script).arg("-Directory").arg(installed_directory) + .arg("-Operation").arg(if command=="cancel-update-recovery" {"cancel"}else{"restore"}).status()?; + ensure!(status.success(),"Uninstall update cancellation failed"); + } + #[cfg(windows)] + "recover-update-task"=>{ + println!("Recovery task handed off: {}",AgentState::recover_from_staged_task(&directory).await?); + } + #[cfg(windows)] "remove-configuration-task"|"restore-configuration-task"=>{ let executable=std::env::current_exe()?; let installed_directory=executable.parent().context("Missing installed agent directory")?; ensure!(executable.file_name().is_some_and(|name|name.to_string_lossy().eq_ignore_ascii_case("swan-agent.exe")),"Uninstall cleanup requires the installed configuration agent"); + let cancellation_script=installed_directory.join("Update-UninstallCancellation.ps1"); + std::fs::write(&cancellation_script,include_str!("../../../deployment/windows/Update-UninstallCancellation.ps1"))?; + let cancellation=|operation:&str|->Result<()> { + let status=swan_agent::update::powershell_command()?.args(["-NoLogo","-NoProfile","-NonInteractive","-ExecutionPolicy","Bypass","-File"]).arg(&cancellation_script).arg("-Directory").arg(installed_directory).arg("-Operation").arg(operation).status()?; + ensure!(status.success(),"Uninstall recovery cancellation failed");Ok(()) + }; + if command=="remove-configuration-task" {cancellation("cancel")?;} // MSI invokes the installed binary under SYSTEM before removing it. // The script checks the exact task executable, arguments and owner. let (name,contents)=if command=="remove-configuration-task" { @@ -28,6 +57,7 @@ async fn main()->Result<()> { let status=swan_agent::update::powershell_command()?.args(["-NoLogo","-NoProfile","-NonInteractive","-ExecutionPolicy","Bypass","-File"]).arg(&script).arg("-Directory").arg(installed_directory).status()?; std::fs::remove_file(&script)?; ensure!(status.success(),"Company configuration task cleanup failed"); + if command=="restore-configuration-task" {cancellation("restore")?;} } "setup"=>{ let file=args.get(2).context("Usage: swan-agent setup bootstrap.json --accept-company")?; diff --git a/product/agent/src/update.rs b/product/agent/src/update.rs index 3b1ff79409b..60f313f7fac 100644 --- a/product/agent/src/update.rs +++ b/product/agent/src/update.rs @@ -408,6 +408,7 @@ fn supports_rollback(previous:&Release,failed:&Release)->bool { #[cfg(windows)] fn rollback_release(state:&AgentState,directory:&Path,folder:&Path,receipt:&Receipt)->Result<()> { + ensure!(!directory.join("pending-uninstall").exists(),"Explicit uninstall cancels automatic rollback"); ensure!(receipt.rollback_protocol==1 && receipt.phase=="rolling_back","Rollback was not durably prepared"); ensure!(state.last_release_sequence==receipt.previous_sequence,"Cannot roll back a committed newer installation"); let previous=receipt.previous_release.as_ref().context("Rollback requires a signed previous release")?; @@ -503,13 +504,27 @@ fn prepare_rollback_snapshot(state:&AgentState,directory:&Path,update:&Release)- result?;Ok(envelope) } +#[cfg(windows)] +fn recovery_task(directory:&Path,release:&Release,operation:&str)->Result<()> { + ensure!(matches!(operation,"register"|"remove"),"Unknown recovery task operation"); + let folder=directory.join("updates").join(release.sequence.to_string()); + let script=folder.join("Update-RecoveryTask.ps1"); + std::fs::write(&script,include_str!("../../../deployment/windows/Update-RecoveryTask.ps1"))?; + let status=powershell_command()?.args(["-NoLogo","-NoProfile","-NonInteractive","-ExecutionPolicy","Bypass","-File"]).arg(script) + .arg("-Operation").arg(operation).arg("-Directory").arg(directory).arg("-Edition").arg(if release.edition==Edition::Customer {"customer"}else{"technician"}) + .arg("-Sequence").arg(release.sequence.to_string()).status()?; + ensure!(status.success(),"Durable update recovery task operation failed");Ok(()) +} + #[cfg(windows)] fn launch_update_helper(directory:&Path,release:&Release)->Result<()> { + ensure!(!directory.join("pending-uninstall").exists(),"Explicit uninstall cancels automatic update handoff"); use std::os::windows::process::CommandExt; let folder=directory.join("updates").join(release.sequence.to_string()); let helper=folder.join("swan-agent.exe"); ensure!(digest(std::fs::read(&helper)?).eq_ignore_ascii_case(&release.agent_sha256),"Staged update helper hash mismatch"); let mut agent_release=release.clone();agent_release.sha256=release.agent_sha256.clone();verify_publisher(&helper,&agent_release)?; + recovery_task(directory,release,"register")?; use std::io::Write; let attempt=folder.join(format!("last-attempt-{}.tmp",random_token())); let mut file=std::fs::OpenOptions::new().create_new(true).write(true).open(&attempt)?; @@ -523,6 +538,32 @@ fn launch_update_helper(directory:&Path,release:&Release)->Result<()> { } impl AgentState { + #[cfg(windows)] + pub async fn recover_from_staged_task(directory:&Path)->Result { + let check_directory=directory.to_owned(); + let pending=tokio::task::spawn_blocking(move ||->Result> { + let state=AgentState::load_for_refresh(&check_directory)?; + let helper=std::env::current_exe()?; + let folder=helper.parent().context("Missing staged recovery folder")?; + let sequence=folder.file_name().context("Missing recovery sequence")?.to_string_lossy().parse::()?; + ensure!(sequence>0 && helper==check_directory.join("updates").join(sequence.to_string()).join("swan-agent.exe"),"Recovery task must run from its exact staged helper"); + let receipt_path=check_directory.join("pending-update.json"); + if !receipt_path.exists() || check_directory.join("pending-uninstall").exists(){ + let installed:SignedEnvelope=serde_json::from_slice(&std::fs::read(check_directory.join("installed-release.json"))?)?; + let mut release:Release=installed.verify(&public_key(&state.bootstrap.release_public_key)?)?; + ensure!(release.edition==state.bootstrap.edition,"Installed release belongs to another edition"); + release.sequence=sequence;recovery_task(&check_directory,&release,"remove")?;return Ok(None); + } + let receipt:Receipt=serde_json::from_slice(&std::fs::read(receipt_path)?)?; + let release=validate_pending_receipt(&state,&receipt)?; + ensure!(release.sequence==sequence,"Another update owns the pending receipt"); + ensure!(digest(std::fs::read(&helper)?).eq_ignore_ascii_case(&release.agent_sha256),"Recovery task helper differs from signed receipt"); + let mut agent_release=release;agent_release.sha256=agent_release.agent_sha256.clone();verify_publisher(&helper,&agent_release)?; + Ok(Some(state)) + }).await.context("Staged recovery task verification failed")??; + let Some(state)=pending else{return Ok(false);}; + state.resume_pending_update_online(directory,false).await + } pub async fn approved_update(&self,directory:&Path,technician_token:Option<&str>)->Result> { let profile=self.company_profile()?; if !update_policy_open(self)?{return Ok(None);} @@ -565,10 +606,15 @@ impl AgentState { save_installed_metadata(directory,envelope)?; latest.last_release_sequence=release.sequence;latest.save(directory)?; if setup_marker.exists(){std::fs::remove_file(setup_marker)?;} + let uninstall_marker=directory.join("pending-uninstall"); + if uninstall_marker.exists(){std::fs::remove_file(uninstall_marker)?;} + let cancellation_rollback=directory.join("uninstall-cancellation-rollback.json"); + if cancellation_rollback.exists(){std::fs::remove_file(cancellation_rollback)?;} *self=latest;Ok(()) } #[cfg(windows)] pub fn recover_update(&mut self,directory:&Path)->Result<()> { + ensure!(!directory.join("pending-uninstall").exists(),"Explicit uninstall cancels automatic recovery"); let activity=activity_file(directory)?;fs2::FileExt::try_lock_exclusive(&activity).context("Recovery requires all sessions to close")?; let receipt_path=directory.join("pending-update.json"); let receipt:Receipt=serde_json::from_slice(&std::fs::read(&receipt_path)?)?; @@ -593,6 +639,7 @@ impl AgentState { let activity=activity_file(directory)?;fs2::FileExt::try_lock_exclusive(&activity).context("Update recovery already running or sessions remain active")?; let receipt_path=directory.join("pending-update.json"); if !receipt_path.exists(){return Ok(false);} + ensure!(!directory.join("pending-uninstall").exists(),"Explicit uninstall cancels automatic update recovery"); ensure!(!directory.join("pending-install.json").exists(),"Company setup recovery must finish first"); let latest=AgentState::load_for_refresh(directory)?; let receipt:Receipt=serde_json::from_slice(&std::fs::read(&receipt_path)?)?; @@ -616,6 +663,7 @@ impl AgentState { fs2::FileExt::try_lock_exclusive(&activity).context("Update recovery already running or sessions remain active")?; let receipt_path=check_directory.join("pending-update.json"); if !receipt_path.exists(){return Ok(None);} + ensure!(!check_directory.join("pending-uninstall").exists(),"Explicit uninstall cancels automatic update recovery"); ensure!(!check_directory.join("pending-install.json").exists(),"Company setup recovery must finish first"); let latest=AgentState::load_for_refresh(&check_directory)?; let receipt:Receipt=serde_json::from_slice(&std::fs::read(receipt_path)?)?; @@ -654,6 +702,7 @@ impl AgentState { } let receipt_path=directory.join("pending-update.json"); if !receipt_path.exists(){return Ok(false);} + ensure!(!directory.join("pending-uninstall").exists(),"Explicit uninstall cancels automatic update recovery"); ensure!(!directory.join("pending-install.json").exists(),"Company setup recovery must finish first"); let mut receipt:Receipt=serde_json::from_slice(&std::fs::read(&receipt_path)?)?; let mut latest=AgentState::load_for_refresh(directory)?; @@ -721,6 +770,7 @@ impl AgentState { pub async fn update(&mut self,directory:&Path,technician_token:Option<&str>)->Result { #[cfg(not(windows))] {let _=(directory,technician_token);anyhow::bail!("Endpoint updates require Windows");} #[cfg(windows)] { + ensure!(!directory.join("pending-uninstall").exists(),"Explicit uninstall cancels automatic updates"); ensure!(!directory.join("pending-install.json").exists(),"Company setup recovery is required before automatic updates"); if directory.join("pending-update.json").exists(){ return self.resume_pending_update_online(directory,false).await; @@ -755,6 +805,7 @@ impl AgentState { let activity=activity_file(&directory)?; fs2::FileExt::try_lock_exclusive(&activity).context("Update deferred while a session or connection attempt is active")?; ensure!(!directory.join("pending-install.json").exists(),"Company setup started while the update was downloading"); + ensure!(!directory.join("pending-uninstall").exists(),"Explicit uninstall started while the update was downloading"); ensure!(!receipt_path.exists(),"Another update already requires recovery"); // Re-read consent and enrollment immediately before installation. let latest=AgentState::load(&directory)?; @@ -1035,8 +1086,13 @@ try {$stream.Lock(0,1);[Console]::WriteLine('locked');[Console]::ReadLine()|Out- assert!(require_existing_application(&target).is_err()); std::fs::write(&target,b"installed test fixture").unwrap(); assert!(require_existing_application(&target).is_ok()); + std::fs::write(folder.join("pending-uninstall"),b"explicit cancellation").unwrap(); + assert!(lock_session(&folder).is_err()); + std::fs::remove_file(folder.join("pending-uninstall")).unwrap(); + assert!(lock_session(&folder).is_ok()); std::fs::remove_file(&target).unwrap(); assert!(require_existing_application(&target).is_err()); + std::fs::remove_file(folder.join("activity.lock")).unwrap(); std::fs::remove_dir(folder).unwrap(); } #[test] From 2fe9349efeb304b29cf26c8f8a7eab0b62313a9c Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Fri, 2 Oct 2026 17:18:10 +0200 Subject: [PATCH 095/652] Prepare explicit setup after cancelled updates without losing recovery evidence --- deployment/windows/Install-Company.ps1 | 27 +++-- deployment/windows/UPDATE-RECOVERY.md | 19 +++- docs/ACCEPTANCE.md | 17 +++ product/agent/src/main.rs | 12 ++ product/agent/src/update.rs | 145 ++++++++++++++++++++++++- product/worker/src/main.rs | 1 + 6 files changed, 203 insertions(+), 18 deletions(-) diff --git a/deployment/windows/Install-Company.ps1 b/deployment/windows/Install-Company.ps1 index f84eb3a1bdc..677e132b5d4 100644 --- a/deployment/windows/Install-Company.ps1 +++ b/deployment/windows/Install-Company.ps1 @@ -44,7 +44,17 @@ $recordArguments = @('record-installation',(Join-Path $root 'release.json'),$ins if ($Repair) { $packageArguments += '--repair'; $recordArguments += '--repair' } & $agent @packageArguments if ($LASTEXITCODE -ne 0) { throw 'Installer metadata, hash or publisher validation failed.' } -$msiOperation = if ($Repair) { '/fvamus' } else { '/i' } +$prepareArguments = @('prepare-installation',(Join-Path $root 'release.json'),$installers[0].FullName) +if ($Repair) { $prepareArguments += '--repair' } +& $agent @prepareArguments +if ($LASTEXITCODE -ne 0) { throw 'Cannot prepare explicit installation or preserve cancelled update evidence.' } +$systemDirectory = [Environment]::GetFolderPath('System') +$msiExecutable = Join-Path $systemDirectory 'msiexec.exe' +$msiOperation = '/i' +if ($installers[0].Extension -eq '.msi') { + $msiOperation = (& (Join-Path $systemDirectory 'WindowsPowerShell/v1.0/powershell.exe') -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -File (Join-Path $root 'Get-MsiInstallMode.ps1') -Package $installers[0].FullName | Out-String).Trim() + if ($LASTEXITCODE -ne 0 -or $msiOperation -notin @('/i','/fvamus')) { throw 'Cannot select a safe MSI installation or repair mode.' } +} $installationMarker = Join-Path $editionDirectory 'pending-install.json' $activityStream = [IO.File]::Open((Join-Path $editionDirectory 'activity.lock'),[IO.FileMode]::OpenOrCreate,[IO.FileAccess]::ReadWrite,[IO.FileShare]::ReadWrite) $activityLocked = $false @@ -53,18 +63,15 @@ try { # Refuse active sessions; do not terminate them to perform setup. $activityStream.Lock(0,1) $activityLocked = $true - $markerStream = [IO.File]::Open($installationMarker,[IO.FileMode]::Create,[IO.FileAccess]::Write,[IO.FileShare]::Read) - try { - $markerBytes = [IO.File]::ReadAllBytes((Join-Path $root 'release.json')) - $markerStream.Write($markerBytes,0,$markerBytes.Length) - $markerStream.Flush($true) - } finally { $markerStream.Dispose() } + $prepared = Get-Content -LiteralPath $installationMarker -Raw | ConvertFrom-Json + $requested = Get-Content -LiteralPath (Join-Path $root 'release.json') -Raw | ConvertFrom-Json + if ($prepared.payload -cne $requested.payload -or $prepared.signature -cne $requested.signature) { throw 'Another company setup owns the installation recovery marker.' } if ($bootstrap.edition -eq 'customer') { $configurationTask = @(Get-ScheduledTask | Where-Object { $_.TaskName -eq 'Swan Company Configuration' }) if ($configurationTask.Count -gt 1) { throw 'Ambiguous existing configuration task.' } if ($configurationTask.Count -eq 1) { $expectedAgent = [IO.Path]::GetFullPath((Join-Path $editionDirectory 'swan-agent.exe')) - if ($configurationTask[0].Actions.Count -ne 1 -or [IO.Path]::GetFullPath($configurationTask[0].Actions[0].Execute) -ine $expectedAgent -or $configurationTask[0].Actions[0].Arguments -cne 'watch') { throw 'Existing configuration task does not match this installation.' } + if ($configurationTask[0].Actions.Count -ne 1 -or [IO.Path]::GetFullPath($configurationTask[0].Actions[0].Execute) -ine $expectedAgent -or $configurationTask[0].Actions[0].Arguments -cne 'watch' -or $configurationTask[0].Principal.UserId -notin @('SYSTEM','S-1-5-18','NT AUTHORITY\SYSTEM')) { throw 'Existing configuration task does not match this installation.' } $configurationTask[0] | Disable-ScheduledTask | Out-Null $configurationTask[0] | Stop-ScheduledTask $stopDeadline = [DateTime]::UtcNow.AddSeconds(10) @@ -74,7 +81,7 @@ if ($bootstrap.edition -eq 'customer') { } } if ($installers[0].Extension -eq '.msi') { - $process = Start-Process -FilePath 'msiexec.exe' -ArgumentList @($msiOperation,('"' + $installers[0].FullName + '"'),'/passive','/norestart') -PassThru -Wait -WindowStyle Hidden + $process = Start-Process -FilePath $msiExecutable -ArgumentList @($msiOperation,('"' + $installers[0].FullName + '"'),'/passive','/norestart') -PassThru -Wait -WindowStyle Hidden } else { $process = Start-Process -FilePath $installers[0].FullName -ArgumentList '--silent-install','printer=0' -PassThru -Wait -WindowStyle Hidden } @@ -93,7 +100,7 @@ if ($bootstrap.edition -eq 'customer') { $release = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($releaseEnvelope.payload)) | ConvertFrom-Json $portable = Join-Path $editionDirectory 'SwanRemoteSupport-Technician.exe' if ($release.format -eq 'msi') { - $process = Start-Process -FilePath 'msiexec.exe' -ArgumentList @($msiOperation,('"' + $installers[0].FullName + '"'),'/passive','/norestart',('INSTALLFOLDER="' + $editionDirectory + '"')) -PassThru -Wait -WindowStyle Hidden + $process = Start-Process -FilePath $msiExecutable -ArgumentList @($msiOperation,('"' + $installers[0].FullName + '"'),'/passive','/norestart',('INSTALLFOLDER="' + $editionDirectory + '"')) -PassThru -Wait -WindowStyle Hidden if ($process.ExitCode -notin @(0,3010)) { throw "Technician MSI installation failed ($($process.ExitCode))." } if (-not (Test-Path -LiteralPath $portable)) { throw 'Technician MSI did not install the expected executable.' } } elseif ($release.format -eq 'exe' -and $release.sha256 -ceq $release.installed_sha256) { diff --git a/deployment/windows/UPDATE-RECOVERY.md b/deployment/windows/UPDATE-RECOVERY.md index 64ded8b32cd..2babb0719c7 100644 --- a/deployment/windows/UPDATE-RECOVERY.md +++ b/deployment/windows/UPDATE-RECOVERY.md @@ -28,9 +28,16 @@ new sessions and automatic recovery. Paired uninstall rollback restores the previous cancellation state, including an earlier cancellation. Software rollback narrowly uninstalls its verified failed MSI with `SWAN_RECOVERY=1`, excluding user-uninstall cancellation actions. Only explicit verified setup or -repair clears the cancellation marker. A cancelled pending update receipt may -still require additional explicit setup/recovery handling; this is not yet a -verified uninstall/reinstall workflow. +repair clears the cancellation marker. Explicit setup now verifies its incoming +package again under the activity lock, publishes a setup marker without +overwriting a different interrupted setup, and archives the exact original +cancelled receipt at `updates//cancelled-update.json`. An unchanged +archive permits interrupted preparation to retry; a conflicting archive fails +closed. Cancellation and setup markers keep sessions blocked until full installed +identity verification completes. A setup interrupted after committing its exact +installed metadata may finish only that same signed setup; ordinary sequence +replay rejection resumes when the marker is gone. This implements the transition +but does not yet demonstrate native uninstall/reinstall. For diagnostics, `Update-RecoveryTask.ps1 -InspectOnly` reads task ownership and outputs a proposed identity without registering or removing a task. It does not @@ -38,6 +45,12 @@ prove executable trust or successful registration. `test_update_recovery_task.ps tests pure ownership rejection and cancellation files in an isolated temporary directory, without native task changes or installer execution. +The company bundle includes the fixed read-only MSI mode inspector. Setup selects +repair for the exact already-registered product and installation for an unknown +product. Both the updater and setup resolve Windows Installer from the OS system +directory. This does not authorize another edition, publisher, version or package: +signed metadata, artifact identity and package checks still precede installation. + ## Verification boundary Windows agent/worker compilation, agent component tests, read-only task planning, diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index f0a43fa8b44..211e24f3c97 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -666,3 +666,20 @@ mutation ran on this host. Signed task registration, startup/logon, reboot, explicit cancelled-update reinstall handling and customer EXE rollback still need implementation/verification as applicable; the full clean Windows matrix and current native build remain gates. + +Explicit setup now handles cancelled pending updates after incoming package +validation: it takes the activity lock, atomically prepares an immutable setup +marker and archives the exact old receipt without changing identity, consent or +sequence. Active recovery, tampered/foreign metadata, sequence replay, +conflicting archives and competing setup markers are rejected. Identical +preparation retries preserve evidence; exact committed setup metadata can finish +interrupted marker cleanup without allowing ordinary replay. The wrapper checks +the prepared marker instead of truncating it. A Windows component rehearsal +passes actual hash/publisher verification using a copied Windows-signed OS +executable, without executing it; bad bytes leave both pending evidence and +setup state unchanged. Targeted signed-recovery regressions, script parsing and +Windows agent/worker compilation pass. The MSI mode inspector reads an actual +unsigned uninstalled technician package and selects installation without running +it. Company bundles now include the inspector, and installer execution resolves +the OS system directory. Actual Swan-signed setup, MSI repair, cancelled-update +uninstall/reinstall and complete clean Windows acceptance remain unverified. diff --git a/product/agent/src/main.rs b/product/agent/src/main.rs index 2557311dacc..06e9bf485ec 100644 --- a/product/agent/src/main.rs +++ b/product/agent/src/main.rs @@ -134,6 +134,18 @@ async fn main()->Result<()> { println!("Installed release verified: {}",installed); } #[cfg(windows)] + "prepare-installation"=>{ + let file=PathBuf::from(args.get(2).context("Usage: swan-agent prepare-installation release.json INSTALLER [--repair]")?); + let package=PathBuf::from(args.get(3).context("Missing original installer path")?); + let prepare_directory=directory.clone();let repair=args.iter().any(|value|value=="--repair"); + tokio::task::spawn_blocking(move ||->Result<()> { + let envelope:SignedEnvelope=serde_json::from_slice(&std::fs::read(file)?)?; + let state=AgentState::load(&prepare_directory)?; + state.prepare_installation(&prepare_directory,&envelope,&package,repair) + }).await.context("Explicit installation preparation failed")??; + println!("Verified setup prepared; any cancelled update evidence was retained."); + } + #[cfg(windows)] "record-installation"=>{ let file=PathBuf::from(args.get(2).context("Usage: swan-agent record-installation release.json INSTALLER [--repair]")?); let package=PathBuf::from(args.get(3).context("Missing original installer path")?); diff --git a/product/agent/src/update.rs b/product/agent/src/update.rs index 60f313f7fac..787684c3957 100644 --- a/product/agent/src/update.rs +++ b/product/agent/src/update.rs @@ -57,10 +57,16 @@ pub(crate) fn validate_recovery_release(state:&AgentState,envelope:&SignedEnvelo Ok(release) } -#[cfg(windows)] +#[cfg(any(windows,test))] fn installation_release(state:&AgentState,directory:&Path,envelope:&SignedEnvelope,repair:bool)->Result { - ensure!(!directory.join("pending-update.json").exists(),"Resolve pending update recovery before setup or repair"); - if repair { + cancelled_pending_update(state,directory)?; + let setup_marker=directory.join("pending-install.json"); + let completion=if !repair && setup_marker.exists() { + let marker:SignedEnvelope=serde_json::from_slice(&std::fs::read(setup_marker)?)?; + let requested:Release=envelope.verify(&public_key(&state.bootstrap.release_public_key)?)?; + marker.payload==envelope.payload && marker.signature==envelope.signature && requested.sequence==state.last_release_sequence + }else{false}; + if repair || completion { let installed:SignedEnvelope=serde_json::from_slice(&std::fs::read(directory.join("installed-release.json"))?)?; validate_repair_release(state,envelope,&installed) }else{ @@ -70,6 +76,50 @@ fn installation_release(state:&AgentState,directory:&Path,envelope:&SignedEnvelo } } +#[cfg(any(windows,test))] +fn cancelled_pending_update(state:&AgentState,directory:&Path)->Result)>> { + let bytes=match std::fs::read(directory.join("pending-update.json")) { + Ok(bytes)=>bytes, + Err(error) if error.kind()==std::io::ErrorKind::NotFound=>return Ok(None), + Err(error)=>return Err(error).context("Cannot inspect pending update for explicit setup"), + }; + ensure!(directory.join("pending-uninstall").exists(),"Resolve active update recovery before explicit setup"); + let receipt:Receipt=serde_json::from_slice(&bytes)?; + validate_pending_receipt(state,&receipt)?; + Ok(Some((receipt,bytes))) +} + +#[cfg(any(windows,test))] +fn archive_cancelled_update(state:&AgentState,directory:&Path)->Result<()> { + let Some((receipt,bytes))=cancelled_pending_update(state,directory)? else{return Ok(());}; + let release=validate_pending_receipt(state,&receipt)?; + let folder=directory.join("updates").join(release.sequence.to_string()); + std::fs::create_dir_all(&folder)?; + let archive=folder.join("cancelled-update.json"); + match std::fs::read(&archive) { + Ok(saved)=>ensure!(saved==bytes,"Cancelled update archive differs; refuse to overwrite evidence"), + Err(error) if error.kind()==std::io::ErrorKind::NotFound=>publish_initial_bytes(&archive,&bytes)?, + Err(error)=>return Err(error).context("Cannot inspect cancelled update archive"), + } + ensure!(directory.join("pending-uninstall").exists() && std::fs::read(directory.join("pending-update.json"))?==bytes,"Cancellation changed while preparing setup"); + std::fs::remove_file(directory.join("pending-update.json"))?; + Ok(()) +} + +#[cfg(any(windows,test))] +fn prepare_installation_marker(directory:&Path,envelope:&SignedEnvelope)->Result<()> { + let path=directory.join("pending-install.json"); + match std::fs::read(&path) { + Ok(bytes)=>{ + let stored:SignedEnvelope=serde_json::from_slice(&bytes)?; + ensure!(stored.payload==envelope.payload && stored.signature==envelope.signature,"Another company setup must be recovered first"); + Ok(()) + }, + Err(error) if error.kind()==std::io::ErrorKind::NotFound=>publish_initial_bytes(&path,&serde_json::to_vec(envelope)?), + Err(error)=>Err(error).context("Cannot inspect company setup recovery marker"), + } +} + #[cfg(any(windows,test))] fn validate_pending_receipt(state:&AgentState,receipt:&Receipt)->Result { ensure!(receipt.rollback_protocol<=1,"Unsupported pending rollback protocol"); @@ -138,11 +188,16 @@ fn publish_download(path:&Path,bytes:&[u8])->Result<()> { #[cfg(any(windows,test))] fn publish_initial_receipt(path:&Path,receipt:&Receipt)->Result<()> { + publish_initial_bytes(path,&serde_json::to_vec(receipt)?) +} + +#[cfg(any(windows,test))] +fn publish_initial_bytes(path:&Path,bytes:&[u8])->Result<()> { use std::io::Write; let temporary=path.parent().context("Missing receipt directory")?.join(format!("swan-receipt-{}.tmp",random_token())); let mut file=std::fs::OpenOptions::new().create_new(true).write(true).open(&temporary)?; let result=(||->Result<()> { - file.write_all(&serde_json::to_vec(receipt)?)?;file.sync_all()?;drop(file); + file.write_all(bytes)?;file.sync_all()?;drop(file); #[cfg(windows)] { use std::os::windows::ffi::OsStrExt; use windows_sys::Win32::Storage::FileSystem::{MoveFileExW,MOVEFILE_WRITE_THROUGH}; @@ -291,7 +346,7 @@ fn msi_install_command(package:&Path,release:&Release)->ResultResult<()> { } impl AgentState { + #[cfg(windows)] + pub fn prepare_installation(&self,directory:&Path,envelope:&SignedEnvelope,package:&Path,repair:bool)->Result<()> { + let activity=activity_file(directory)?;fs2::FileExt::try_lock_exclusive(&activity).context("Explicit setup requires all sessions and update helpers to close")?; + let state=AgentState::load(directory)?; + if repair {verify_repair_package(&state,directory,envelope,package)?;}else{verify_package(&state,directory,envelope,package)?;} + prepare_installation_marker(directory,envelope)?; + archive_cancelled_update(&state,directory)?; + Ok(()) + } #[cfg(windows)] pub async fn recover_from_staged_task(directory:&Path)->Result { let check_directory=directory.to_owned(); @@ -891,6 +955,25 @@ pub(crate) fn test_failed_release_quarantine(state:&AgentState,release:&Release, installer.sha256=digest(std::fs::read(&source).unwrap()); retain_installer(&source,&target,&installer).unwrap(); assert!(staged_hash_matches(&target,&installer.sha256).unwrap()); + let preparation=folder.join("verified-explicit-preparation");state.save(&preparation).unwrap(); + let initial_state=std::fs::read(preparation.join("managed-state.json")).unwrap(); + let mut cancelled_release=release.clone();cancelled_release.sequence=2; + let cancelled=Receipt{release:SignedEnvelope::sign(&cancelled_release,key).unwrap(),previous_sequence:1,phase:"installing".into(),previous_release:Some(SignedEnvelope::sign(&previous,key).unwrap()),rollback_protocol:1}; + let pending_bytes=serde_json::to_vec_pretty(&cancelled).unwrap(); + std::fs::write(preparation.join("pending-update.json"),&pending_bytes).unwrap(); + std::fs::write(preparation.join("pending-uninstall"),b"explicit fixture cancellation").unwrap(); + let mut requested=installer.clone();requested.sequence=3; + let mut invalid=requested.clone();invalid.sha256=digest(b"wrong verified setup bytes"); + assert!(state.prepare_installation(&preparation,&SignedEnvelope::sign(&invalid,key).unwrap(),&source,false).is_err()); + assert!(!preparation.join("pending-install.json").exists()); + assert_eq!(std::fs::read(preparation.join("pending-update.json")).unwrap(),pending_bytes); + let envelope=SignedEnvelope::sign(&requested,key).unwrap(); + state.prepare_installation(&preparation,&envelope,&source,false).unwrap(); + state.prepare_installation(&preparation,&envelope,&source,false).unwrap(); + assert!(!preparation.join("pending-update.json").exists()); + assert_eq!(std::fs::read(preparation.join("updates/2/cancelled-update.json")).unwrap(),pending_bytes); + assert!(preparation.join("pending-uninstall").exists() && preparation.join("pending-install.json").exists()); + assert_eq!(std::fs::read(preparation.join("managed-state.json")).unwrap(),initial_state); let mut wrong_publisher=installer.clone();wrong_publisher.publisher="Unapproved publisher".into(); assert!(retain_installer(&source,&target,&wrong_publisher).is_err()); assert!(staged_hash_matches(&target,&installer.sha256).unwrap(),"Publisher rejection retains the previous complete cache"); @@ -946,6 +1029,58 @@ pub(crate) fn test_failed_release_quarantine(state:&AgentState,release:&Release, std::fs::write(&path,b"interrupted metadata").unwrap();assert!(failed_release_sequence(state,&folder).is_err()); std::fs::remove_file(&path).unwrap();std::fs::create_dir(&path).unwrap(); assert!(failed_release_sequence(state,&folder).is_err(),"Quarantine I/O failures must not clear suppression"); + let cancellation_folder=folder.join("explicit-cancelled-setup");std::fs::create_dir(&cancellation_folder).unwrap(); + state.save(&cancellation_folder).unwrap(); + let original_state=std::fs::read(cancellation_folder.join("managed-state.json")).unwrap(); + let mut cancelled=receipt.clone();cancelled.phase="installing".into(); + let cancelled_bytes=serde_json::to_vec_pretty(&cancelled).unwrap(); + let cancelled_path=cancellation_folder.join("pending-update.json"); + std::fs::write(&cancelled_path,&cancelled_bytes).unwrap(); + let mut setup=release.clone();setup.sequence=3; + let setup_envelope=SignedEnvelope::sign(&setup,key).unwrap(); + assert!(installation_release(state,&cancellation_folder,&setup_envelope,false).is_err(),"Active recovery must not become implicit setup"); + assert!(archive_cancelled_update(state,&cancellation_folder).is_err()); + std::fs::write(cancellation_folder.join("pending-uninstall"),b"explicit user cancellation").unwrap(); + assert!(installation_release(state,&cancellation_folder,&setup_envelope,false).is_ok()); + let mut repair=previous.clone();repair.expires_at=now()+3600; + let repair_envelope=SignedEnvelope::sign(&repair,key).unwrap(); + std::fs::write(cancellation_folder.join("installed-release.json"),serde_json::to_vec(&repair_envelope).unwrap()).unwrap(); + assert!(installation_release(state,&cancellation_folder,&repair_envelope,true).is_ok()); + assert!(installation_release(state,&cancellation_folder,&repair_envelope,false).is_err(),"Cancellation cannot bypass sequence replay protection"); + let foreign=SignedEnvelope::sign(&setup,&SigningKey::from_bytes(&[9;32])).unwrap(); + assert!(installation_release(state,&cancellation_folder,&foreign,false).is_err()); + let mut tampered=cancelled.clone();tampered.release.signature=STANDARD.encode([0u8;64]); + std::fs::write(&cancelled_path,serde_json::to_vec(&tampered).unwrap()).unwrap(); + assert!(installation_release(state,&cancellation_folder,&setup_envelope,false).is_err()); + assert!(archive_cancelled_update(state,&cancellation_folder).is_err()); + std::fs::write(&cancelled_path,&cancelled_bytes).unwrap(); + prepare_installation_marker(&cancellation_folder,&setup_envelope).unwrap(); + let marker_bytes=std::fs::read(cancellation_folder.join("pending-install.json")).unwrap(); + let mut competing=setup.clone();competing.sequence=4; + assert!(prepare_installation_marker(&cancellation_folder,&SignedEnvelope::sign(&competing,key).unwrap()).is_err()); + assert_eq!(std::fs::read(cancellation_folder.join("pending-install.json")).unwrap(),marker_bytes); + prepare_installation_marker(&cancellation_folder,&setup_envelope).unwrap(); + archive_cancelled_update(state,&cancellation_folder).unwrap(); + let archive=cancellation_folder.join("updates/2/cancelled-update.json"); + assert_eq!(std::fs::read(&archive).unwrap(),cancelled_bytes); + assert!(!cancelled_path.exists()); + assert!(cancellation_folder.join("pending-uninstall").exists() && lock_session(&cancellation_folder).is_err()); + std::fs::write(&cancelled_path,&cancelled_bytes).unwrap(); + archive_cancelled_update(state,&cancellation_folder).unwrap(); + let mut changed=cancelled.clone();changed.phase="rolling_back".into(); + std::fs::write(&cancelled_path,serde_json::to_vec(&changed).unwrap()).unwrap(); + assert!(archive_cancelled_update(state,&cancellation_folder).is_err(),"Cancelled evidence cannot be overwritten on retry"); + assert_eq!(std::fs::read(&archive).unwrap(),cancelled_bytes); + assert_eq!(std::fs::read(cancellation_folder.join("managed-state.json")).unwrap(),original_state,"Explicit preparation preserves identity, consent and sequence"); + std::fs::remove_file(&cancelled_path).unwrap(); + std::fs::write(cancellation_folder.join("installed-release.json"),serde_json::to_vec(&setup_envelope).unwrap()).unwrap(); + let mut completed=state.clone();completed.last_release_sequence=setup.sequence; + assert!(installation_release(&completed,&cancellation_folder,&setup_envelope,false).is_ok(),"Setup may finish exact committed metadata after marker cleanup was interrupted"); + let mut altered=setup.clone();altered.version="99.0.0".into(); + assert!(installation_release(&completed,&cancellation_folder,&SignedEnvelope::sign(&altered,key).unwrap(),false).is_err()); + std::fs::remove_file(cancellation_folder.join("pending-install.json")).unwrap(); + assert!(installation_release(&completed,&cancellation_folder,&setup_envelope,false).is_err(),"Absent completion marker restores ordinary sequence rejection"); + assert!(installation_release(&completed,&cancellation_folder,&setup_envelope,true).is_ok()); std::fs::remove_dir_all(folder).unwrap(); } diff --git a/product/worker/src/main.rs b/product/worker/src/main.rs index af044cda668..cbcca9b7cd3 100644 --- a/product/worker/src/main.rs +++ b/product/worker/src/main.rs @@ -102,6 +102,7 @@ fn package_verified(output:PathBuf,id:&str,work:PathBuf,installer:PathBuf,agent: ("company-profile.json",serde_json::to_vec_pretty(&profile_envelope)?), ("release.json",serde_json::to_vec_pretty(&release_envelope)?), ("Install-Company.ps1",bundle_text(include_bytes!("../../../deployment/windows/Install-Company.ps1"))?), + ("Get-MsiInstallMode.ps1",bundle_text(include_bytes!("../../../deployment/windows/Get-MsiInstallMode.ps1"))?), ("Open-Technician.ps1",bundle_text(include_bytes!("../../../deployment/windows/Open-Technician.ps1"))?), ("Verify-Package.ps1",bundle_text(include_bytes!("../../../deployment/windows/Verify-Package.ps1"))?), ("LICENSE.txt",bundle_text(include_bytes!("../../../LICENCE"))?), From 215dfef3fc80799f7077cfe314ecad7b1c1ebc30 Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Fri, 2 Oct 2026 17:52:02 +0200 Subject: [PATCH 096/652] Restore customer EXE releases and wait for silent installer completion --- .github/workflows/swan-windows-build.yml | 4 ++ deployment/windows/CUSTOMER-MSI.md | 5 +- .../windows/Cancel-CustomerExeRecovery.ps1 | 19 ++++++ deployment/windows/Restore-CustomerExe.ps1 | 32 ++++++++++ deployment/windows/UPDATE-RECOVERY.md | 19 ++++++ deployment/windows/test_msi_service_guard.ps1 | 4 +- docs/ACCEPTANCE.md | 20 ++++++ libs/portable/src/install_wait.rs | 35 +++++++++++ libs/portable/src/main.rs | 6 ++ product/agent/src/update.rs | 14 ++++- src/platform/windows.rs | 63 +++++++++++++------ 11 files changed, 196 insertions(+), 25 deletions(-) create mode 100644 deployment/windows/Cancel-CustomerExeRecovery.ps1 create mode 100644 deployment/windows/Restore-CustomerExe.ps1 create mode 100644 libs/portable/src/install_wait.rs diff --git a/.github/workflows/swan-windows-build.yml b/.github/workflows/swan-windows-build.yml index 2a74dd6f7eb..dc8b26850ac 100644 --- a/.github/workflows/swan-windows-build.yml +++ b/.github/workflows/swan-windows-build.yml @@ -172,6 +172,10 @@ jobs: 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.' } 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 diff --git a/deployment/windows/CUSTOMER-MSI.md b/deployment/windows/CUSTOMER-MSI.md index 3e078aa3193..e2b603db49d 100644 --- a/deployment/windows/CUSTOMER-MSI.md +++ b/deployment/windows/CUSTOMER-MSI.md @@ -82,8 +82,9 @@ only its owned SYSTEM configuration task, deferring startup until recovery finishes. Enrollment and consent files remain current. Unexpected registration, service or task ownership leaves recovery pending. A separate staged recovery task now implements startup and periodic retry across removal of the installed -agent; see [update recovery](UPDATE-RECOVERY.md). Customer EXE/service rollback -remains unfinished. This path has component and read-only planning evidence only; native +agent; see [update recovery](UPDATE-RECOVERY.md). A separate EXE path now +implements customer service restoration while refusing MSI-managed or unrelated +installations. These paths have component and read-only planning evidence only; native signed installation, removal, restore and restart remain acceptance gates. References: [WiX service installation](https://docs.firegiant.com/wix/schema/wxs/serviceinstall/) diff --git a/deployment/windows/Cancel-CustomerExeRecovery.ps1 b/deployment/windows/Cancel-CustomerExeRecovery.ps1 new file mode 100644 index 00000000000..e7373f53f75 --- /dev/null +++ b/deployment/windows/Cancel-CustomerExeRecovery.ps1 @@ -0,0 +1,19 @@ +$ErrorActionPreference='Stop' +$env:PSModulePath=Join-Path ([Environment]::GetFolderPath('System')) 'WindowsPowerShell\v1.0\Modules' +$directory=Join-Path ([Environment]::GetFolderPath('CommonApplicationData')) 'SwanRemoteSupport' +if (-not (Test-Path -LiteralPath (Join-Path $directory 'managed-state.json'))) { return } +$marker=Join-Path $directory 'pending-uninstall' +if (-not (Test-Path -LiteralPath $marker)) { + $stream=[IO.File]::Open($marker,[IO.FileMode]::CreateNew,[IO.FileAccess]::Write,[IO.FileShare]::None) + try { $stream.Flush($true) } finally { $stream.Dispose() } +} +$expected=[IO.Path]::GetFullPath((Join-Path $directory 'swan-agent.exe')) +$tasks=@(Get-ScheduledTask | Where-Object { $_.TaskName -eq 'Swan Company Configuration' }) +if ($tasks.Count -gt 1) { throw 'Ambiguous company configuration task.' } +if ($tasks.Count -eq 1) { + $task=$tasks[0] + if ($task.Actions.Count -ne 1 -or [IO.Path]::GetFullPath($task.Actions[0].Execute) -ine $expected -or $task.Actions[0].Arguments -cne 'watch' -or $task.Principal.UserId -notin @('SYSTEM','S-1-5-18','NT AUTHORITY\SYSTEM')) { throw 'Configuration task belongs to another installation.' } + $task | Disable-ScheduledTask | Out-Null + $task | Stop-ScheduledTask + $task | Unregister-ScheduledTask -Confirm:$false +} diff --git a/deployment/windows/Restore-CustomerExe.ps1 b/deployment/windows/Restore-CustomerExe.ps1 new file mode 100644 index 00000000000..e04e642a9dd --- /dev/null +++ b/deployment/windows/Restore-CustomerExe.ps1 @@ -0,0 +1,32 @@ +[CmdletBinding()] +param([Parameter(Mandatory=$true)][string]$Package,[Parameter(Mandatory=$true)][string]$Directory) +$ErrorActionPreference='Stop' +function Assert-CustomerServiceRecord($Service,[string]$Expected,[bool]$Required) { + if ($Service.PathName -ine $Expected -or $Service.StartName -ne 'LocalSystem') { throw 'Customer service belongs to another installation.' } + if ($Required -and ($Service.State -ne 'Running' -or $Service.StartMode -ne 'Auto')) { throw 'Restored customer service is not running automatically.' } +} +function Assert-Service([bool]$Required) { + $services=@(Get-CimInstance -ClassName Win32_Service -Filter "Name='Swan Remote Support'") + if ($services.Count -eq 0 -and -not $Required) { return } + if ($services.Count -ne 1) { throw 'Expected exactly one customer service.' } + $expected='"'+[IO.Path]::GetFullPath((Join-Path $Directory 'Swan Remote Support.exe'))+'" --service' + Assert-CustomerServiceRecord $services[0] $expected $Required +} +$installer=New-Object -ComObject WindowsInstaller.Installer +if (@($installer.RelatedProducts('{32A585D7-9A78-4AD2-AF72-D0266EFC709D}')).Count -ne 0) { throw 'EXE rollback cannot modify an MSI-managed installation.' } +if (Test-Path -LiteralPath 'HKLM:\Software\SwanRemoteSupport\Customer') { + $marker=Get-ItemProperty -LiteralPath 'HKLM:\Software\SwanRemoteSupport\Customer' + if ($null -ne $marker.PSObject.Properties['ProductCode']) { throw 'EXE rollback cannot modify an MSI registration marker.' } +} +$uninstallKey='HKLM:\Software\Microsoft\Windows\CurrentVersion\Uninstall\Swan Remote Support' +if (Test-Path -LiteralPath $uninstallKey) { + $registered=Get-ItemProperty -LiteralPath $uninstallKey + $expected='"'+[IO.Path]::GetFullPath((Join-Path $Directory 'Swan Remote Support.exe'))+'" --uninstall' + if ($registered.WindowsInstaller -eq 1 -or $registered.UninstallString -ine $expected -or [IO.Path]::GetFullPath($registered.InstallLocation) -ine [IO.Path]::GetFullPath($Directory)) { throw 'Registered customer installation identity differs from recovery.' } +} +Assert-Service $false +# The agent verifies exact signed package bytes/publisher before calling this +# script; no uninstall command from registry or company profile is executed here. +$process=Start-Process -FilePath (Resolve-Path -LiteralPath $Package).Path -ArgumentList @('--silent-install','printer=0') -PassThru -Wait -WindowStyle Hidden +if ($process.ExitCode -ne 0) { throw "Customer restoration remains pending (exit $($process.ExitCode))." } +Assert-Service $true diff --git a/deployment/windows/UPDATE-RECOVERY.md b/deployment/windows/UPDATE-RECOVERY.md index 2babb0719c7..e8fd67f5f5a 100644 --- a/deployment/windows/UPDATE-RECOVERY.md +++ b/deployment/windows/UPDATE-RECOVERY.md @@ -53,6 +53,25 @@ signed metadata, artifact identity and package checks still precede installation ## Verification boundary +Customer EXE rollback now reinstalls only the retained previous hash/publisher- +verified package after complete snapshot and previous-agent capability checks. +It rejects an MSI upgrade-family registration, MSI product marker, wrong +uninstall registration, or another installation's service. The restored service +must run automatically; installed endpoint/agent files verify before metadata +and failed-release quarantine are published. Identity, keys and consent remain +current. Explicit customer EXE uninstall runs a fixed encoded cancellation +script inside its elevated uninstall batch before service/file removal, stopping +only its owned configuration task. Recovery rechecks cancellation before +completion. + +Native service/process command generation now quotes stable names containing +spaces. SYSTEM installation uses the OS command processor directly. Silent +portable packages wait for the extracted install process and propagate its exit +status; GUI launch keeps its existing asynchronous behavior. The standalone wait +module's Windows child lifecycle tests pass, including failed exit and missing +executable handling. Native app/packer syntax parses, but current full native +compilation and signed installer execution have not yet passed. + Windows agent/worker compilation, agent component tests, read-only task planning, Scheduled Task object creation and ownership/cancellation regressions pass. Unsigned customer and technician packaging rehearsals using historical 1.4.9 diff --git a/deployment/windows/test_msi_service_guard.ps1 b/deployment/windows/test_msi_service_guard.ps1 index 52d1d4e17f3..b33e7da184f 100644 --- a/deployment/windows/test_msi_service_guard.ps1 +++ b/deployment/windows/test_msi_service_guard.ps1 @@ -2,7 +2,8 @@ $ErrorActionPreference = 'Stop' # Load only the pure guard function. Do not execute the recovery script, query # services, open MSI databases, or invoke installation/removal on this host. $tokens=$null; $errors=$null -$ast=[System.Management.Automation.Language.Parser]::ParseFile((Join-Path $PSScriptRoot 'Restore-ReleaseMsi.ps1'),[ref]$tokens,[ref]$errors) +foreach ($source in @('Restore-ReleaseMsi.ps1','Restore-CustomerExe.ps1')) { +$ast=[System.Management.Automation.Language.Parser]::ParseFile((Join-Path $PSScriptRoot $source),[ref]$tokens,[ref]$errors) if ($errors.Count) { throw 'Recovery script failed parsing.' } $function=$ast.Find({param($node) $node -is [System.Management.Automation.Language.FunctionDefinitionAst] -and $node.Name -eq 'Assert-CustomerServiceRecord'},$true) if ($null -eq $function) { throw 'Service identity guard missing.' } @@ -19,4 +20,5 @@ foreach ($change in @(@{PathName='"C:\OtherInstallation\Swan Remote Support.exe" } $stopped=$valid.Clone();$stopped.State='Stopped' Assert-CustomerServiceRecord $stopped $expected $false +} Write-Output 'Service guard component checks passed; no native service or installer operations executed.' diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index 211e24f3c97..7d4b811f13f 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -683,3 +683,23 @@ unsigned uninstalled technician package and selects installation without running it. Company bundles now include the inspector, and installer execution resolves the OS system directory. Actual Swan-signed setup, MSI repair, cancelled-update uninstall/reinstall and complete clean Windows acceptance remain unverified. + +Customer EXE rollback now uses its retained verified original package, refuses +MSI-managed and unrelated registrations/services, verifies automatic service +startup and full installed identity, then applies the existing quarantine without +restoring enrollment or consent. Explicit EXE uninstall embeds a fixed encoded +cancellation/task-ownership script in its elevated uninstall batch; no profile +field provides commands. Stable native service/process names containing spaces +are quoted. SYSTEM install command execution avoids the interactive elevation +route. Silent portable packages now wait for their extracted installer and +propagate its exit status instead of racing installed-file verification. + +Targeted signed recovery regressions and Windows agent/worker builds pass. Pure +service ownership tests pass for both MSI and EXE scripts without querying or +modifying services. Three standalone Windows portable-wait tests pass, including +actual child completion/failure and launch failure; these test the exact wait +module used by the packer, not a packaged application install. New scripts and +native Rust syntax parse. Full current native app/packer compilation, EXE +uninstall/service restoration, signed rollback, reboot and clean Windows matrix +acceptance remain unverified. No native uninstall/cancellation script, installer +or service operation executed on this development host. diff --git a/libs/portable/src/install_wait.rs b/libs/portable/src/install_wait.rs new file mode 100644 index 00000000000..14fbf650d77 --- /dev/null +++ b/libs/portable/src/install_wait.rs @@ -0,0 +1,35 @@ +use std::{io,process::Child}; + +pub fn installation_exit_code(child:io::Result)->i32 { + match child { + Ok(mut child)=>match child.wait(){Ok(status)=>status.code().unwrap_or(1),Err(error)=>{eprintln!("Installer wait failed: {error}");1}}, + Err(error)=>{eprintln!("Installer launch failed: {error}");1}, + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn child_fixture(){ + if let Ok(code)=std::env::var("SWAN_TEST_INSTALL_CHILD") { + std::thread::sleep(std::time::Duration::from_millis(100)); + std::process::exit(code.parse().unwrap()); + } + } + #[test] + fn waits_for_child_and_preserves_failure_status(){ + let started=std::time::Instant::now(); + let child=std::process::Command::new(std::env::current_exe().unwrap()).args(["--exact","tests::child_fixture"]).env("SWAN_TEST_INSTALL_CHILD","7").spawn(); + assert_eq!(installation_exit_code(child),7); + assert!(started.elapsed()>=std::time::Duration::from_millis(100)); + } + #[test] + fn completed_child_succeeds_and_missing_executable_fails(){ + let child=std::process::Command::new(std::env::current_exe().unwrap()).args(["--exact","tests::child_fixture"]).env("SWAN_TEST_INSTALL_CHILD","0").spawn(); + assert_eq!(installation_exit_code(child),0); + let missing=std::env::temp_dir().join(format!("swan-missing-installer-{}.exe",std::process::id())); + assert!(!missing.exists()); + assert_eq!(installation_exit_code(std::process::Command::new(missing).spawn()),1); + } +} diff --git a/libs/portable/src/main.rs b/libs/portable/src/main.rs index b7ff44ec55b..44b0baedf70 100644 --- a/libs/portable/src/main.rs +++ b/libs/portable/src/main.rs @@ -8,6 +8,7 @@ use std::{ use bin_reader::BinaryReader; pub mod bin_reader; +mod install_wait; #[cfg(windows)] mod ui; @@ -133,6 +134,7 @@ fn is_windows_7() -> bool { } fn execute(path: PathBuf, args: Vec, _ui: bool) { + let wait_for_install=args.iter().any(|argument|argument=="--silent-install"); println!("executing {}", path.display()); // setup env let exe = std::env::current_exe().unwrap_or_default(); @@ -160,6 +162,10 @@ fn execute(path: PathBuf, args: Vec, _ui: bool) { .stderr(Stdio::inherit()); } let _child = cmd.spawn(); + if wait_for_install { + let code=install_wait::installation_exit_code(_child); + std::process::exit(code); + } #[cfg(windows)] if _ui { diff --git a/product/agent/src/update.rs b/product/agent/src/update.rs index 787684c3957..63fcfb8607a 100644 --- a/product/agent/src/update.rs +++ b/product/agent/src/update.rs @@ -458,7 +458,7 @@ fn agent_rollback_protocol(agent:&Path,directory:&Path)->Result { #[cfg(windows)] fn supports_rollback(previous:&Release,failed:&Release)->bool { previous.rollback_protocol==1 && previous.edition==failed.edition && previous.format==failed.format && - (previous.format=="msi" || (previous.edition==Edition::Technician && previous.format=="exe" && previous.sha256.eq_ignore_ascii_case(&previous.installed_sha256))) + (previous.format=="msi" || (previous.format=="exe" && (previous.edition==Edition::Customer || previous.sha256.eq_ignore_ascii_case(&previous.installed_sha256)))) } #[cfg(windows)] @@ -488,12 +488,21 @@ fn rollback_release(state:&AgentState,directory:&Path,folder:&Path,receipt:&Rece .arg("-PreviousPackage").arg(&package).arg("-NextProductCode").arg(next_identity.product_code).arg("-NextVersion").arg(&failed_release.version) .arg("-PreviousProductCode").arg(previous_identity.product_code).arg("-PreviousVersion").arg(&release.version).arg("-Directory").arg(target.parent().context("Missing installation directory")?).arg("-Edition").arg(if release.edition==Edition::Customer {"customer"}else{"technician"}).status()?; ensure!(status.success(),"MSI rollback remains pending; explicit recovery or restart may be required"); + }else if release.edition==Edition::Customer { + let package=snapshot.join("package/installer.exe"); + ensure!(staged_hash_matches(&package,&release.sha256)?,"Previous EXE installer is unavailable for complete customer recovery"); + verify_publisher(&package,&release)?; + let script=folder.join("Restore-CustomerExe.ps1"); + std::fs::write(&script,include_str!("../../../deployment/windows/Restore-CustomerExe.ps1"))?; + let status=powershell_command()?.args(["-NoLogo","-NoProfile","-NonInteractive","-ExecutionPolicy","Bypass","-File"]).arg(script).arg("-Package").arg(package).arg("-Directory").arg(target.parent().context("Missing customer installation directory")?).status()?; + ensure!(status.success(),"Customer EXE restoration remains pending"); }else{ require_existing_application(&target)?; replace_verified_file(&snapshot.join("endpoint/SwanRemoteSupport-Technician.exe"),&target,&release.installed_sha256)?; } replace_verified_file(&snapshot.join("agent/swan-agent.exe"),&directory.join("swan-agent.exe"),&release.agent_sha256)?; verify_installed(directory,&release)?; + ensure!(!directory.join("pending-uninstall").exists(),"Explicit uninstall cancelled recovery before completion"); if release.edition==Edition::Customer { let script=folder.join("Restore-Configuration.ps1"); std::fs::write(&script,include_str!("../../../deployment/windows/Restore-Configuration.ps1"))?; @@ -925,7 +934,8 @@ pub(crate) fn test_failed_release_quarantine(state:&AgentState,release:&Release, old.rollback_protocol=0;assert!(!supports_rollback(&old,&failed));old.rollback_protocol=1; old.format="exe".into();assert!(!supports_rollback(&old,&failed)); failed.format="exe".into();old.installed_sha256=old.sha256.clone();assert!(supports_rollback(&old,&failed)); - old.edition=Edition::Customer;failed.edition=Edition::Customer;assert!(!supports_rollback(&old,&failed)); + old.edition=Edition::Customer;failed.edition=Edition::Customer;assert!(supports_rollback(&old,&failed)); + old.rollback_protocol=0;assert!(!supports_rollback(&old,&failed)); let mut identity=MsiIdentity{product_code:"{00112233-4455-6677-8899-AABBCCDDEEFF}".into(),upgrade_code:"{32A585D7-9A78-4AD2-AF72-D0266EFC709D}".into(),product_version:release.version.clone(),template:"x64;1033".into()}; validate_msi_identity(&identity,&Edition::Customer,&release.version).unwrap(); assert!(validate_msi_identity(&identity,&Edition::Technician,&release.version).is_err()); diff --git a/src/platform/windows.rs b/src/platform/windows.rs index bb6bf10333b..85be20ee176 100644 --- a/src/platform/windows.rs +++ b/src/platform/windows.rs @@ -1800,10 +1800,10 @@ fn get_before_uninstall(kill_self: bool) -> String { format!( " chcp 65001 - sc stop {app_name} - sc delete {app_name} + sc stop \"{app_name}\" + sc delete \"{app_name}\" taskkill /F /IM {broker_exe} - taskkill /F /IM {app_name}.exe{filter} + taskkill /F /IM \"{app_name}.exe\"{filter} reg delete HKEY_CLASSES_ROOT\\.{ext} /f reg delete HKEY_CLASSES_ROOT\\{ext} /f netsh advfirewall firewall delete rule name=\"{app_name} Service\" @@ -1891,7 +1891,21 @@ pub fn uninstall_me(kill_self: bool) -> ResultType<()> { } return Ok(()); } - run_cmds(get_uninstall(kill_self, true), true, "uninstall") + let commands=get_uninstall(kill_self,true); + #[cfg(feature = "swan_custom")] + let commands=if crate::get_app_name()=="Swan Remote Support" { + let mut directory=vec![0u16;32768]; + let length=unsafe{winapi::um::sysinfoapi::GetSystemDirectoryW(directory.as_mut_ptr(),directory.len() as u32)} as usize; + if length==0 || length>=directory.len(){bail!("Cannot resolve trusted Windows PowerShell for uninstall cancellation");} + let powershell=PathBuf::from(OsString::from_wide(&directory[..length])).join("WindowsPowerShell/v1.0/powershell.exe"); + let script=include_str!("../../deployment/windows/Cancel-CustomerExeRecovery.ps1"); + let bytes:Vec=script.encode_utf16().flat_map(|word|word.to_le_bytes()).collect(); + let encoded=crate::encode64(bytes); + // This fixed script runs inside the existing elevated uninstall batch, + // before stopping the service or deleting any installed files. + format!("\"{}\" -NoLogo -NoProfile -NonInteractive -EncodedCommand {}\r\nif errorlevel 1 exit /b 1\r\n{}",powershell.display(),encoded,commands) + }else{commands}; + run_cmds(commands, true, "uninstall") } fn write_cmds(cmds: String, ext: &str, tip: &str) -> ResultType { @@ -1955,11 +1969,20 @@ fn run_cmds(cmds: String, show: bool, tip: &str) -> ResultType<()> { let tmp_fn = tmp.to_str().unwrap_or(""); // https://github.com/rustdesk/rustdesk/issues/6786#issuecomment-1879655410 // Specify cmd.exe explicitly to avoid the replacement of cmd commands. - let res = runas::Command::new("cmd.exe") + let res = if is_root() { + // SYSTEM update workers run without an interactive UAC desktop. + let mut directory=vec![0u16;32768]; + let length=unsafe{winapi::um::sysinfoapi::GetSystemDirectoryW(directory.as_mut_ptr(),directory.len() as u32)} as usize; + if length==0 || length>=directory.len(){bail!("Cannot resolve Windows command processor");} + let system=PathBuf::from(OsString::from_wide(&directory[..length])); + std::process::Command::new(system.join("cmd.exe")).args(["/C",tmp_fn]).current_dir(&system) + .creation_flags(if show {0}else{winapi::um::winbase::CREATE_NO_WINDOW}).status() + .and_then(|status|if status.success(){Ok(())}else{Err(std::io::Error::new(std::io::ErrorKind::Other,"SYSTEM installation command failed"))}) + }else{runas::Command::new("cmd.exe") .args(&["/C", &tmp_fn]) .show(show) .force_prompt(true) - .status(); + .status().map(|_| ())}; if !show { allow_err!(std::fs::remove_file(tmp)); } @@ -3223,11 +3246,11 @@ pub fn uninstall_service(show_new_window: bool, _: bool) -> bool { let cmds = format!( " chcp 65001 - sc stop {app_name} - sc delete {app_name} + sc stop \"{app_name}\" + sc delete \"{app_name}\" if exist \"%PROGRAMDATA%\\Microsoft\\Windows\\Start Menu\\Programs\\Startup\\{app_name} Tray.lnk\" del /f /q \"%PROGRAMDATA%\\Microsoft\\Windows\\Start Menu\\Programs\\Startup\\{app_name} Tray.lnk\" taskkill /F /IM {broker_exe} - taskkill /F /IM {app_name}.exe{filter} + taskkill /F /IM \"{app_name}.exe\"{filter} ", app_name = crate::get_app_name(), broker_exe = WIN_TOPMOST_INJECTED_PROCESS_EXE, @@ -3253,7 +3276,7 @@ pub fn install_service() -> bool { let cmds = format!( " chcp 65001 -taskkill /F /IM {app_name}.exe{filter} +taskkill /F /IM \"{app_name}.exe\"{filter} cscript \"{tray_shortcut}\" copy /Y \"{tmp_path}\\{app_name} Tray.lnk\" \"%PROGRAMDATA%\\Microsoft\\Windows\\Start Menu\\Programs\\Startup\\\" {import_config} @@ -3457,8 +3480,8 @@ reg add {subkey} /f /v EstimatedSize /t REG_DWORD /d {size} let cmds = format!( " chcp 65001 -sc stop {app_name} -taskkill /F /IM {app_name}.exe{filter} +sc stop \"{app_name}\" +taskkill /F /IM \"{app_name}.exe\"{filter} {reg_cmd} {copy_exe} {rename_exe} @@ -3746,12 +3769,12 @@ fn get_import_config(exe: &str) -> String { return "".to_string(); } format!(" -sc stop {app_name} -sc delete {app_name} -sc create {app_name} binpath= \"\\\"{exe}\\\" --import-config \\\"{config_path}\\\"\" start= auto DisplayName= \"{app_name} Service\" -sc start {app_name} -sc stop {app_name} -sc delete {app_name} +sc stop \"{app_name}\" +sc delete \"{app_name}\" +sc create \"{app_name}\" binpath= \"\\\"{exe}\\\" --import-config \\\"{config_path}\\\"\" start= auto DisplayName= \"{app_name} Service\" +sc start \"{app_name}\" +sc stop \"{app_name}\" +sc delete \"{app_name}\" ", app_name = crate::get_app_name(), config_path=Config::file().to_str().unwrap_or(""), @@ -3769,8 +3792,8 @@ if exist \"%PROGRAMDATA%\\Microsoft\\Windows\\Start Menu\\Programs\\Startup\\{ap ", app_name = crate::get_app_name()) } else { format!(" -sc create {app_name} binpath= \"\\\"{exe}\\\" --service\" start= auto DisplayName= \"{app_name} Service\" -sc start {app_name} +sc create \"{app_name}\" binpath= \"\\\"{exe}\\\" --service\" start= auto DisplayName= \"{app_name} Service\" +sc start \"{app_name}\" ", app_name = crate::get_app_name()) } From fd794a2ab6be76443fe8bfe2f4d69c3acf6f146c Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Fri, 2 Oct 2026 18:17:41 +0200 Subject: [PATCH 097/652] Protect Linux image build inputs and verify container lifecycle --- .github/workflows/company-product.yml | 10 ++ deployment/linux/Dockerfile.dockerignore | 55 ++++++++ deployment/linux/README.md | 66 +++++++++ deployment/linux/test_build_context.py | 88 ++++++++++++ deployment/linux/test_container_startup.py | 147 +++++++++++++++++++++ docs/ACCEPTANCE.md | 20 +++ product/management/src/main.rs | 11 +- 7 files changed, 396 insertions(+), 1 deletion(-) create mode 100644 deployment/linux/Dockerfile.dockerignore create mode 100644 deployment/linux/README.md create mode 100644 deployment/linux/test_build_context.py create mode 100644 deployment/linux/test_container_startup.py diff --git a/.github/workflows/company-product.yml b/.github/workflows/company-product.yml index 1419fe0e5e5..a9e15633823 100644 --- a/.github/workflows/company-product.yml +++ b/.github/workflows/company-product.yml @@ -21,3 +21,13 @@ jobs: run: cargo +1.90.0 test --manifest-path product/Cargo.toml --workspace --locked - name: Build components run: cargo +1.90.0 build --manifest-path product/Cargo.toml --workspace --release --locked + linux-deployment: + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - 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 diff --git a/deployment/linux/Dockerfile.dockerignore b/deployment/linux/Dockerfile.dockerignore new file mode 100644 index 00000000000..e843ce99d18 --- /dev/null +++ b/deployment/linux/Dockerfile.dockerignore @@ -0,0 +1,55 @@ +# 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/Cargo.toml +!product/Cargo.lock +!product/protocol/ +!product/protocol/Cargo.toml +!product/protocol/src/ +!product/protocol/src/lib.rs +!product/agent/ +!product/agent/Cargo.toml +!product/agent/src/ +!product/agent/src/lib.rs +!product/agent/src/main.rs +!product/agent/src/payload.rs +!product/agent/src/technician.rs +!product/agent/src/update.rs +!product/management/ +!product/management/Cargo.toml +!product/management/src/ +!product/management/src/main.rs +!product/management/src/service.rs +!product/management/src/network.rs +!product/management/src/backup.rs +!product/management/src/windows.rs +!product/management/src/tests.rs +!product/management/src/schema.sql +!product/management/web/ +!product/management/web/index.html +!product/management/web/app.js +!product/management/web/style.css +!product/worker/ +!product/worker/Cargo.toml +!product/worker/src/ +!product/worker/src/main.rs +!branding/ +!branding/swan-support-mark.svg +!deployment/ +!deployment/windows/ +!deployment/windows/Install-Company.ps1 +!deployment/windows/Open-Technician.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/Remove-Configuration.ps1 +!deployment/windows/Update-RecoveryTask.ps1 +!deployment/windows/Update-UninstallCancellation.ps1 diff --git a/deployment/linux/README.md b/deployment/linux/README.md new file mode 100644 index 00000000000..2cb8d62c15e --- /dev/null +++ b/deployment/linux/README.md @@ -0,0 +1,66 @@ +# 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 Dockerfile has its own deny-by-default build context. It admits +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. + +From the repository root, with a working Linux Docker engine: + +```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 +``` + +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. Credentials stay in memory and the test removes only its own container +and volume. 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 company-product CI runs both checks. diff --git a/deployment/linux/test_build_context.py b/deployment/linux/test_build_context.py new file mode 100644 index 00000000000..fe6170c29fd --- /dev/null +++ b/deployment/linux/test_build_context.py @@ -0,0 +1,88 @@ +"""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/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) + probe = lab / "probe.Dockerfile" + probe.write_text("FROM scratch\nCOPY . /\n", encoding="utf-8") + shutil.copyfile(ROOT / "deployment/linux/Dockerfile.dockerignore", 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_startup.py b/deployment/linux/test_container_startup.py new file mode 100644 index 00000000000..1d5ad6c392a --- /dev/null +++ b/deployment/linux/test_container_startup.py @@ -0,0 +1,147 @@ +"""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 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) + 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") + base = "http://" + port + 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 "$(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) + 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") + 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/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index 7d4b811f13f..bdd13ca7e37 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -703,3 +703,23 @@ native Rust syntax parse. Full current native app/packer compilation, EXE uninstall/service restoration, signed rollback, reboot and clean Windows matrix acceptance remain unverified. No native uninstall/cancellation script, installer or service operation executed on this development host. + +Linux deployment now has a Dockerfile-specific deny-by-default context for named +public build inputs; Git-ignored company environment files, keys, databases and +build caches cannot intentionally be copied by the deployment recipe. A new CI +check uses Docker's scratch exporter against tracked public source and synthetic +configuration/key/cache canaries, and rejects missing or unexpected exported +files. A separate real-image rehearsal exercises non-root/read-only operation, +private storage permissions, fresh company setup, MFA/replay rejection, pending +enrollment, unattended-policy denial, graceful Docker SIGTERM shutdown, persisted +identity/approval/consent and logout after restart. Linux management now handles +SIGTERM as well as Ctrl+C. + +Python syntax and fixture assembly (38 required public inputs and 11 exclusion +canaries), Compose schema/configuration and Windows GNU management compilation +pass locally. Neither new Docker execution check has passed yet: the local +Docker Desktop startup reports an inference-service socket error before its +Linux engine is usable. Existing Docker data was not reset. The new CI job must +pass before claiming this Linux deployment evidence. Public HTTPS, native +transport, full backup/restore/migration recovery, real package installation and +the complete clean Windows matrix remain separate acceptance gates. diff --git a/product/management/src/main.rs b/product/management/src/main.rs index 396b0b54d09..0946e51f697 100644 --- a/product/management/src/main.rs +++ b/product/management/src/main.rs @@ -17,7 +17,16 @@ fn main() -> Result<()> { let runtime=tokio::runtime::Runtime::new()?; runtime.block_on(async { let (sender,receiver)=tokio::sync::oneshot::channel(); - tokio::spawn(async move {if tokio::signal::ctrl_c().await.is_ok(){let _=sender.send(());}}); + #[cfg(unix)] + let mut terminate=tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()).context("Install management termination handler")?; + tokio::spawn(async move { + #[cfg(unix)] + let signal=tokio::select! {result=tokio::signal::ctrl_c()=>result,_=terminate.recv()=>Ok(())}; + #[cfg(not(unix))] + let signal=tokio::signal::ctrl_c().await; + if let Err(error)=signal {eprintln!("Management shutdown signal failed: {error}");} + let _=sender.send(()); + }); run(receiver).await }) } From 362f2de4c7de42cb09ea2d9afcfa8c5c9048667b Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Fri, 2 Oct 2026 19:29:16 +0200 Subject: [PATCH 098/652] Validate complete company bundles and protect older Docker build contexts --- .dockerignore | 68 +++++++++ deployment/linux/Dockerfile.dockerignore | 13 ++ deployment/linux/test_build_context.py | 8 +- docs/ACCEPTANCE.md | 13 ++ product/management/src/service.rs | 168 +++++++++++++++++++---- product/protocol/src/lib.rs | 8 ++ product/worker/src/main.rs | 40 ++++-- 7 files changed, 281 insertions(+), 37 deletions(-) create mode 100644 .dockerignore diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000000..980bc0c931e --- /dev/null +++ b/.dockerignore @@ -0,0 +1,68 @@ +# 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/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/technician.rs +!product/agent/src/update.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/network.rs +!product/management/src/backup.rs +!product/management/src/windows.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 +!branding/ +branding/** +!branding/swan-support-mark.svg +!deployment/ +deployment/** +!deployment/windows/ +deployment/windows/** +!deployment/windows/Install-Company.ps1 +!deployment/windows/Open-Technician.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/Remove-Configuration.ps1 +!deployment/windows/Update-RecoveryTask.ps1 +!deployment/windows/Update-UninstallCancellation.ps1 diff --git a/deployment/linux/Dockerfile.dockerignore b/deployment/linux/Dockerfile.dockerignore index e843ce99d18..980bc0c931e 100644 --- a/deployment/linux/Dockerfile.dockerignore +++ b/deployment/linux/Dockerfile.dockerignore @@ -4,23 +4,30 @@ ** !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/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/technician.rs !product/agent/src/update.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/network.rs @@ -29,17 +36,23 @@ !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 !branding/ +branding/** !branding/swan-support-mark.svg !deployment/ +deployment/** !deployment/windows/ +deployment/windows/** !deployment/windows/Install-Company.ps1 !deployment/windows/Open-Technician.ps1 !deployment/windows/Verify-Package.ps1 diff --git a/deployment/linux/test_build_context.py b/deployment/linux/test_build_context.py index fe6170c29fd..66f18b09ccb 100644 --- a/deployment/linux/test_build_context.py +++ b/deployment/linux/test_build_context.py @@ -67,9 +67,13 @@ def main(): context = lab / "context" context.mkdir() required = prepare_fixture(context) - probe = lab / "probe.Dockerfile" + 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(ROOT / "deployment/linux/Dockerfile.dockerignore", pathlib.Path(str(probe) + ".dockerignore")) + 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)], diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index bdd13ca7e37..a0964ad06d0 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -723,3 +723,16 @@ Linux engine is usable. Existing Docker data was not reset. The new CI job must pass before claiming this Linux deployment evidence. Public HTTPS, native transport, full backup/restore/migration recovery, real package installation and the complete clean Windows matrix remain separate acceptance gates. + +Distribution validation now accepts the complete ten-file worker recipe for both +editions and EXE/MSI formats. It rejects changed scripts, hidden duplicate ZIP +entries, inconsistent archive headers, altered metadata and wrong company trust. +The worker verifies both artifact signatures and applies MSI identity checks only +to the installer. Nineteen management/protocol tests and the worker verification +regression pass; these fixtures do not demonstrate signed native installation. + +A disposable Debian 12 VM now runs Docker. Its real scratch-export check passed: +all 38 required public inputs were included and all 11 synthetic private-data +canaries were excluded. Older Docker required root exclusion rules and explicit +child exclusions after directory allowances; both shipped ignore files now agree. +The complete management image and container lifecycle rehearsal remain pending. diff --git a/product/management/src/service.rs b/product/management/src/service.rs index a192258d243..5a60266370a 100644 --- a/product/management/src/service.rs +++ b/product/management/src/service.rs @@ -561,6 +561,82 @@ struct BuildResult { success:bool, artifact_url:String,sha256:String,log:String mod worker_completion_tests { use super::*; #[test] + fn worker_bundle_requires_complete_exact_recipe_and_company_trust() { + // Inert bytes exercise the upload contract; no installer or signing + // provider is invoked, and these fixtures are never published. + fn archive(entries:&[(String,Vec)])->Vec { + use std::io::Write; + let mut writer=zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); + let options=zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated); + for (name,bytes) in entries {writer.start_file(name,options).unwrap();writer.write_all(bytes).unwrap();} + writer.finish().unwrap().into_inner() + } + let company=SigningKey::from_bytes(&[23;32]);let project=SigningKey::from_bytes(&[24;32]); + let company_public=STANDARD.encode(company.verifying_key().as_bytes()); + let trust=STANDARD.encode(project.verifying_key().as_bytes()); + let profile=CompanyProfile{schema:SCHEMA,company_id:"bundle-test".into(),revision:1,issued_at:now()-1,expires_at:now()+3600,management_url:"https://support.example.com".into(),rendezvous:"support.example.com".into(),relay:"support.example.com".into(),transport_public_key:company_public.clone(),customer:Default::default(),technician:Default::default(),allow_unattended:false,updates_paused:true,rollout_percent:0,maintenance_start_utc:0,maintenance_end_utc:0,update_channel:"test".into(),next_profile_public_key:None}; + let signed_profile=SignedEnvelope::sign(&profile,&company).unwrap(); + for edition in [Edition::Customer,Edition::Technician] {for format in ["exe","msi"] { + let installer=b"inert installer fixture";let agent=b"inert agent fixture"; + let executable=if edition==Edition::Customer {"Swan Remote Support.exe"}else{"SwanRemoteSupport-Technician.exe"}; + let mut installed_files=vec![InstalledFile{path:executable.into(),sha256:digest(installer)}]; + if edition==Edition::Customer {for name in ["librustdesk.dll","flutter_windows.dll"] {installed_files.push(InstalledFile{path:name.into(),sha256:digest(b"inert library fixture")});}} + let release=Release{schema:SCHEMA,product:PRODUCT.into(),version:"2.0.0".into(),sequence:1,edition:edition.clone(),architecture:"x64".into(),channel:"test".into(),expires_at:now()+3600,artifact_url:format!("https://releases.example/install.{format}"),sha256:digest(installer),installed_sha256:digest(installer),installed_files,agent_url:"https://releases.example/agent.exe".into(),agent_sha256:digest(agent),publisher:"Inert fixture".into(),publisher_certificate_sha256:"d".repeat(64),windows_versions:vec!["windows_11".into()],source_url:"https://releases.example/source.tar.gz".into(),format:format.into(),rollback_protocol:1}; + release.validate(&edition,0,now()).unwrap(); + let signed_release=SignedEnvelope::sign(&release,&project).unwrap(); + let job=json!({"profile":signed_profile,"release":signed_release}); + let bootstrap=swan_agent::Bootstrap{schema:SCHEMA,edition:edition.clone(),company_id:profile.company_id.clone(),management_url:profile.management_url.clone(),profile_public_key:company_public.clone(),release_public_key:trust.clone()}; + let mut entries=vec![ + (format!("SwanRemoteSupport-install.{format}"),installer.to_vec()),("swan-agent.exe".into(),agent.to_vec()), + ("bootstrap.json".into(),serde_json::to_vec_pretty(&bootstrap).unwrap()), + ("company-profile.json".into(),serde_json::to_vec_pretty(&signed_profile).unwrap()), + ("release.json".into(),serde_json::to_vec_pretty(&signed_release).unwrap()), + ]; + for (name,bytes) in COMPANY_BUNDLE_TEXT_FILES {entries.push((name.into(),bundle_text(bytes).unwrap()));} + assert_eq!(entries.len(),10); + let verify=|bytes:&[u8]|validate_worker_bundle(bytes,&job,&release,&company.verifying_key(),&trust).is_ok(); + let valid=archive(&entries);assert!(verify(&valid),"Complete {edition:?}/{format} bundle was rejected"); + let helper=entries.iter().position(|(name,_)|name=="Get-MsiInstallMode.ps1").unwrap(); + let mut altered=entries.clone();altered[helper].1=b"altered script".to_vec();assert!(!verify(&archive(&altered))); + let mut missing=entries.clone();missing.remove(helper);assert!(!verify(&archive(&missing))); + let mut extra=entries.clone();extra.push(("extra.dll".into(),b"unexpected".to_vec()));assert!(!verify(&archive(&extra))); + let mut renamed=entries.clone();renamed[helper].0="Get-MsiInstallM0de.ps1".into();assert!(!verify(&archive(&renamed))); + let mut binary=entries.clone();binary[0].1=b"different installer".to_vec();assert!(!verify(&archive(&binary))); + for index in [3,4] { + let mut hidden=entries.clone();let mut metadata:Value=serde_json::from_slice(&hidden[index].1).unwrap(); + metadata["technician_token"]=json!("inert secret canary");hidden[index].1=serde_json::to_vec_pretty(&metadata).unwrap(); + assert!(!verify(&archive(&hidden)),"Unsigned fields cannot enter public bundles"); + } + let mut duplicate_json=entries.clone(); + let mut metadata=b"{\"payload\":\"inert secret canary\",".to_vec(); + metadata.extend_from_slice(&duplicate_json[3].1[1..]);duplicate_json[3].1=metadata; + assert!(!verify(&archive(&duplicate_json)),"Duplicate JSON keys cannot hide discarded metadata"); + for (field,value) in [("company_id",json!("wrong-company")),("schema",json!(2)),("management_url",json!("https://wrong.example.com")),("edition",json!(if edition==Edition::Customer {"technician"}else{"customer"}))] { + let mut wrong=entries.clone();let mut metadata:Value=serde_json::from_slice(&wrong[2].1).unwrap();metadata[field]=value; + // Serialize through Bootstrap so the fixture keeps the worker's field order. + let bootstrap:swan_agent::Bootstrap=serde_json::from_value(metadata).unwrap();wrong[2].1=serde_json::to_vec_pretty(&bootstrap).unwrap(); + assert!(!verify(&archive(&wrong)),"Wrong bootstrap {field} accepted"); + } + let mut local_name=valid.clone();local_name[30]=b'X';assert!(!verify(&local_name),"Local and central filenames must agree"); + let mut truncated=valid.clone();truncated.pop();assert!(!verify(&truncated)); + let mut prefixed=b"MZ".to_vec();prefixed.extend_from_slice(&valid);assert!(!verify(&prefixed)); + // zip 2.4.2 hides duplicate names behind its unique-name map. Build + // a distinct alias, then change both header names to the real helper. + let alias=b"Get-MsiInstallM0de.ps1";let canonical=b"Get-MsiInstallMode.ps1"; + let mut duplicate=entries.clone();duplicate.push((String::from_utf8(alias.to_vec()).unwrap(),entries[helper].1.clone())); + let mut duplicate=archive(&duplicate); + for offset in 0..=duplicate.len()-alias.len() {if &duplicate[offset..offset+alias.len()]==alias {duplicate[offset..offset+alias.len()].copy_from_slice(canonical);}} + assert_eq!(zip::ZipArchive::new(std::io::Cursor::new(&duplicate)).unwrap().len(),10,"Regression must exercise hidden physical duplicates"); + assert!(!verify(&duplicate)); + let footer=duplicate.len()-22;duplicate[footer+8..footer+12].copy_from_slice(&[10,0,10,0]); + assert!(!verify(&duplicate),"Forged entry counts cannot hide duplicate records"); + let mut expired=profile.clone();expired.expires_at=now();let expired=SignedEnvelope::sign(&expired,&company).unwrap(); + let mut expired_entries=entries.clone();expired_entries[3].1=serde_json::to_vec_pretty(&expired).unwrap(); + let expired_job=json!({"profile":expired,"release":signed_release}); + assert!(validate_worker_bundle(&archive(&expired_entries),&expired_job,&release,&company.verifying_key(),&trust).is_err()); + }} + } + #[test] fn legacy_schema_migration_requires_encrypted_backup_and_keeps_original_key() { let directory=std::env::temp_dir().join(format!("swan-key-migration-{}",random_token())); let initial=Store::open(&directory).unwrap();let public=initial.key.verifying_key(); @@ -773,35 +849,77 @@ async fn upload_artifact(State(s):State,Path(id):Path,request:ax let trust=std::env::var("SWAN_RELEASE_PUBLIC_KEY").map_err(|_|bad("Release key not configured"))?; let release:Release=release_envelope.verify(&public_key(&trust)?).map_err(|_|denied())?; release.validate(&release.edition,0,now()).map_err(|_|bad("Expired release"))?; + let company_key=s.key.verifying_key(); + let hash=tokio::task::spawn_blocking(move || { + validate_worker_bundle(&bytes,&job,&release,&company_key,&trust)?; + let hash=digest(&bytes); + persist_verified_artifact(&s,&id,&worker,&bytes)?; + Ok::<_,ApiError>(hash) + }) + .await.map_err(|_|bad("Artifact persistence failed"))??; + Ok(Json(json!({"sha256":hash}))) +} + +fn validate_bundle_zip_layout(bytes:&[u8],count:usize)->Result<(),ApiError> { + // Admit the worker's bounded, seek-written ZIP32 recipe only. zip 2.4.2 + // deduplicates central-directory names, so ZipArchive::len alone cannot + // establish that an archive has exactly the expected physical entries. + let range=|offset:usize,length:usize|->Result<&[u8],ApiError>{ + bytes.get(offset..offset.checked_add(length).ok_or_else(||bad("Invalid ZIP bounds"))?).ok_or_else(||bad("Invalid ZIP bounds")) + }; + let word=|offset:usize|->Result{let data=range(offset,2)?;Ok(u16::from_le_bytes([data[0],data[1]]) as usize)}; + let dword=|offset:usize|->Result{let data=range(offset,4)?;Ok(u32::from_le_bytes([data[0],data[1],data[2],data[3]]) as usize)}; + let end=bytes.len().checked_sub(22).ok_or_else(||bad("Incomplete ZIP footer"))?; + if range(end,4)?!=b"PK\x05\x06" || word(end+4)?!=0 || word(end+6)?!=0 || word(end+8)?!=count || word(end+10)?!=count || word(end+20)?!=0 {return Err(bad("Unexpected ZIP footer"));} + let start=dword(end+16)?; + if start.checked_add(dword(end+12)?)!=Some(end) {return Err(bad("Invalid ZIP directory bounds"));} + let mut central=start;let mut local=0;let mut names=std::collections::HashSet::new(); + for _ in 0..count { + let header=range(central,46)?; + if &header[..4]!=b"PK\x01\x02" || word(central+30)?!=0 || word(central+32)?!=0 || word(central+34)?!=0 || dword(central+42)?!=local {return Err(bad("Unsupported ZIP entry layout"));} + let name_length=word(central+28)?; + let name=range(central+46,name_length)?; + if !names.insert(name) {return Err(bad("Duplicate ZIP entry"));} + let local_header=range(local,30)?; + let flags=word(central+8)?; + if &local_header[..4]!=b"PK\x03\x04" || flags & !0x0800!=0 || word(local+6)?!=flags || word(central+10)?!=8 || word(local+8)?!=8 || word(local+26)?!=name_length || word(local+28)?!=0 || range(local+30,name_length)?!=name || range(local+14,12)?!=range(central+16,12)? {return Err(bad("Ambiguous ZIP entry headers"));} + let compressed=dword(central+20)?; + local=local.checked_add(30+name_length).and_then(|offset|offset.checked_add(compressed)).ok_or_else(||bad("Invalid ZIP data bounds"))?; + central=central.checked_add(46+name_length).ok_or_else(||bad("Invalid ZIP directory bounds"))?; + if local>start || central>end {return Err(bad("ZIP entry exceeds its region"));} + } + if local!=start || central!=end {return Err(bad("Unaccounted ZIP contents"));} + Ok(()) +} + +fn validate_worker_bundle(bytes:&[u8],job:&Value,release:&Release,company_key:&ed25519_dalek::VerifyingKey,trust:&str)->Result<(),ApiError> { // Verify content without extracting any paths. Download bundles cannot inject executables or setup scripts. - let mut zip=zip::ZipArchive::new(std::io::Cursor::new(&bytes)).map_err(|_|bad("Invalid ZIP artifact"))?; - if zip.len()!=9 {return Err(bad("Unexpected bundle contents"));} + validate_bundle_zip_layout(bytes,5+COMPANY_BUNDLE_TEXT_FILES.len())?; + let mut zip=zip::ZipArchive::new(std::io::Cursor::new(bytes)).map_err(|_|bad("Invalid ZIP artifact"))?; + if zip.len()!=5+COMPANY_BUNDLE_TEXT_FILES.len() {return Err(bad("Unexpected bundle contents"));} use std::io::Read; - let mut read=|name:&str|->Result,ApiError>{ + let mut read=|name:&str,limit:u64|->Result,ApiError>{ let file=zip.by_name(name).map_err(|_|bad("Missing bundle file"))?; - if file.size()>512*1024*1024{return Err(bad("Bundle entry too large"));} - let mut output=Vec::new();file.take(512*1024*1024+1).read_to_end(&mut output).map_err(|_|bad("Unreadable bundle"))?; - if output.len()>512*1024*1024{return Err(bad("Bundle entry too large"));}Ok(output) + if file.is_dir() || file.is_symlink() || file.size()>limit{return Err(bad("Invalid bundle entry type or size"));} + let mut output=Vec::new();file.take(limit+1).read_to_end(&mut output).map_err(|_|bad("Unreadable bundle"))?; + if output.len() as u64>limit{return Err(bad("Bundle entry too large"));}Ok(output) }; - if digest(read(&format!("SwanRemoteSupport-install.{}",release.format))?)!=release.sha256.to_lowercase() || digest(read("swan-agent.exe")?)!=release.agent_sha256.to_lowercase(){return Err(bad("Bundle binary hash mismatch"));} - for (name,expected) in [ - ("Install-Company.ps1",include_bytes!("../../../deployment/windows/Install-Company.ps1").as_slice()), - ("Open-Technician.ps1",include_bytes!("../../../deployment/windows/Open-Technician.ps1").as_slice()), - ("Verify-Package.ps1",include_bytes!("../../../deployment/windows/Verify-Package.ps1").as_slice()), - ("LICENSE.txt",include_bytes!("../../../LICENCE").as_slice()) - ] {if read(name)?!=bundle_text(expected)?{return Err(bad("Bundle script or license mismatch"));}} - let profile:SignedEnvelope=serde_json::from_slice(&read("company-profile.json")?)?; - if serde_json::to_value(&profile)?!=job["profile"] {return Err(bad("Wrong company profile"));} - let copied_release:SignedEnvelope=serde_json::from_slice(&read("release.json")?)?; - if serde_json::to_value(&copied_release)?!=job["release"] {return Err(bad("Wrong release metadata"));} - let bootstrap:swan_agent::Bootstrap=serde_json::from_slice(&read("bootstrap.json")?)?; - let p:CompanyProfile=profile.verify(&s.key.verifying_key()).map_err(|_|denied())?; - if bootstrap.company_id!=p.company_id || bootstrap.edition!=release.edition || bootstrap.management_url!=p.management_url || bootstrap.profile_public_key!=STANDARD.encode(s.key.verifying_key().as_bytes()) || bootstrap.release_public_key!=trust {return Err(bad("Bootstrap trust mismatch"));} - drop(read);drop(zip); - let hash=digest(&bytes); - tokio::task::spawn_blocking(move || persist_verified_artifact(&s,&id,&worker,&bytes)) - .await.map_err(|_|bad("Artifact persistence failed"))??; - Ok(Json(json!({"sha256":hash}))) + if digest(read(&format!("SwanRemoteSupport-install.{}",release.format),512*1024*1024)?)!=release.sha256.to_lowercase() || digest(read("swan-agent.exe",512*1024*1024)?)!=release.agent_sha256.to_lowercase(){return Err(bad("Bundle binary hash mismatch"));} + for (name,expected) in COMPANY_BUNDLE_TEXT_FILES {let expected=bundle_text(expected)?;if read(name,expected.len() as u64)?!=expected{return Err(bad("Bundle script or license mismatch"));}} + let profile_bytes=read("company-profile.json",2*1024*1024)?; + let profile:SignedEnvelope=serde_json::from_slice(&profile_bytes)?; + if profile_bytes!=serde_json::to_vec_pretty(&profile)? || serde_json::to_value(&profile)?!=job["profile"] {return Err(bad("Wrong company profile"));} + let release_bytes=read("release.json",2*1024*1024)?; + let copied_release:SignedEnvelope=serde_json::from_slice(&release_bytes)?; + if release_bytes!=serde_json::to_vec_pretty(&copied_release)? || serde_json::to_value(&copied_release)?!=job["release"] {return Err(bad("Wrong release metadata"));} + let bootstrap_bytes=read("bootstrap.json",16*1024)?; + let bootstrap:swan_agent::Bootstrap=serde_json::from_slice(&bootstrap_bytes)?; + if bootstrap_bytes!=serde_json::to_vec_pretty(&bootstrap)? {return Err(bad("Noncanonical bootstrap configuration"));} + bootstrap.validate().map_err(|_|bad("Invalid bootstrap configuration"))?; + let p:CompanyProfile=profile.verify(company_key).map_err(|_|denied())?; + p.validate(&p.company_id,0,now()).map_err(|_|bad("Expired or invalid bundle profile"))?; + if bootstrap.company_id!=p.company_id || bootstrap.edition!=release.edition || bootstrap.management_url!=p.management_url || bootstrap.profile_public_key!=STANDARD.encode(company_key.as_bytes()) || bootstrap.release_public_key!=trust {return Err(bad("Bootstrap trust mismatch"));} + Ok(()) } // Called only after bundle validation, under a blocking task. diff --git a/product/protocol/src/lib.rs b/product/protocol/src/lib.rs index c2ab3f297d3..9819c73eee8 100644 --- a/product/protocol/src/lib.rs +++ b/product/protocol/src/lib.rs @@ -9,6 +9,14 @@ use sha2::{Digest, Sha256}; pub const SCHEMA: u32 = 1; pub const PRODUCT: &str = "Swan Remote Support"; pub const WIRE_PREFIX: &[u8] = b"SWAN1:"; +// Worker output and server upload validation must use the same trusted recipe. +pub const COMPANY_BUNDLE_TEXT_FILES:[(&str,&[u8]);5]=[ + ("Install-Company.ps1",include_bytes!("../../../deployment/windows/Install-Company.ps1")), + ("Get-MsiInstallMode.ps1",include_bytes!("../../../deployment/windows/Get-MsiInstallMode.ps1")), + ("Open-Technician.ps1",include_bytes!("../../../deployment/windows/Open-Technician.ps1")), + ("Verify-Package.ps1",include_bytes!("../../../deployment/windows/Verify-Package.ps1")), + ("LICENSE.txt",include_bytes!("../../../LICENCE")), +]; #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] diff --git a/product/worker/src/main.rs b/product/worker/src/main.rs index cbcca9b7cd3..d6cb54ae928 100644 --- a/product/worker/src/main.rs +++ b/product/worker/src/main.rs @@ -89,8 +89,7 @@ async fn build(_client:&reqwest::Client,job:&Value,output:&Path,id:&str,release_ } fn package_verified(output:PathBuf,id:&str,work:PathBuf,installer:PathBuf,agent:PathBuf,release:Release,profile:CompanyProfile,release_envelope:SignedEnvelope,profile_envelope:SignedEnvelope,release_key:&str,company_key:&str)->Result<(String,String)> { - verify_windows(&installer,&release,&work)?; - verify_windows(&agent,&release,&work)?; + verify_artifacts(&installer,&agent,&release.format,|path,check_msi|verify_windows(path,&release,&work,check_msi))?; let bootstrap=Bootstrap{schema:SCHEMA,edition:release.edition.clone(),company_id:profile.company_id,management_url:profile.management_url,profile_public_key:company_key.into(),release_public_key:release_key.into()}; let file_name=format!("SwanRemoteSupport-{}-{}-{}.zip",if release.edition==Edition::Customer{"Customer"}else{"Technician"},release.version,id); let temporary=output.join(format!("{file_name}.partial")); @@ -101,12 +100,8 @@ fn package_verified(output:PathBuf,id:&str,work:PathBuf,installer:PathBuf,agent: ("bootstrap.json",serde_json::to_vec_pretty(&bootstrap)?), ("company-profile.json",serde_json::to_vec_pretty(&profile_envelope)?), ("release.json",serde_json::to_vec_pretty(&release_envelope)?), - ("Install-Company.ps1",bundle_text(include_bytes!("../../../deployment/windows/Install-Company.ps1"))?), - ("Get-MsiInstallMode.ps1",bundle_text(include_bytes!("../../../deployment/windows/Get-MsiInstallMode.ps1"))?), - ("Open-Technician.ps1",bundle_text(include_bytes!("../../../deployment/windows/Open-Technician.ps1"))?), - ("Verify-Package.ps1",bundle_text(include_bytes!("../../../deployment/windows/Verify-Package.ps1"))?), - ("LICENSE.txt",bundle_text(include_bytes!("../../../LICENCE"))?), ] {zip.start_file(name,options)?;zip.write_all(&bytes)?;} + for (name,bytes) in COMPANY_BUNDLE_TEXT_FILES {zip.start_file(name,options)?;zip.write_all(&bundle_text(bytes)?)?;} zip.finish()?.sync_all()?; let hash=digest(std::fs::read(&temporary)?); let final_name=file_name.replace(".zip",&format!("-{}.zip",&hash[..16]));let target=output.join(&final_name); @@ -122,16 +117,41 @@ pub async fn download(client:&reqwest::Client,url:&str,path:&Path,hash:&str)->Re ensure!(digest(&bytes).eq_ignore_ascii_case(hash),"Artifact hash mismatch"); tokio::fs::write(path,bytes).await?;Ok(()) } +fn verify_artifacts(installer:&Path,agent:&Path,format:&str,mut verify:impl FnMut(&Path,bool)->Result<()>)->Result<()> { + verify(installer,format=="msi")?; + verify(agent,false) +} #[cfg(windows)] -fn verify_windows(path:&Path,release:&Release,directory:&Path)->Result<()> { +fn verify_windows(path:&Path,release:&Release,directory:&Path,check_msi:bool)->Result<()> { let verifier=directory.join("Verify-Package.ps1");std::fs::write(&verifier,include_bytes!("../../../deployment/windows/Verify-Package.ps1"))?; let status=swan_agent::update::powershell_command()?.args(["-NoProfile","-NonInteractive","-ExecutionPolicy","Bypass","-File"]).arg(verifier).arg("-Path").arg(path).arg("-Publisher").arg(&release.publisher).arg("-CertificateSha256").arg(&release.publisher_certificate_sha256).status()?; ensure!(status.success(),"Artifact signature or publisher rejected"); - if release.format=="msi" {swan_agent::update::verify_msi_release_identity(path,release)?;} + if check_msi {swan_agent::update::verify_msi_release_identity(path,release)?;} Ok(()) } #[cfg(not(windows))] -fn verify_windows(_path:&Path,_release:&Release,_directory:&Path)->Result<()> { +fn verify_windows(_path:&Path,_release:&Release,_directory:&Path,_check_msi:bool)->Result<()> { anyhow::bail!("Artifact signature verification requires a Windows worker") } + +#[cfg(test)] +mod verification_tests { + use super::*; + + #[test] + fn both_artifacts_require_trust_but_only_msi_installer_requires_msi_identity() { + let agent=Path::new("swan-agent.exe"); + for format in ["exe","msi"] { + let installer=PathBuf::from(format!("installer.{format}")); + let mut checked=Vec::new(); + verify_artifacts(&installer,agent,format,|path,msi|{checked.push((path.to_owned(),msi));Ok(())}).unwrap(); + assert_eq!(checked,vec![(installer.clone(),format=="msi"),(agent.to_owned(),false)]); + for rejected in [&installer,agent] { + assert!(verify_artifacts(&installer,agent,format,|path,_|{ + ensure!(path!=rejected,"Untrusted artifact");Ok(()) + }).is_err()); + } + } + } +} From 998803817ffa4463555edd7d47f215f6d3b2bf28 Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Fri, 2 Oct 2026 19:31:12 +0200 Subject: [PATCH 099/652] Pin management base image manifests and check root context changes --- .github/workflows/company-product.yml | 4 ++-- deployment/linux/Dockerfile | 4 ++-- deployment/linux/README.md | 9 ++++++++- 3 files changed, 12 insertions(+), 5 deletions(-) diff --git a/.github/workflows/company-product.yml b/.github/workflows/company-product.yml index a9e15633823..e12cc1277a5 100644 --- a/.github/workflows/company-product.yml +++ b/.github/workflows/company-product.yml @@ -2,9 +2,9 @@ name: Company product components on: workflow_dispatch: push: - paths: ['product/**', 'deployment/**', '.github/workflows/company-product.yml'] + paths: ['product/**', 'deployment/**', '.dockerignore', '.github/workflows/company-product.yml'] pull_request: - paths: ['product/**', 'deployment/**', '.github/workflows/company-product.yml'] + paths: ['product/**', 'deployment/**', '.dockerignore', '.github/workflows/company-product.yml'] permissions: contents: read jobs: diff --git a/deployment/linux/Dockerfile b/deployment/linux/Dockerfile index 9e9522a4931..10695ecd8ac 100644 --- a/deployment/linux/Dockerfile +++ b/deployment/linux/Dockerfile @@ -1,4 +1,4 @@ -FROM rust:1.90.0-bookworm AS builder +FROM rust:1.90.0-bookworm@sha256:3914072ca0c3b8aad871db9169a651ccfce30cf58303e5d6f2db16d1d8a7e58f AS builder WORKDIR /source COPY product ./product COPY branding ./branding @@ -6,7 +6,7 @@ COPY deployment ./deployment COPY LICENCE ./LICENCE RUN cargo build --manifest-path product/Cargo.toml --locked --release -p swan-management -FROM debian:bookworm-slim +FROM debian:bookworm-slim@sha256:3783cc01769c7b2b1b83a5c5ad96c815348e28ed7da68e2e3687004faa906251 RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates && rm -rf /var/lib/apt/lists/* && 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 diff --git a/deployment/linux/README.md b/deployment/linux/README.md index 2cb8d62c15e..1bd6057d83b 100644 --- a/deployment/linux/README.md +++ b/deployment/linux/README.md @@ -42,12 +42,19 @@ and restore; backing up only the management volume is insufficient. ## Build and deployment checks -The management Dockerfile has its own deny-by-default build context. It admits +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 and Debian runtime images are pinned by manifest digest. +Update those digests deliberately and rerun these checks when applying base-image +security updates. Debian packages installed during the build still come from its +configured repositories; the complete image is not claimed to be bit-for-bit +reproducible across different build dates. + From the repository root, with a working Linux Docker engine: ```sh From 797a290471a8968842797fe46cd47ce300b76bf6 Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Fri, 2 Oct 2026 19:33:23 +0200 Subject: [PATCH 100/652] Record current full product component verification --- docs/ACCEPTANCE.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index a0964ad06d0..14864411bb1 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -736,3 +736,10 @@ all 38 required public inputs were included and all 11 synthetic private-data canaries were excluded. Older Docker required root exclusion rules and explicit child exclusions after directory allowances; both shipped ignore files now agree. The complete management image and container lifecycle rehearsal remain pending. + +At source `9988038`, the complete Windows GNU product-workspace test run passed +41 tests with zero failures. Two explicitly fixture-dependent tests were skipped: +the isolated HTTPS download-recovery harness and read-only MSI identity check. +These skips do not prove those requirements at this source. The agent suite +exercised actual read-only Authenticode checks and expected rejection paths; +no native installer, service installation or desktop session was executed. From c54d34a611401b7f4710ae5c0a2c1effbc8a27ab Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Fri, 2 Oct 2026 19:35:57 +0200 Subject: [PATCH 101/652] Pin the HTTPS proxy manifest for company deployment --- deployment/linux/README.md | 3 ++- deployment/linux/compose.yaml | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/deployment/linux/README.md b/deployment/linux/README.md index 1bd6057d83b..b663218eac9 100644 --- a/deployment/linux/README.md +++ b/deployment/linux/README.md @@ -49,7 +49,8 @@ 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 and Debian runtime images are pinned by manifest digest. +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. Debian packages installed during the build still come from its configured repositories; the complete image is not claimed to be bit-for-bit diff --git a/deployment/linux/compose.yaml b/deployment/linux/compose.yaml index f92e17d1c1d..7a61919d8f7 100644 --- a/deployment/linux/compose.yaml +++ b/deployment/linux/compose.yaml @@ -14,7 +14,7 @@ services: read_only: true tmpfs: [/tmp] https: - image: caddy:2.10.2 + image: caddy:2.10.2@sha256:c3d7ee5d2b11f9dc54f947f68a734c84e9c9666c92c88a7f30b9cba5da182adb restart: unless-stopped environment: SWAN_HOST: ${SWAN_HOST:?Set the public company server hostname} From 1359cfb62710f0e8422a9fb9c6bb306c9389a101 Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Fri, 2 Oct 2026 19:41:38 +0200 Subject: [PATCH 102/652] Bound installer verification memory while checking expanded data --- docs/ACCEPTANCE.md | 7 +++++++ product/management/src/service.rs | 35 ++++++++++++++++++++++++++++++- 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index 14864411bb1..ef347793a6a 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -743,3 +743,10 @@ the isolated HTTPS download-recovery harness and read-only MSI identity check. These skips do not prove those requirements at this source. The agent suite exercised actual read-only Authenticode checks and expected rejection paths; no native installer, service installation or desktop session was executed. + +Installer upload validation now hashes expanded binaries through a fixed 64 KiB +buffer instead of allocating a complete expanded binary. The 512 MiB per-entry +limit, signed hash comparison and ZIP CRC rejection remain enforced. Seven +management worker/distribution regressions pass, including a real compressed ZIP +fixture that verifies exact-boundary success, expansion-limit rejection and CRC +corruption rejection. Full native package generation remains unverified. diff --git a/product/management/src/service.rs b/product/management/src/service.rs index 5a60266370a..bba895578b6 100644 --- a/product/management/src/service.rs +++ b/product/management/src/service.rs @@ -561,6 +561,23 @@ struct BuildResult { success:bool, artifact_url:String,sha256:String,log:String mod worker_completion_tests { use super::*; #[test] + fn compressed_artifact_hash_checks_expanded_limit_and_crc() { + use std::io::Write; + let payload=vec![42u8;256*1024]; + let mut writer=zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); + writer.start_file("artifact.exe",zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated)).unwrap(); + writer.write_all(&payload).unwrap();let bytes=writer.finish().unwrap().into_inner(); + assert!(bytes.len()512*1024*1024{return Err(bad("Invalid bundle entry type or size"));} + if bounded_artifact_digest(file,512*1024*1024)?!=expected.to_lowercase(){return Err(bad("Bundle binary hash mismatch"));} + } let mut read=|name:&str,limit:u64|->Result,ApiError>{ let file=zip.by_name(name).map_err(|_|bad("Missing bundle file"))?; if file.is_dir() || file.is_symlink() || file.size()>limit{return Err(bad("Invalid bundle entry type or size"));} let mut output=Vec::new();file.take(limit+1).read_to_end(&mut output).map_err(|_|bad("Unreadable bundle"))?; if output.len() as u64>limit{return Err(bad("Bundle entry too large"));}Ok(output) }; - if digest(read(&format!("SwanRemoteSupport-install.{}",release.format),512*1024*1024)?)!=release.sha256.to_lowercase() || digest(read("swan-agent.exe",512*1024*1024)?)!=release.agent_sha256.to_lowercase(){return Err(bad("Bundle binary hash mismatch"));} for (name,expected) in COMPANY_BUNDLE_TEXT_FILES {let expected=bundle_text(expected)?;if read(name,expected.len() as u64)?!=expected{return Err(bad("Bundle script or license mismatch"));}} let profile_bytes=read("company-profile.json",2*1024*1024)?; let profile:SignedEnvelope=serde_json::from_slice(&profile_bytes)?; @@ -922,6 +943,18 @@ fn validate_worker_bundle(bytes:&[u8],job:&Value,release:&Release,company_key:&e Ok(()) } +fn bounded_artifact_digest(mut reader:impl std::io::Read,limit:u64)->Result { + use sha2::{Digest,Sha256}; + let mut hash=Sha256::new();let mut total=0u64;let mut chunk=[0u8;64*1024]; + loop { + let count=reader.read(&mut chunk).map_err(|_|bad("Unreadable bundle"))?; + if count==0 {break;} + total=total.checked_add(count as u64).filter(|size|*size<=limit).ok_or_else(||bad("Bundle entry too large"))?; + hash.update(&chunk[..count]); + } + Ok(hex::encode(hash.finalize())) +} + // Called only after bundle validation, under a blocking task. fn persist_verified_artifact(s:&Store,id:&str,worker:&str,bytes:&[u8])->Result<(),ApiError> { let mut db=s.db.lock().unwrap();let tx=db.transaction()?; From 059f6756b2a06a3f1b0fda4eb6abdb1d540e8b7c Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Fri, 2 Oct 2026 19:42:52 +0200 Subject: [PATCH 103/652] Use pinned certificate roots without runtime package resolution --- deployment/linux/Dockerfile | 5 +++-- deployment/linux/README.md | 8 +++++--- deployment/linux/test_container_startup.py | 2 ++ 3 files changed, 10 insertions(+), 5 deletions(-) diff --git a/deployment/linux/Dockerfile b/deployment/linux/Dockerfile index 10695ecd8ac..6e31dd48d9c 100644 --- a/deployment/linux/Dockerfile +++ b/deployment/linux/Dockerfile @@ -7,9 +7,10 @@ COPY LICENCE ./LICENCE RUN cargo build --manifest-path product/Cargo.toml --locked --release -p swan-management FROM debian:bookworm-slim@sha256:3783cc01769c7b2b1b83a5c5ad96c815348e28ed7da68e2e3687004faa906251 -RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates && rm -rf /var/lib/apt/lists/* && useradd --system --uid 10001 --create-home swan && mkdir -p /var/lib/swan && chown swan:swan /var/lib/swan +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 +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/README.md b/deployment/linux/README.md index b663218eac9..816e745831f 100644 --- a/deployment/linux/README.md +++ b/deployment/linux/README.md @@ -52,9 +52,11 @@ 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. Debian packages installed during the build still come from its -configured repositories; the complete image is not claimed to be bit-for-bit -reproducible across different build dates. +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. Bit-for-bit reproducibility still requires a separate rebuild +comparison and is not established by the deployment tests. From the repository root, with a working Linux Docker engine: diff --git a/deployment/linux/test_container_startup.py b/deployment/linux/test_container_startup.py index 1d5ad6c392a..62e79849f8a 100644 --- a/deployment/linux/test_container_startup.py +++ b/deployment/linux/test_container_startup.py @@ -84,6 +84,8 @@ def main(): 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; ' From a2040123eb3139e8ab011ed8a99cefac9c878f1b Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Fri, 2 Oct 2026 19:46:30 +0200 Subject: [PATCH 104/652] Record HTTPS recovery and both read-only MSI fixture checks --- docs/ACCEPTANCE.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index ef347793a6a..39c1bec65bd 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -750,3 +750,13 @@ limit, signed hash comparison and ZIP CRC rejection remain enforced. Seven management worker/distribution regressions pass, including a real compressed ZIP fixture that verifies exact-boundary success, expansion-limit rejection and CRC corruption rejection. Full native package generation remains unverified. + +At clean source `059f675`, the dedicated HTTPS recovery harness also passed +against the current agent test executable. It verified redirect handling, +artifact tamper/interruption rejection, retention of complete staged bytes, +cleanup and absence of credentials in artifact requests. Both customer and +technician unsigned 1.4.9 MSI fixtures passed the current read-only edition, +version and recovery-plan checks, including wrong-identity rejection and unchanged +package bytes. These checks close the ordinary suite's two skipped fixture tests +for their stated scope; they do not establish current native MSI installation, +trusted production signing, service/task changes or upgrade restoration. From 831c7a87b7257ac95b968a934b5b872102618404 Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Fri, 2 Oct 2026 19:58:57 +0200 Subject: [PATCH 105/652] Refresh ephemeral Docker ports after management restart --- deployment/linux/test_container_startup.py | 13 +++++++++---- docs/ACCEPTANCE.md | 10 ++++++++++ 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/deployment/linux/test_container_startup.py b/deployment/linux/test_container_startup.py index 62e79849f8a..53a0a8f69d0 100644 --- a/deployment/linux/test_container_startup.py +++ b/deployment/linux/test_container_startup.py @@ -62,6 +62,13 @@ def ready(base): 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 main(): image = sys.argv[1] if len(sys.argv) == 2 else "swan-management:test" name = "swan-company-test-" + uuid.uuid4().hex @@ -76,10 +83,7 @@ def main(): "--publish", "127.0.0.1::8080", image) started = True docker("start", 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") - base = "http://" + port + base = container_base(name) ready(base) if docker("exec", name, "id", "-u") != "10001": raise RuntimeError("Management container must run as the service account") @@ -128,6 +132,7 @@ def main(): 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"]: diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index 39c1bec65bd..1bbe2d0e025 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -760,3 +760,13 @@ version and recovery-plan checks, including wrong-identity rejection and unchang package bytes. These checks close the ordinary suite's two skipped fixture tests for their stated scope; they do not establish current native MSI installation, trusted production signing, service/task changes or upgrade restoration. + +The first Linux management image built and its intermediate container rehearsal +passed fresh company setup, non-root/read-only execution, private storage, MFA +and replay rejection, pending enrollment and unattended-policy rejection, clean +SIGTERM shutdown, persisted company/device/consent state and logout denial. +The rehearsal exposed a test bug: Docker changed its dynamically assigned +loopback port after restart. The harness now refreshes and validates that mapping +on every start. This intermediate image required an explicit SSL_CERT_FILE test +environment; the exact `059f675` image and its default certificate environment +remain pending. This evidence does not prove public HTTPS or native transport. From da84b10a8e675f5e18a0c33fd140c6472ce1b1f2 Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Fri, 2 Oct 2026 20:09:10 +0200 Subject: [PATCH 106/652] Verify encrypted management restore in real Linux containers --- deployment/linux/README.md | 9 ++- deployment/linux/test_container_startup.py | 90 ++++++++++++++++++++++ docs/ACCEPTANCE.md | 20 ++++- 3 files changed, 116 insertions(+), 3 deletions(-) diff --git a/deployment/linux/README.md b/deployment/linux/README.md index 816e745831f..228f362b602 100644 --- a/deployment/linux/README.md +++ b/deployment/linux/README.md @@ -70,7 +70,12 @@ 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. Credentials stay in memory and the test removes only its own container -and volume. These are actual Docker checks, but are not evidence for public +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 company-product CI runs both checks. diff --git a/deployment/linux/test_container_startup.py b/deployment/linux/test_container_startup.py index 53a0a8f69d0..d545acc435e 100644 --- a/deployment/linux/test_container_startup.py +++ b/deployment/linux/test_container_startup.py @@ -69,6 +69,95 @@ def container_base(name): 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: + raise RuntimeError("Unexpected encrypted backup/restore command result") + 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)" = SWANBK1') + 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 @@ -140,6 +229,7 @@ def main(): 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.") diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index 1bbe2d0e025..503015d676c 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -84,7 +84,7 @@ remain unverified. | Requirement | Current evidence and remaining work | | --- | --- | | Reproducible server, customer, technician and worker builds | Product components pass pinned builds. Native Windows build/package run 36998413349 passed at exact source `2eec008`. Later native source, including consent/reconnect/version and rollback changes, still needs CI and VM verification. Baseline rebuild and clean-machine installation are not newly proved. | -| Fresh-company Windows/Linux setup | HTTPS/API setup works. Administrator network settings and server-vantage DNS/TCP/HTTPS company-signature diagnostics are implemented with access tests. Equivalent full setup wizards, transport provisioning, certificate management and external reachability checks remain incomplete; see NETWORK_DIAGNOSTICS.md. | +| Fresh-company Windows/Linux setup | HTTPS/API setup works. The pinned Linux management image passes actual non-root/read-only fresh company setup, MFA, approval/policy rejection and restart persistence. Administrator network settings and server-vantage DNS/TCP/HTTPS company-signature diagnostics are implemented with access tests. Equivalent full setup wizards, transport provisioning, certificate management and external reachability checks remain incomplete; see NETWORK_DIAGNOSTICS.md. | | Roles, groups, permissions and revocation | MFA/group/device denial tests pass. Signed capability fields, administrator group policy APIs, and receiver capability bounds are implemented in source. Policy reduction denies lease renewal in component tests. Administration controls, native enforcement and direct-connection bypass tests remain unverified. | | Configurable customer and technician apps | Signed profile sync passes. Graphical login/inventory/history, logo/color rendering, contacts, company shortcuts, offline operation and native restart/upgrade preservation remain to verify or complete. | | Signed profiles and rotation | Component tests reject tampering, wrong-company, expired and older profiles and untrusted key changes. Trusted cached key/endpoint rotation preserves enrollment in tests. Real isolated HTTPS trusted key rotation, missed-transition rejection and restored rotated trust pass for both CLI agents. Graphical installed-app behavior remains unverified. | @@ -770,3 +770,21 @@ loopback port after restart. The harness now refreshes and validates that mappin on every start. This intermediate image required an explicit SSL_CERT_FILE test environment; the exact `059f675` image and its default certificate environment remain pending. This evidence does not prove public HTTPS or native transport. + +The Linux management image built from required inputs at exact source `059f675` +is `sha256:d4308f1b5714a1cd56987af5aa0c3f51db31e64b33c104e2e610638a0786129a`. +Its management executable hash is +`db7716c7dc61a13af14de68dbd4e197b30ca8b565966638e9e8303ec069b5d9b`; +the public certificate bundle matches the pinned builder. The corrected test +driver, maintained separately from the image build inputs, passed default +certificate-path checks, non-root/read-only startup, fresh setup, MFA/replay +rejection, approval/consent policy, graceful SIGTERM and restart persistence. +The expanded driver then passed actual encrypted management CLI export and +restore to a separate volume, wrong-password authentication rejection with no +target writes, private key permissions, retained branding/policy/device/consent, +fresh MFA login, replay/logout denial and revoked-device rejection. Revocation +in the restored deployment did not mutate the original volume. No backup +passphrase was placed in command arguments or Docker container metadata. +These results prove management-container recovery only. Full transport/TLS +volume recovery, public HTTPS/ACME, native sessions, installers and the clean +Windows matrix remain required acceptance gates. From 95944177e9d0975cb218e3d5416c446d31b5658b Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Fri, 2 Oct 2026 20:29:05 +0200 Subject: [PATCH 107/652] Rehearse pinned HTTPS and native rendezvous components in isolation --- .github/workflows/company-product.yml | 2 + deployment/linux/README.md | 16 +- deployment/linux/test_container_stack.py | 286 +++++++++++++++++++++++ docs/ACCEPTANCE.md | 15 ++ 4 files changed, 317 insertions(+), 2 deletions(-) create mode 100644 deployment/linux/test_container_stack.py diff --git a/.github/workflows/company-product.yml b/.github/workflows/company-product.yml index e12cc1277a5..4f2faaa95a3 100644 --- a/.github/workflows/company-product.yml +++ b/.github/workflows/company-product.yml @@ -31,3 +31,5 @@ jobs: 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 diff --git a/deployment/linux/README.md b/deployment/linux/README.md index 228f362b602..eb16472c5e6 100644 --- a/deployment/linux/README.md +++ b/deployment/linux/README.md @@ -58,12 +58,13 @@ Rustls reads that bundle through `SSL_CERT_FILE`; HTTPS certificate verification remains enabled. Bit-for-bit reproducibility still requires a separate rebuild comparison and is not established by the deployment tests. -From the repository root, with a working Linux Docker engine: +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 @@ -78,4 +79,15 @@ 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 company-product CI runs both checks. +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. +Public ACME, UDP, external direct/relay tests and full transport/TLS restore +remain separate gates. The company-product CI runs all three checks. diff --git a/deployment/linux/test_container_stack.py b/deployment/linux/test_container_stack.py new file mode 100644 index 00000000000..d9ddee11b7a --- /dev/null +++ b/deployment/linux/test_container_stack.py @@ -0,0 +1,286 @@ +"""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 json +import pathlib +import re +import secrets +import socket +import ssl +import subprocess +import sys +import tempfile +import time +import urllib.error +import urllib.request +import uuid + +from test_container_startup import authenticator, docker + + +ROOT = pathlib.Path(__file__).resolve().parents[2] + + +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 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 nat_probe(listener): + # 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(("127.0.0.1", 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"\xc2\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(): + image = sys.argv[1] if len(sys.argv) == 2 else "swan-management:test" + proxy_image, transport_image = pinned_images() + 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"]: + nat_probe(port(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]) + 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"]: + nat_probe(port(names["hbbs"], service)) + 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, trust persistence. 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/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index 503015d676c..c74b41d10d4 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -788,3 +788,18 @@ passphrase was placed in command arguments or Docker container metadata. These results prove management-container recovery only. Full transport/TLS volume recovery, public HTTPS/ACME, native sessions, installers and the clean Windows matrix remain required acceptance gates. + +The pinned Caddy `2.10.2` and RustDesk server `1.1.15` images now pass an actual +isolated component-stack rehearsal with the exact `059f675` management image. +The proxy recipe uses a disposable internal issuer with trust installation +disabled; HTTPS validates through an explicit test CA, while default system +trust rejects it. Fresh company setup, MFA replay rejection, pending enrollment +and logout denial pass through HTTPS. OpenSSL verifies actual management profile +signatures and rejects modified payloads and wrong keys. Both rendezvous TCP +ports return real native TestNatResponse messages before and after restart; +relay TCP accepts a local connection. Company signing identity, transport public +key and the configured HTTPS endpoint survive restart. The test removes only +its uniquely named containers, volumes and network. Production recipes, DNS, +firewall and host trust stores were not modified for this rehearsal. +These results do not prove UDP or external NAT traversal, native remote-session +authorization, public ACME issuance/renewal or full transport/TLS-volume restore. From cb66cc157ffc30bcfb076bbb7a7852eef0b27358 Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Fri, 2 Oct 2026 20:48:29 +0200 Subject: [PATCH 108/652] test: rehearse encrypted full-stack archive recovery --- deployment/linux/test_container_stack.py | 50 +++++++++++++++++++++++- 1 file changed, 49 insertions(+), 1 deletion(-) diff --git a/deployment/linux/test_container_stack.py b/deployment/linux/test_container_stack.py index d9ddee11b7a..88117eb101f 100644 --- a/deployment/linux/test_container_stack.py +++ b/deployment/linux/test_container_stack.py @@ -19,12 +19,59 @@ import urllib.request import uuid -from test_container_startup import authenticator, docker +from test_container_startup import authenticator, docker, secret_command ROOT = pathlib.Path(__file__).resolve().parents[2] +def complete_backup(image, prefix, volumes, lab): + """Export stopped components and authenticate a restore into a fresh volume. + + Private keys stay inside Docker volumes; the passphrase travels over stdin. + This checks archive recovery, not replacement-service startup. + """ + management, transport, tls, _ = volumes + restored = prefix + "-archive-restore" + docker("volume", "create", restored) + 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: + 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/complete.swanbackup --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; }") + finally: + 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) @@ -260,6 +307,7 @@ def main(): 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) for role in ["management", "hbbs", "hbbr", "https"]: docker("start", names[role]) endpoint = Endpoint(names["https"], context) From dd6efbfe3217911ea0aca01d6f42441e4d422885 Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Fri, 2 Oct 2026 20:52:06 +0200 Subject: [PATCH 109/652] test: restart recovered Linux company deployment with original trust --- deployment/linux/README.md | 14 +++- deployment/linux/test_container_stack.py | 99 +++++++++++++++++++++++- docs/ACCEPTANCE.md | 16 ++++ docs/SERVER_BACKUP.md | 12 +++ 4 files changed, 135 insertions(+), 6 deletions(-) diff --git a/deployment/linux/README.md b/deployment/linux/README.md index eb16472c5e6..e6b9e58908f 100644 --- a/deployment/linux/README.md +++ b/deployment/linux/README.md @@ -89,5 +89,15 @@ 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. -Public ACME, UDP, external direct/relay tests and full transport/TLS restore -remain separate gates. The company-product CI runs all three checks. +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/test_container_stack.py b/deployment/linux/test_container_stack.py index 88117eb101f..cd1dc71d8b8 100644 --- a/deployment/linux/test_container_stack.py +++ b/deployment/linux/test_container_stack.py @@ -25,15 +25,20 @@ ROOT = pathlib.Path(__file__).resolve().parents[2] -def complete_backup(image, prefix, volumes, lab): +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. - This checks archive recovery, not replacement-service startup. + 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"] @@ -68,7 +73,86 @@ def complete_backup(image, prefix, volumes, lab): "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"]: + nat_probe(port(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) @@ -307,7 +391,8 @@ def main(): 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) + 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) @@ -320,7 +405,13 @@ def main(): endpoint.request("/api/v1/devices", token=administrator, expected=401) for service in ["21115/tcp", "21116/tcp"]: nat_probe(port(names["hbbs"], service)) - 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, trust persistence. Native sessions, UDP traversal, public ACME and external reachability remain unverified.") + 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) diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index c74b41d10d4..4d72037239f 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -803,3 +803,19 @@ its uniquely named containers, volumes and network. Production recipes, DNS, firewall and host trust stores were not modified for this rehearsal. These results do not prove UDP or external NAT traversal, native remote-session authorization, public ACME issuance/renewal or full transport/TLS-volume restore. + +The extended Linux stack rehearsal now passes complete encrypted CLI export of +management, actual transport keys/database, private deployment settings and +Caddy certificate storage, followed by restore into separate volumes. Incorrect +passwords fail before target writes; recovered transport keys, environment and +every TLS file match the originals. Replacement management, rendezvous, relay +and HTTPS containers start using the restored data. The original CA validates +HTTPS, company profile signatures and transport identity persist, and fresh +MFA login, replay/logout denial, pending enrollment, consent and device +revocation pass. The restarted original retains its pending device after the +replacement revokes its own copy. The compiled management image still uses +exact required source inputs at `059f675`; only excluded test drivers changed. +This establishes isolated component recovery, including actual transport and +TLS storage. It does not establish public ACME, external traversal, authorized +remote desktop sessions, populated transport-peer recovery, migration rollback, +native Windows installation or the compatibility matrix. diff --git a/docs/SERVER_BACKUP.md b/docs/SERVER_BACKUP.md index 54c86677336..dd8c14ac0bd 100644 --- a/docs/SERVER_BACKUP.md +++ b/docs/SERVER_BACKUP.md @@ -113,3 +113,15 @@ restored identity; rotated profile trust, device credentials and closed-grant denial survive. Both original/restored fixture processes stop afterward. This remains isolated local TLS evidence; ACME renewal and live transport restore are still unverified. + +The Linux `test_container_stack.py` rehearsal now additionally restores actual +pinned RustDesk transport keys/database and Caddy storage, alongside management +and private deployment settings, from one encrypted archive. It starts replacement +management, rendezvous, relay and HTTPS containers on separate volumes. The +original CA still validates HTTPS, signed company configuration and transport +identity remain unchanged, and enrollment, consent, MFA replay protection, +logout and device revocation survive. The original deployment starts again and +retains its pending device despite revocation in the replacement. Private key +contents never leave the volumes. This supersedes the earlier local transport +restore limitation above; remote desktop sessions, public ACME renewal, +post-backup reconciliation and migration rollback remain unverified. From b978470d9b5ebbd3f4767daa2b05b796ce806ceb Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Fri, 2 Oct 2026 20:56:59 +0200 Subject: [PATCH 110/652] build: prepare pinned Windows transport and HTTPS components --- .../windows/Prepare-ServerComponents.ps1 | 48 +++++++++++++++++++ deployment/windows/SERVER-COMPONENTS.md | 30 ++++++++++++ deployment/windows/server-components.json | 16 +++++++ docs/ACCEPTANCE.md | 11 +++++ 4 files changed, 105 insertions(+) create mode 100644 deployment/windows/Prepare-ServerComponents.ps1 create mode 100644 deployment/windows/SERVER-COMPONENTS.md create mode 100644 deployment/windows/server-components.json diff --git a/deployment/windows/Prepare-ServerComponents.ps1 b/deployment/windows/Prepare-ServerComponents.ps1 new file mode 100644 index 00000000000..ab956df7052 --- /dev/null +++ b/deployment/windows/Prepare-ServerComponents.ps1 @@ -0,0 +1,48 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory)][string]$TransportPackage, + [Parameter(Mandatory)][string]$HttpsPackage, + [Parameter(Mandatory)][string]$OutputDirectory +) +$ErrorActionPreference = 'Stop' +Add-Type -AssemblyName System.IO.Compression.FileSystem +$manifestPath = Join-Path $PSScriptRoot 'server-components.json' +$manifest = Get-Content -LiteralPath $manifestPath -Raw | ConvertFrom-Json +if ($manifest.schema -ne 1 -or $manifest.architecture -ne 'x86_64') { throw 'Unsupported server component manifest.' } +$destination = [IO.Path]::GetFullPath($OutputDirectory) +if (Test-Path -LiteralPath $destination) { throw 'Component output directory already exists.' } +# Verify both archives before writing anything. No supplier executable is run. +$packages = @( + @{ Path=(Resolve-Path -LiteralPath $TransportPackage).Path; Pin=$manifest.transport }, + @{ Path=(Resolve-Path -LiteralPath $HttpsPackage).Path; Pin=$manifest.https } +) +foreach ($package in $packages) { + if ((Get-FileHash -LiteralPath $package.Path -Algorithm SHA256).Hash -ne $package.Pin.sha256) { + throw 'Pinned server component archive hash mismatch.' + } +} +$archives = [Collections.Generic.List[IDisposable]]::new() +$selected = @{} +try { + foreach ($package in $packages) { + $archive = [IO.Compression.ZipFile]::OpenRead($package.Path) + $archives.Add($archive) + foreach ($name in $package.Pin.files) { + if ($name -notin @('hbbs.exe','hbbr.exe','caddy.exe')) { throw 'Unexpected component executable.' } + $matches = @($archive.Entries | Where-Object { ($_.FullName -replace '\\','/' -split '/')[-1] -eq $name }) + if ($matches.Count -ne 1 -or $matches[0].Length -lt 2 -or $matches[0].Length -gt 128MB) { + throw 'Missing, duplicate or oversized server component.' + } + $selected[$name] = $matches[0] + } + } + New-Item -ItemType Directory -Path $destination | Out-Null + foreach ($name in $selected.Keys) { + # Use a fixed basename, never a supplier-controlled archive path. + [IO.Compression.ZipFileExtensions]::ExtractToFile($selected[$name], (Join-Path $destination $name), $false) + } + Copy-Item -LiteralPath $manifestPath -Destination (Join-Path $destination 'server-components.json') + Write-Output $destination +} finally { + foreach ($archive in $archives) { $archive.Dispose() } +} diff --git a/deployment/windows/SERVER-COMPONENTS.md b/deployment/windows/SERVER-COMPONENTS.md new file mode 100644 index 00000000000..165d5d45964 --- /dev/null +++ b/deployment/windows/SERVER-COMPONENTS.md @@ -0,0 +1,30 @@ +# Windows company server components + +`Install-Server.ps1` currently installs the signed management service only. +The combined Windows wizard, HTTPS/transport service supervision and their +installation, repair and uninstall tests are still incomplete. + +`server-components.json` pins the Windows x64 RustDesk server 1.1.15 and Caddy +2.10.2 archives by SHA-256, with upstream source references. Download each exact +URL in that manifest to local storage, then prepare their executables: + +```powershell +./deployment/windows/Prepare-ServerComponents.ps1 ` + -TransportPackage ./rustdesk-server-windows-x86_64-unsigned.zip ` + -HttpsPackage ./caddy_2.10.2_windows_amd64.zip ` + -OutputDirectory ./prepared-server-components +``` + +The destination must be new. Both archive hashes are checked before any output +is created. Only fixed `hbbs.exe`, `hbbr.exe` and `caddy.exe` basenames are +extracted; missing, duplicate or oversized executables are rejected. This does +not execute the supplier setup program, register services, open ports, change +DNS, or replace an existing transport deployment. An interrupted preparation +can leave partial output; use a new destination when retrying. + +Archive hash verification establishes the pinned download identity. It does +not establish a trusted Authenticode publisher or production signing approval. +Production packaging must retain RustDesk AGPL source/license notices and +Caddy Apache-2.0 license/notices, publish exact corresponding project and +transport source, and pass the server lifecycle and Windows compatibility +checks. These prepared files are build inputs, not a complete server installer. diff --git a/deployment/windows/server-components.json b/deployment/windows/server-components.json new file mode 100644 index 00000000000..9f347faf1d3 --- /dev/null +++ b/deployment/windows/server-components.json @@ -0,0 +1,16 @@ +{ + "schema": 1, + "architecture": "x86_64", + "transport": { + "source": "https://github.com/rustdesk/rustdesk-server/tree/9bae9f2f39d92c4b4ba2e28e089da5071897b22e", + "url": "https://github.com/rustdesk/rustdesk-server/releases/download/1.1.15/rustdesk-server-windows-x86_64-unsigned.zip", + "sha256": "541cbe462c0c7f9fe995fea3752108d0bb3a0e0c83a96618ce684bcd6c806242", + "files": ["hbbs.exe", "hbbr.exe"] + }, + "https": { + "source": "https://github.com/caddyserver/caddy/tree/v2.10.2", + "url": "https://github.com/caddyserver/caddy/releases/download/v2.10.2/caddy_2.10.2_windows_amd64.zip", + "sha256": "9fd1ef9be5d9b05852b66ccc25f96f23d8651bcab20779861a745bdffa273722", + "files": ["caddy.exe"] + } +} diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index 4d72037239f..b224a476ed8 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -819,3 +819,14 @@ This establishes isolated component recovery, including actual transport and TLS storage. It does not establish public ACME, external traversal, authorized remote desktop sessions, populated transport-peer recovery, migration rollback, native Windows installation or the compatibility matrix. + +Windows server component preparation now passes with the actual pinned +RustDesk server 1.1.15 and Caddy 2.10.2 Windows archives. Both archive SHA-256 +values match upstream release asset metadata; extraction produces only the +expected component executables and the public manifest. Read-only PE inspection +confirms x64 executables. An invalid archive is rejected before output creation. +All three supplier executables report `NotSigned`; hash pinning does not supply +production Authenticode trust. No supplier installer or component was executed, +and no services or firewall settings changed. Combined Windows server setup, +service supervision, HTTPS provisioning, signing and native lifecycle tests +remain incomplete. From 8cfdd8f81a7831571c96efa6bb6838a222b8dfaa Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Fri, 2 Oct 2026 21:11:27 +0200 Subject: [PATCH 111/652] feat: supervise packaged Windows company server components --- .dockerignore | 1 + deployment/linux/Dockerfile.dockerignore | 1 + deployment/windows/Install-Server.ps1 | 89 +++++- .../windows/Prepare-ServerComponents.ps1 | 9 + deployment/windows/SERVER-COMPONENTS.md | 49 +++- docs/ACCEPTANCE.md | 17 ++ product/Cargo.lock | 1 + product/management/Cargo.toml | 1 + product/management/src/components.rs | 257 ++++++++++++++++++ product/management/src/main.rs | 8 + product/management/src/windows.rs | 11 +- 11 files changed, 430 insertions(+), 14 deletions(-) create mode 100644 product/management/src/components.rs diff --git a/.dockerignore b/.dockerignore index 980bc0c931e..5aed7b50cec 100644 --- a/.dockerignore +++ b/.dockerignore @@ -33,6 +33,7 @@ product/management/src/** !product/management/src/network.rs !product/management/src/backup.rs !product/management/src/windows.rs +!product/management/src/components.rs !product/management/src/tests.rs !product/management/src/schema.sql !product/management/web/ diff --git a/deployment/linux/Dockerfile.dockerignore b/deployment/linux/Dockerfile.dockerignore index 980bc0c931e..5aed7b50cec 100644 --- a/deployment/linux/Dockerfile.dockerignore +++ b/deployment/linux/Dockerfile.dockerignore @@ -33,6 +33,7 @@ product/management/src/** !product/management/src/network.rs !product/management/src/backup.rs !product/management/src/windows.rs +!product/management/src/components.rs !product/management/src/tests.rs !product/management/src/schema.sql !product/management/web/ diff --git a/deployment/windows/Install-Server.ps1 b/deployment/windows/Install-Server.ps1 index 3755773ac4f..e993b7923d5 100644 --- a/deployment/windows/Install-Server.ps1 +++ b/deployment/windows/Install-Server.ps1 @@ -1,5 +1,6 @@ [CmdletBinding()] -param([Parameter(Mandatory)][string]$Executable,[Parameter(Mandatory)][string]$ReleasePublicKey,[int]$Port=8080) +param([Parameter(Mandatory)][string]$Executable,[Parameter(Mandatory)][string]$ReleasePublicKey,[int]$Port=8080, + [string]$ComponentsDirectory,[string]$PublicHostname) $ErrorActionPreference = 'Stop' $identity = [Security.Principal.WindowsIdentity]::GetCurrent() if (-not ([Security.Principal.WindowsPrincipal]::new($identity)).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { throw 'Server installation requires administrator rights.' } @@ -7,19 +8,89 @@ if ($Port -lt 1024 -or $Port -gt 65535) { throw 'Invalid management port.' } $source = (Resolve-Path -LiteralPath $Executable).Path if ((Get-AuthenticodeSignature -LiteralPath $source).Status -ne 'Valid') { throw 'The company server executable requires a trusted release signature.' } if ([Convert]::FromBase64String($ReleasePublicKey).Length -ne 32) { throw 'Expected a 32-byte project release public key.' } +$componentPins = @{ + 'hbbs.exe'='2102e17d32af3ab313a4096d4a4db307984630eccf4f9ed03ef3dfbd4fdb8f83' + 'hbbr.exe'='5b5fe62f5b5f1fa7df521a243cbfadbeca67bd9df80d5017f23cf62aca59257e' + 'caddy.exe'='586d4a4cd74bdfd2951b6b81766a32904ffa69c4f1c3d521da3870a2120f1d31' +} +$fullStack = -not [string]::IsNullOrEmpty($ComponentsDirectory) +if ($fullStack -ne (-not [string]::IsNullOrEmpty($PublicHostname))) { throw 'Specify both prepared components and the public hostname.' } +if ($fullStack) { + if ($PublicHostname.Length -gt 253 -or $PublicHostname -notmatch '\.' -or @($PublicHostname.Split('.') | Where-Object { $_ -notmatch '^[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?$' }).Count) { throw 'Invalid company public hostname.' } + if ($Port -in @(80,443,21115,21116,21117)) { throw 'Management port conflicts with a packaged component.' } + $ComponentsDirectory = (Resolve-Path -LiteralPath $ComponentsDirectory).Path + foreach ($name in $componentPins.Keys) { + $component = Join-Path $ComponentsDirectory $name + if ((Get-Item -LiteralPath $component).Attributes -band [IO.FileAttributes]::ReparsePoint) { throw 'Component must be a regular file.' } + if ((Get-FileHash -LiteralPath $component -Algorithm SHA256).Hash -ne $componentPins[$name]) { throw 'Packaged server component hash mismatch.' } + } + foreach ($name in @('Caddy-LICENSE.txt','RustDesk-LICENSE.txt','THIRD-PARTY.txt','server-components.json')) { + if (-not (Test-Path -LiteralPath (Join-Path $ComponentsDirectory $name) -PathType Leaf)) { throw 'Prepared component license or source notice missing.' } + } + $ports = @(80,443,21115,21116,21117,$Port) + if (@(Get-NetTCPConnection -State Listen -ErrorAction SilentlyContinue | Where-Object LocalPort -in $ports).Count -or + @(Get-NetUDPEndpoint -LocalPort 21116 -ErrorAction SilentlyContinue).Count) { throw 'A required company server port is already in use. Existing services will not be changed.' } + foreach ($rule in @('SwanCompanyServer-TCP','SwanCompanyServer-UDP')) { + if (Get-NetFirewallRule -Name $rule -ErrorAction SilentlyContinue) { throw 'Company firewall rule already exists.' } + } +} $installDirectory = Join-Path $env:ProgramFiles 'Swan Company Server' $dataDirectory = Join-Path $env:ProgramData 'SwanCompanyServer' if (Get-Service -Name SwanCompanyServer -ErrorAction SilentlyContinue) { throw 'Company server already exists. Use the documented backed-up maintenance upgrade procedure.' } -New-Item -ItemType Directory -Force -Path $installDirectory,$dataDirectory | Out-Null +if ((Test-Path -LiteralPath $installDirectory) -or (Test-Path -LiteralPath $dataDirectory)) { throw 'Company install/data directory already exists. Preserve it and use the recovery or maintenance procedure.' } +New-Item -ItemType Directory -Path $installDirectory,$dataDirectory | Out-Null & icacls.exe $dataDirectory '/inheritance:r' '/grant:r' '*S-1-5-18:(OI)(CI)F' '*S-1-5-32-544:(OI)(CI)F' | Out-Null if ($LASTEXITCODE -ne 0) { throw 'Cannot protect server data.' } $destination = Join-Path $installDirectory 'swan-management.exe' Copy-Item -LiteralPath $source -Destination $destination -New-Service -Name SwanCompanyServer -DisplayName 'Swan Remote Support Company Server' -BinaryPathName ('"'+$destination+'" --service') -StartupType Automatic | Out-Null -$serviceRegistry = 'HKLM:/SYSTEM/CurrentControlSet/Services/SwanCompanyServer' -New-ItemProperty -Path $serviceRegistry -Name Environment -PropertyType MultiString -Value @("SWAN_DATA_DIR=$dataDirectory","SWAN_LISTEN=127.0.0.1:$Port","SWAN_RELEASE_PUBLIC_KEY=$ReleasePublicKey") -Force | Out-Null -& sc.exe failure SwanCompanyServer reset= 86400 actions= restart/60000/restart/60000/restart/300000 | Out-Null -Start-Service SwanCompanyServer -Write-Host "Company server installed on localhost:$Port. Put an HTTPS reverse proxy in front of this service." +$serviceEnvironment = @("SWAN_DATA_DIR=$dataDirectory","SWAN_LISTEN=127.0.0.1:$Port","SWAN_RELEASE_PUBLIC_KEY=$ReleasePublicKey") +if ($fullStack) { + $packaged = Join-Path $installDirectory 'components' + New-Item -ItemType Directory -Path $packaged | Out-Null + foreach ($name in $componentPins.Keys) { Copy-Item -LiteralPath (Join-Path $ComponentsDirectory $name) -Destination (Join-Path $packaged $name) } + foreach ($name in @('Caddy-LICENSE.txt','RustDesk-LICENSE.txt','THIRD-PARTY.txt','server-components.json')) { Copy-Item -LiteralPath (Join-Path $ComponentsDirectory $name) -Destination (Join-Path $packaged $name) } + $configuration = "{`n admin off`n}`n$PublicHostname {`n reverse_proxy 127.0.0.1:$Port`n header Strict-Transport-Security max-age=31536000`n request_body {`n max_size 512MB`n }`n}`n" + [IO.File]::WriteAllText((Join-Path $dataDirectory 'Caddyfile'),$configuration,[Text.UTF8Encoding]::new($false)) + $serviceEnvironment += @('SWAN_COMPONENTS=1',"SWAN_PUBLIC_HOST=$PublicHostname") +} +$serviceCreated = $false +$createdRules = @() +try { + New-Service -Name SwanCompanyServer -DisplayName 'Swan Remote Support Company Server' -BinaryPathName ('"'+$destination+'" --service') -StartupType Automatic | Out-Null + $serviceCreated = $true + $serviceRegistry = 'HKLM:/SYSTEM/CurrentControlSet/Services/SwanCompanyServer' + New-ItemProperty -Path $serviceRegistry -Name Environment -PropertyType MultiString -Value $serviceEnvironment -Force | Out-Null + & sc.exe failure SwanCompanyServer reset= 86400 actions= restart/60000/restart/60000/restart/300000 | Out-Null + if ($LASTEXITCODE -ne 0) { throw 'Cannot configure company service recovery.' } + if ($fullStack) { + New-NetFirewallRule -Name 'SwanCompanyServer-TCP' -DisplayName 'Swan company HTTPS and transport' -Direction Inbound -Action Allow -Protocol TCP -LocalPort 80,443,21115,21116,21117 | Out-Null + $createdRules += 'SwanCompanyServer-TCP' + New-NetFirewallRule -Name 'SwanCompanyServer-UDP' -DisplayName 'Swan company rendezvous UDP' -Direction Inbound -Action Allow -Protocol UDP -LocalPort 21116 | Out-Null + $createdRules += 'SwanCompanyServer-UDP' + } + Start-Service SwanCompanyServer + $deadline = [DateTime]::UtcNow.AddSeconds(45) + $healthy = $false + while ([DateTime]::UtcNow -lt $deadline) { + try { $health = Invoke-RestMethod -Uri "http://127.0.0.1:$Port/health" -TimeoutSec 2; $healthy = $health.status -eq 'ok' -and $health.product -eq 'Swan Remote Support' } catch { $healthy = $false } + if ($healthy) { break } + if ((Get-Service -Name SwanCompanyServer).Status -eq 'Stopped') { throw 'Company service stopped during startup. Review protected component logs.' } + Start-Sleep -Milliseconds 250 + } + if (-not $healthy) { throw 'Company management health check timed out.' } +} catch { + foreach ($rule in $createdRules) { Remove-NetFirewallRule -Name $rule -ErrorAction Continue } + if ($serviceCreated) { + Stop-Service -Name SwanCompanyServer -ErrorAction Continue + & sc.exe delete SwanCompanyServer | Out-Null + if ($LASTEXITCODE -ne 0) { Write-Warning 'Failed to remove the newly created company service.' } + } + Write-Warning 'Installation failed. Newly created files and private data remain for inspection; preserve them before retrying.' + throw +} +if ($fullStack) { + Write-Host "Company components installed. Configure company DNS and forward required ports to this server, then open https://$PublicHostname to complete setup." + Write-Host 'HTTPS issuance requires public DNS and inbound reachability. Management health alone does not prove external HTTPS or transport connectivity.' +} else { Write-Host "Company server installed on localhost:$Port. Put an HTTPS reverse proxy in front of this service." } Write-Host "Read the one-time setup token from $dataDirectory/setup-token.txt, then complete the web setup wizard." -Write-Host 'Existing RustDesk and Tailscale services were not changed. Install the parallel public transport using the pinned release in deployment/transport-source.json.' +Write-Host 'Existing RustDesk and Tailscale services were not changed. Public transport setup will fail if its required ports are occupied.' diff --git a/deployment/windows/Prepare-ServerComponents.ps1 b/deployment/windows/Prepare-ServerComponents.ps1 index ab956df7052..c55b017338e 100644 --- a/deployment/windows/Prepare-ServerComponents.ps1 +++ b/deployment/windows/Prepare-ServerComponents.ps1 @@ -8,6 +8,7 @@ $ErrorActionPreference = 'Stop' Add-Type -AssemblyName System.IO.Compression.FileSystem $manifestPath = Join-Path $PSScriptRoot 'server-components.json' $manifest = Get-Content -LiteralPath $manifestPath -Raw | ConvertFrom-Json +$transportLicense = (Resolve-Path -LiteralPath (Join-Path $PSScriptRoot '../../LICENCE')).Path if ($manifest.schema -ne 1 -or $manifest.architecture -ne 'x86_64') { throw 'Unsupported server component manifest.' } $destination = [IO.Path]::GetFullPath($OutputDirectory) if (Test-Path -LiteralPath $destination) { throw 'Component output directory already exists.' } @@ -35,6 +36,11 @@ try { } $selected[$name] = $matches[0] } + if ('caddy.exe' -in $package.Pin.files) { + $licenses = @($archive.Entries | Where-Object FullName -eq 'LICENSE') + if ($licenses.Count -ne 1 -or $licenses[0].Length -lt 1 -or $licenses[0].Length -gt 128KB) { throw 'Pinned HTTPS archive license missing or invalid.' } + $selected['Caddy-LICENSE.txt'] = $licenses[0] + } } New-Item -ItemType Directory -Path $destination | Out-Null foreach ($name in $selected.Keys) { @@ -42,6 +48,9 @@ try { [IO.Compression.ZipFileExtensions]::ExtractToFile($selected[$name], (Join-Path $destination $name), $false) } Copy-Item -LiteralPath $manifestPath -Destination (Join-Path $destination 'server-components.json') + Copy-Item -LiteralPath $transportLicense -Destination (Join-Path $destination 'RustDesk-LICENSE.txt') + $notices = "RustDesk rendezvous/relay: AGPL-3.0, upstream RustDesk contributors.`nCorresponding source: $($manifest.transport.source)`n`nCaddy HTTPS server: Apache-2.0, upstream Caddy contributors.`nSource: $($manifest.https.source)`n`nRetain both license files and these source references with distributions.`n" + [IO.File]::WriteAllText((Join-Path $destination 'THIRD-PARTY.txt'),$notices,[Text.UTF8Encoding]::new($false)) Write-Output $destination } finally { foreach ($archive in $archives) { $archive.Dispose() } diff --git a/deployment/windows/SERVER-COMPONENTS.md b/deployment/windows/SERVER-COMPONENTS.md index 165d5d45964..2405ff0f53d 100644 --- a/deployment/windows/SERVER-COMPONENTS.md +++ b/deployment/windows/SERVER-COMPONENTS.md @@ -1,8 +1,11 @@ # Windows company server components -`Install-Server.ps1` currently installs the signed management service only. -The combined Windows wizard, HTTPS/transport service supervision and their -installation, repair and uninstall tests are still incomplete. +`Install-Server.ps1` installs the signed management service, optionally with +packaged rendezvous, relay and HTTPS components. The service supervises its +own children; component failure stops management, and service stop or a service +process crash terminates its assigned children. Windows process-job lifecycle +tests pass, but clean-machine installation, repair and uninstall are still +unverified. A graphical setup wrapper remains incomplete. `server-components.json` pins the Windows x64 RustDesk server 1.1.15 and Caddy 2.10.2 archives by SHA-256, with upstream source references. Download each exact @@ -17,7 +20,9 @@ URL in that manifest to local storage, then prepare their executables: The destination must be new. Both archive hashes are checked before any output is created. Only fixed `hbbs.exe`, `hbbr.exe` and `caddy.exe` basenames are -extracted; missing, duplicate or oversized executables are rejected. This does +extracted; missing, duplicate or oversized executables are rejected. The output +also retains Caddy's archive license, the AGPL license and public source notices. +This does not execute the supplier setup program, register services, open ports, change DNS, or replace an existing transport deployment. An interrupted preparation can leave partial output; use a new destination when retrying. @@ -28,3 +33,39 @@ Production packaging must retain RustDesk AGPL source/license notices and Caddy Apache-2.0 license/notices, publish exact corresponding project and transport source, and pass the server lifecycle and Windows compatibility checks. These prepared files are build inputs, not a complete server installer. + +For a fresh company deployment, run with administrator rights and a trusted +signed management build containing component supervision: + +```powershell +./deployment/windows/Install-Server.ps1 ` + -Executable ./swan-management.exe ` + -ReleasePublicKey PROJECT_RELEASE_PUBLIC_KEY ` + -ComponentsDirectory ./prepared-server-components ` + -PublicHostname support.example.com +``` + +The script rejects existing service/data/install directories, missing licenses, +modified component executables, invalid hostnames and occupied component ports. +It protects company data, copies the components under the management installation, +writes a Caddy recipe, registers automatic service recovery, and opens TCP +80/443/21115โ€“21117 and UDP 21116 for this deployment. Management stays on loopback. +Configure public company DNS and forwarding before completing the shared web +setup wizard. Public ACME issuance and external transport tests remain required; +the script's management health check proves neither. + +The service rechecks exact executable hashes before launching fixed commands. +Rendezvous and relay share `transport/` beneath company data; relay starts only +after a public transport key is available. Caddy keeps its state beneath `tls/` +and `proxy-config/`, with its administration API disabled. Component logs are in +the protected `logs/` directory. Child processes are terminated and waited for +on service shutdown; this is forced termination, so full backup must wait until +the service has stopped and must snapshot SQLite using the management CLI. +Include the complete transport and TLS directories in encrypted backups. + +`swan-management.exe check-components` validates packaged executable hashes, +hostname and the presence of a Caddyfile without launching services. It creates +component data/log directories under `SWAN_DATA_DIR`; it does not parse the +HTTPS recipe or prove DNS, certificates or connectivity. Set `SWAN_PUBLIC_HOST` +and `SWAN_DATA_DIR` for this preflight. Installation failure removes only its +new service and firewall rules, retaining files/private data for inspection. diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index b224a476ed8..0b7057acbd6 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -830,3 +830,20 @@ production Authenticode trust. No supplier installer or component was executed, and no services or firewall settings changed. Combined Windows server setup, service supervision, HTTPS provisioning, signing and native lifecycle tests remain incomplete. + +Windows management now supports supervised packaged rendezvous, relay and +HTTPS children. The full-stack install script pins their executable hashes, +retains license/source notices, validates the company hostname, rejects +occupied ports and existing data, configures the proxy and private storage, +and checks management startup. Windows GNU management tests pass: 18 ordinary +tests, with two child-process fixtures invoked by lifecycle tests. Actual +Windows child processes are reaped on stop and partial startup failure; +component exit stops management. A process-owner crash test observes its +assigned child terminate through Windows job-object cleanup. Private preflight +with the actual supplier binaries accepts their hashes, and changing one byte +of the relay rejects it without starting services. PowerShell scripts parse, +and preparation preserves upstream licenses/source references. +The privileged installer itself has not run on a clean Windows VM. SCM startup, +firewall rollback, reboot/service recovery, public HTTPS/ACME, live Windows +transport sessions, repair/uninstall and the graphical setup wrapper remain +unverified or incomplete. These process tests do not satisfy those gates. diff --git a/product/Cargo.lock b/product/Cargo.lock index d361333ef5f..c9257e34f81 100644 --- a/product/Cargo.lock +++ b/product/Cargo.lock @@ -1655,6 +1655,7 @@ dependencies = [ "tower-http", "uuid", "windows-service", + "windows-sys 0.61.2", "zip", ] diff --git a/product/management/Cargo.toml b/product/management/Cargo.toml index 8aaac0e5bfa..9490bdb4cff 100644 --- a/product/management/Cargo.toml +++ b/product/management/Cargo.toml @@ -30,6 +30,7 @@ tower-http = { version = "0.6", features = ["limit", "set-header"] } [target.'cfg(windows)'.dependencies] windows-service = "0.8" +windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_Security", "Win32_System_JobObjects", "Win32_System_Threading"] } [dev-dependencies] tower = { version = "0.5", features = ["util"] } diff --git a/product/management/src/components.rs b/product/management/src/components.rs new file mode 100644 index 00000000000..2c16fa33373 --- /dev/null +++ b/product/management/src/components.rs @@ -0,0 +1,257 @@ +use anyhow::{Context, Result}; +use std::{ffi::OsString, future::Future, path::PathBuf, process::Stdio, time::Duration}; +use tokio::{process::{Child, Command}, sync::oneshot}; + +pub(crate) struct Component { + name: &'static str, + executable: PathBuf, + arguments: Vec, + directory: PathBuf, + environment: Vec<(OsString, OsString)>, + log: Option, + ready_key: Option, +} + +#[cfg(windows)] +pub(crate) fn installed_specs() -> Result> { + use std::io::Read; + use sha2::{Digest, Sha256}; + let host = std::env::var("SWAN_PUBLIC_HOST").context("Set company public hostname")?; + anyhow::ensure!(valid_hostname(&host), "Invalid company public hostname"); + let executable = std::env::current_exe()?; + let packaged = executable.parent().context("Management install directory")?.join("components"); + let data = PathBuf::from(std::env::var("SWAN_DATA_DIR").context("Set company data directory")?); + anyhow::ensure!(data.is_absolute(), "Company data directory must be absolute"); + let transport = data.join("transport"); + let tls = data.join("tls"); + let logs = data.join("logs"); + for directory in [&transport, &tls, &logs] {std::fs::create_dir_all(directory)?;} + let config = data.join("Caddyfile"); + anyhow::ensure!(config.is_file(), "Company HTTPS configuration missing"); + let pins = [ + ("hbbs", "2102e17d32af3ab313a4096d4a4db307984630eccf4f9ed03ef3dfbd4fdb8f83"), + ("hbbr", "5b5fe62f5b5f1fa7df521a243cbfadbeca67bd9df80d5017f23cf62aca59257e"), + ("caddy", "586d4a4cd74bdfd2951b6b81766a32904ffa69c4f1c3d521da3870a2120f1d31"), + ]; + let mut result = Vec::new(); + for (name, expected) in pins { + let binary = packaged.join(format!("{name}.exe")); + anyhow::ensure!(std::fs::symlink_metadata(&binary)?.is_file(), "Component must be a regular file"); + let mut input = std::fs::File::open(&binary)?; + let mut hash = Sha256::new(); + let mut buffer = [0u8; 65536]; + loop {let count=input.read(&mut buffer)?;if count==0 {break;}hash.update(&buffer[..count]);} + anyhow::ensure!(hex::encode(hash.finalize())==expected, "Packaged {name} hash mismatch"); + let (arguments, directory, environment, ready_key) = match name { + "hbbs" => (vec!["-r".into(), format!("{host}:21117").into(), "-k".into(), "_".into()], transport.clone(), vec![], Some(transport.join("id_ed25519.pub"))), + "hbbr" => (vec!["-k".into(), "_".into()], transport.clone(), vec![], None), + _ => (vec!["run".into(), "--config".into(), config.as_os_str().to_owned(), "--adapter".into(), "caddyfile".into()], data.clone(), + vec![("XDG_DATA_HOME".into(), tls.clone().into_os_string()), ("XDG_CONFIG_HOME".into(), data.join("proxy-config").into_os_string())], None), + }; + let log = std::fs::OpenOptions::new().create(true).append(true).open(logs.join(format!("{name}.log")))?; + result.push(Component{name, executable:binary, arguments, directory, environment, log:Some(log), ready_key}); + } + Ok(result) +} + +fn valid_hostname(host: &str) -> bool { + host.len()<=253 && host.contains('.') && host.split('.').all(|label| + !label.is_empty() && label.len()<=63 && !label.starts_with('-') && !label.ends_with('-') && + label.bytes().all(|byte|byte.is_ascii_alphanumeric() || byte==b'-')) +} + +#[cfg(windows)] +struct ProcessJob(std::os::windows::io::OwnedHandle); +#[cfg(windows)] +impl ProcessJob { + fn new() -> Result { + use std::os::windows::io::{AsRawHandle, FromRawHandle, OwnedHandle}; + use windows_sys::Win32::System::JobObjects::*; + // An unnamed, non-inheritable job owns only children launched here. + let raw=unsafe {CreateJobObjectW(std::ptr::null(), std::ptr::null())}; + anyhow::ensure!(!raw.is_null(), "Create component process job: {}", std::io::Error::last_os_error()); + let handle=unsafe {OwnedHandle::from_raw_handle(raw)}; + let mut limits:JOBOBJECT_EXTENDED_LIMIT_INFORMATION=unsafe {std::mem::zeroed()}; + limits.BasicLimitInformation.LimitFlags=JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; + let set=unsafe {SetInformationJobObject(handle.as_raw_handle(),JobObjectExtendedLimitInformation, + &limits as *const _ as *const _,std::mem::size_of_val(&limits) as u32)}; + anyhow::ensure!(set!=0, "Configure component process job: {}", std::io::Error::last_os_error()); + Ok(Self(handle)) + } + fn attach(&self, child: &Child) -> Result<()> { + use std::os::windows::io::AsRawHandle; + let handle=child.raw_handle().context("Component exited before job assignment")?; + let assigned=unsafe {windows_sys::Win32::System::JobObjects::AssignProcessToJobObject(self.0.as_raw_handle(),handle)}; + anyhow::ensure!(assigned!=0,"Assign component process job: {}",std::io::Error::last_os_error()); + Ok(()) + } +} + +async fn stop_children(children: &mut [(&'static str, Child)]) -> Result<()> { + let mut failure = None; + for (name, child) in children { + let outcome = async { + if child.try_wait()?.is_none() {child.start_kill()?;} + tokio::time::timeout(Duration::from_secs(10), child.wait()).await??; + Ok::<_, anyhow::Error>(()) + }.await; + if let Err(error)=outcome {failure=Some(error.context(format!("Stop {name}")));} + } + match failure {Some(error)=>Err(error),None=>Ok(())} +} + +pub(crate) async fn supervise(specs: Vec, mut stop: oneshot::Receiver<()>, run: F) -> Result<()> +where F: FnOnce(oneshot::Receiver<()>) -> Fut, Fut: Future> { + #[cfg(windows)] + let job=ProcessJob::new()?; + let mut children = Vec::new(); + let startup = async { + for spec in specs { + let mut command=Command::new(&spec.executable); + command.args(&spec.arguments).current_dir(&spec.directory).envs(spec.environment) + .stdin(Stdio::null()).kill_on_drop(true); + #[cfg(windows)] + command.creation_flags(0x08000000); // CREATE_NO_WINDOW + if let Some(log)=spec.log {command.stderr(log.try_clone()?).stdout(log);}else{command.stderr(Stdio::null()).stdout(Stdio::null());} + let child=command.spawn().with_context(||format!("Start {}",spec.name))?; + children.push((spec.name,child)); + #[cfg(windows)] + job.attach(&children.last().context("Started component missing")?.1)?; + if let Some(key)=spec.ready_key { + use base64::Engine; + let deadline=tokio::time::Instant::now()+Duration::from_secs(30); + loop { + if let Ok(value)=tokio::fs::read_to_string(&key).await { + if base64::engine::general_purpose::STANDARD.decode(value.trim()).is_ok_and(|value|value.len()==32) {break;} + } + if children.last_mut().context("Started component missing")?.1.try_wait()?.is_some() {anyhow::bail!("Component exited before transport key became ready");} + anyhow::ensure!(tokio::time::Instant::now()anyhow::bail!("Service stopped during component startup"),_=tokio::time::sleep(Duration::from_millis(100))=>{}} + } + } + } + Ok::<_,anyhow::Error>(()) + }.await; + if let Err(error)=startup {stop_children(&mut children).await?;return Err(error);} + let (shutdown, receiver)=oneshot::channel(); + let server=run(receiver); + tokio::pin!(server); + let mut monitor=tokio::time::interval(Duration::from_millis(200)); + let mut server_finished=false; + let outcome=loop { + tokio::select! { + result=&mut server=>{server_finished=true;break result;}, + _=&mut stop=>break Ok(()), + _=monitor.tick()=>{ + let mut failure=None; + for (name,child) in &mut children { + match child.try_wait() { + Ok(Some(status))=>{failure=Some(anyhow::anyhow!("Component {name} exited: {status}"));break;}, + Err(error)=>{failure=Some(error.into());break;}, + Ok(None)=>{}, + } + } + if let Some(error)=failure {break Err(error);} + } + } + }; + let _=shutdown.send(()); + let server_stop=if server_finished {Ok(())}else{ + match tokio::time::timeout(Duration::from_secs(10),&mut server).await { + Ok(result)=>result,Err(error)=>Err(anyhow::Error::from(error).context("Management shutdown timed out")), + } + }; + let cleanup=stop_children(&mut children).await; + outcome.and(server_stop).and(cleanup) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn rejects_host_configuration_injection() { + for host in ["localhost", "example.com\n}", "example.com:443", "example.com/path", "-bad.example", "a..example", "example.com;cmd"] {assert!(!valid_hostname(host));} + assert!(valid_hostname("support.example.com")); + } + #[test] + #[ignore = "child fixture invoked by supervision tests"] + fn child_fixture() { + if std::env::var("SWAN_COMPONENT_FIXTURE").as_deref()==Ok("wait") {std::thread::sleep(Duration::from_secs(60));} + } + fn fixture(wait: bool) -> Component { + Component{name:"fixture", executable:std::env::current_exe().unwrap(), + arguments:vec!["--exact".into(), "components::tests::child_fixture".into(), "--ignored".into()], + directory:std::env::current_dir().unwrap(), environment:vec![("SWAN_COMPONENT_FIXTURE".into(),if wait {"wait"}else{"exit"}.into())],log:None,ready_key:None} + } + #[tokio::test] + async fn service_stop_reaps_components() { + let (stop,receiver)=oneshot::channel(); + let result=supervise(vec![fixture(true)],receiver,|shutdown|async {let _=stop.send(());let _=shutdown.await;Ok(())}).await; + assert!(result.is_ok(),"{result:?}"); + } + #[tokio::test] + async fn component_exit_stops_management() { + let (_stop,receiver)=oneshot::channel(); + let result=supervise(vec![fixture(false)],receiver,|shutdown|async {shutdown.await?;Ok(())}).await; + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("Component fixture exited")); + } + #[tokio::test] + async fn partial_start_failure_reaps_started_components() { + let (_stop,receiver)=oneshot::channel(); + let mut missing=fixture(false);missing.executable=std::env::temp_dir().join(format!("missing-swan-component-{}",uuid::Uuid::new_v4())); + let result=supervise(vec![fixture(true),missing],receiver,|_|async {panic!("Management started despite component failure")}).await; + assert!(result.is_err()); + } + #[cfg(windows)] + #[test] + #[ignore = "process-owner fixture invoked by crash containment test"] + fn job_owner_fixture() { + let pid_file=PathBuf::from(std::env::var_os("SWAN_JOB_PID_FILE").unwrap()); + let runtime=tokio::runtime::Runtime::new().unwrap(); + runtime.block_on(async { + let job=ProcessJob::new().unwrap(); + let child=Command::new(std::env::current_exe().unwrap()) + .args(["--exact","components::tests::child_fixture","--ignored"]) + .env("SWAN_COMPONENT_FIXTURE","wait").stdin(Stdio::null()).stdout(Stdio::null()).stderr(Stdio::null()).spawn().unwrap(); + job.attach(&child).unwrap(); + let temporary=pid_file.with_extension("tmp"); + std::fs::write(&temporary,child.id().unwrap().to_string()).unwrap(); + std::fs::rename(temporary,&pid_file).unwrap(); + tokio::time::sleep(Duration::from_secs(60)).await; + drop(job); + }); + } + #[cfg(windows)] + #[test] + fn process_owner_crash_terminates_children() { + use std::os::windows::io::{AsRawHandle,FromRawHandle,OwnedHandle}; + use windows_sys::Win32::System::Threading::{OpenProcess,GetExitCodeProcess,PROCESS_QUERY_LIMITED_INFORMATION}; + let directory=std::env::temp_dir().join(format!("swan-job-test-{}",uuid::Uuid::new_v4())); + std::fs::create_dir(&directory).unwrap(); + let pid_file=directory.join("pid"); + let mut owner=std::process::Command::new(std::env::current_exe().unwrap()) + .args(["--exact","components::tests::job_owner_fixture","--ignored"]) + .env("SWAN_JOB_PID_FILE",&pid_file).stdin(Stdio::null()).stdout(Stdio::null()).stderr(Stdio::null()).spawn().unwrap(); + let deadline=std::time::Instant::now()+Duration::from_secs(10); + while !pid_file.is_file() { + if std::time::Instant::now()>=deadline {let _=owner.kill();let _=owner.wait();panic!("Job owner did not report child");} + std::thread::sleep(Duration::from_millis(20)); + } + let pid=std::fs::read_to_string(&pid_file).unwrap().parse::().unwrap(); + let raw=unsafe {OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION,0,pid)}; + let open=!raw.is_null(); + let child=if open {Some(unsafe {OwnedHandle::from_raw_handle(raw)})}else{None}; + owner.kill().unwrap();owner.wait().unwrap(); + assert!(open,"Cannot observe supervised child"); + let child=child.unwrap(); + loop { + let mut exit=259; + assert_ne!(unsafe {GetExitCodeProcess(child.as_raw_handle(),&mut exit)},0); + if exit!=259 {break;} + assert!(std::time::Instant::now() Result<()> { + #[cfg(windows)] + if std::env::args().nth(1).as_deref()==Some("check-components") { + components::installed_specs()?; + println!("Pinned company executables and hostname validated; HTTPS configuration found; no services started."); + return Ok(()); + } #[cfg(windows)] if std::env::args().any(|a|a=="--service") {return windows::dispatch();} // This is the executable entrypoint, not a helper within an existing runtime. diff --git a/product/management/src/windows.rs b/product/management/src/windows.rs index 186d99800c1..fe7b0cadf52 100644 --- a/product/management/src/windows.rs +++ b/product/management/src/windows.rs @@ -15,7 +15,16 @@ fn service_main(_arguments:Vec) { 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(super::run(receiver))); + 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}");} } From 07d08b2c9125031e564725b7bba5966c9575944f Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Fri, 2 Oct 2026 21:23:30 +0200 Subject: [PATCH 112/652] feat: remove owned Windows server installation while preserving company data --- .github/workflows/company-product.yml | 7 +++ deployment/windows/Get-ServerRemovalPlan.ps1 | 35 +++++++++++++++ deployment/windows/Install-Server.ps1 | 14 ++++-- deployment/windows/SERVER-COMPONENTS.md | 20 +++++++++ deployment/windows/Uninstall-Server.ps1 | 31 +++++++++++++ .../windows/test_server_removal_plan.ps1 | 44 +++++++++++++++++++ docs/ACCEPTANCE.md | 13 ++++++ 7 files changed, 160 insertions(+), 4 deletions(-) create mode 100644 deployment/windows/Get-ServerRemovalPlan.ps1 create mode 100644 deployment/windows/Uninstall-Server.ps1 create mode 100644 deployment/windows/test_server_removal_plan.ps1 diff --git a/.github/workflows/company-product.yml b/.github/workflows/company-product.yml index 4f2faaa95a3..935f701c0c9 100644 --- a/.github/workflows/company-product.yml +++ b/.github/workflows/company-product.yml @@ -21,6 +21,13 @@ jobs: run: cargo +1.90.0 test --manifest-path product/Cargo.toml --workspace --locked - name: Build components run: cargo +1.90.0 build --manifest-path product/Cargo.toml --workspace --release --locked + - 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.' } linux-deployment: runs-on: ubuntu-24.04 steps: diff --git a/deployment/windows/Get-ServerRemovalPlan.ps1 b/deployment/windows/Get-ServerRemovalPlan.ps1 new file mode 100644 index 00000000000..7ce096bd903 --- /dev/null +++ b/deployment/windows/Get-ServerRemovalPlan.ps1 @@ -0,0 +1,35 @@ +[CmdletBinding()] +param([Parameter(Mandatory)][string]$InstallDirectory,[Parameter(Mandatory)][string]$DataDirectory, + [Parameter(Mandatory)][string]$ServiceBinary) +$ErrorActionPreference = 'Stop' +$install = [IO.Path]::GetFullPath($InstallDirectory) +$data = [IO.Path]::GetFullPath($DataDirectory) +if ([IO.Path]::GetFileName($install) -ne 'Swan Company Server' -or [IO.Path]::GetFileName($data) -ne 'SwanCompanyServer') { throw 'Unexpected company installation directory.' } +$expected = '"'+(Join-Path $install 'swan-management.exe')+'" --service' +if (-not [string]::Equals($ServiceBinary.Trim(),$expected,[StringComparison]::OrdinalIgnoreCase)) { throw 'Company service executable does not match this installation.' } +foreach ($directory in @($install,$data,(Join-Path $install 'components'))) { + if (Test-Path -LiteralPath $directory) { + if ((Get-Item -LiteralPath $directory -Force).Attributes -band [IO.FileAttributes]::ReparsePoint) { throw 'Company installation directory is a reparse point.' } + } +} +$receiptPath = Join-Path $data 'installation.json' +if ((Get-Item -LiteralPath $receiptPath -Force).Attributes -band [IO.FileAttributes]::ReparsePoint) { throw 'Company receipt is a reparse point.' } +$receipt = Get-Content -LiteralPath $receiptPath -Raw | ConvertFrom-Json +if ($receipt.schema -ne 1 -or $receipt.service_name -ne 'SwanCompanyServer' -or $receipt.full_stack -isnot [bool] -or + -not [string]::Equals($receipt.install_directory,$install,[StringComparison]::OrdinalIgnoreCase) -or + -not [string]::Equals($receipt.data_directory,$data,[StringComparison]::OrdinalIgnoreCase)) { throw 'Company installation receipt does not match.' } +$rules = @($receipt.firewall_rules) +$expectedRules = @() +if ($receipt.full_stack) { $expectedRules = @('SwanCompanyServer-TCP','SwanCompanyServer-UDP') } +if ($rules.Count -ne $expectedRules.Count -or @(Compare-Object $rules $expectedRules).Count) { throw 'Unexpected company firewall ownership.' } +$files = @(Join-Path $install 'swan-management.exe') +if ($receipt.full_stack) { + foreach ($name in @('hbbs.exe','hbbr.exe','caddy.exe','Caddy-LICENSE.txt','RustDesk-LICENSE.txt','THIRD-PARTY.txt','server-components.json')) { $files += Join-Path (Join-Path $install 'components') $name } +} +foreach ($file in $files) { + if (Test-Path -LiteralPath $file) { + $item = Get-Item -LiteralPath $file -Force + if ($item.PSIsContainer -or ($item.Attributes -band [IO.FileAttributes]::ReparsePoint)) { throw 'Unexpected company installation file type.' } + } +} +[pscustomobject]@{Files=$files; FirewallRules=$rules; InstallDirectory=$install; DataDirectory=$data; FullStack=$receipt.full_stack} diff --git a/deployment/windows/Install-Server.ps1 b/deployment/windows/Install-Server.ps1 index e993b7923d5..cb7ff0abb72 100644 --- a/deployment/windows/Install-Server.ps1 +++ b/deployment/windows/Install-Server.ps1 @@ -1,12 +1,15 @@ [CmdletBinding()] -param([Parameter(Mandatory)][string]$Executable,[Parameter(Mandatory)][string]$ReleasePublicKey,[int]$Port=8080, +param([Parameter(Mandatory)][string]$Executable,[Parameter(Mandatory)][string]$ReleasePublicKey, + [Parameter(Mandatory)][ValidatePattern('^[A-Fa-f0-9]{40}$')][string]$PublisherThumbprint,[int]$Port=8080, [string]$ComponentsDirectory,[string]$PublicHostname) $ErrorActionPreference = 'Stop' $identity = [Security.Principal.WindowsIdentity]::GetCurrent() if (-not ([Security.Principal.WindowsPrincipal]::new($identity)).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { throw 'Server installation requires administrator rights.' } if ($Port -lt 1024 -or $Port -gt 65535) { throw 'Invalid management port.' } $source = (Resolve-Path -LiteralPath $Executable).Path -if ((Get-AuthenticodeSignature -LiteralPath $source).Status -ne 'Valid') { throw 'The company server executable requires a trusted release signature.' } +$signature = Get-AuthenticodeSignature -LiteralPath $source +if ($signature.Status -ne 'Valid') { throw 'The company server executable requires a trusted release signature.' } +if ($signature.SignerCertificate.Thumbprint -ne $PublisherThumbprint) { throw 'Company server signature publisher does not match the configured certificate.' } if ([Convert]::FromBase64String($ReleasePublicKey).Length -ne 32) { throw 'Expected a 32-byte project release public key.' } $componentPins = @{ 'hbbs.exe'='2102e17d32af3ab313a4096d4a4db307984630eccf4f9ed03ef3dfbd4fdb8f83' @@ -63,9 +66,9 @@ try { & sc.exe failure SwanCompanyServer reset= 86400 actions= restart/60000/restart/60000/restart/300000 | Out-Null if ($LASTEXITCODE -ne 0) { throw 'Cannot configure company service recovery.' } if ($fullStack) { - New-NetFirewallRule -Name 'SwanCompanyServer-TCP' -DisplayName 'Swan company HTTPS and transport' -Direction Inbound -Action Allow -Protocol TCP -LocalPort 80,443,21115,21116,21117 | Out-Null + New-NetFirewallRule -Name 'SwanCompanyServer-TCP' -Group 'SwanCompanyServer' -DisplayName 'Swan company HTTPS and transport' -Direction Inbound -Action Allow -Protocol TCP -LocalPort 80,443,21115,21116,21117 | Out-Null $createdRules += 'SwanCompanyServer-TCP' - New-NetFirewallRule -Name 'SwanCompanyServer-UDP' -DisplayName 'Swan company rendezvous UDP' -Direction Inbound -Action Allow -Protocol UDP -LocalPort 21116 | Out-Null + New-NetFirewallRule -Name 'SwanCompanyServer-UDP' -Group 'SwanCompanyServer' -DisplayName 'Swan company rendezvous UDP' -Direction Inbound -Action Allow -Protocol UDP -LocalPort 21116 | Out-Null $createdRules += 'SwanCompanyServer-UDP' } Start-Service SwanCompanyServer @@ -78,6 +81,9 @@ try { Start-Sleep -Milliseconds 250 } if (-not $healthy) { throw 'Company management health check timed out.' } + $receipt = [ordered]@{schema=1; service_name='SwanCompanyServer'; install_directory=[IO.Path]::GetFullPath($installDirectory); + data_directory=[IO.Path]::GetFullPath($dataDirectory); full_stack=$fullStack; firewall_rules=@($createdRules)} + [IO.File]::WriteAllText((Join-Path $dataDirectory 'installation.json'),($receipt | ConvertTo-Json -Depth 4),[Text.UTF8Encoding]::new($false)) } catch { foreach ($rule in $createdRules) { Remove-NetFirewallRule -Name $rule -ErrorAction Continue } if ($serviceCreated) { diff --git a/deployment/windows/SERVER-COMPONENTS.md b/deployment/windows/SERVER-COMPONENTS.md index 2405ff0f53d..e54d44940da 100644 --- a/deployment/windows/SERVER-COMPONENTS.md +++ b/deployment/windows/SERVER-COMPONENTS.md @@ -41,12 +41,15 @@ signed management build containing component supervision: ./deployment/windows/Install-Server.ps1 ` -Executable ./swan-management.exe ` -ReleasePublicKey PROJECT_RELEASE_PUBLIC_KEY ` + -PublisherThumbprint TRUSTED_RELEASE_CERTIFICATE_THUMBPRINT ` -ComponentsDirectory ./prepared-server-components ` -PublicHostname support.example.com ``` The script rejects existing service/data/install directories, missing licenses, modified component executables, invalid hostnames and occupied component ports. +The management signature must be valid and match the explicitly configured +certificate thumbprint from the trusted project or company release policy. It protects company data, copies the components under the management installation, writes a Caddy recipe, registers automatic service recovery, and opens TCP 80/443/21115โ€“21117 and UDP 21116 for this deployment. Management stays on loopback. @@ -69,3 +72,20 @@ component data/log directories under `SWAN_DATA_DIR`; it does not parse the HTTPS recipe or prove DNS, certificates or connectivity. Set `SWAN_PUBLIC_HOST` and `SWAN_DATA_DIR` for this preflight. Installation failure removes only its new service and firewall rules, retaining files/private data for inspection. + +## Removal + +Run `Uninstall-Server.ps1` with administrator rights to remove this company +service, its owned firewall rules and the known packaged files. `-WhatIf` checks +the removal plan without changing them. The installer records public ownership +metadata in the protected `installation.json`; removal refuses a different +service executable, company directory, reparse point or unexpected firewall +ownership. It does not recursively delete installation directories or remove +unknown files. Company database, configuration, enrollment, keys, TLS storage, +logs and receipt remain in ProgramData for recovery. + +Existing installations without this receipt need an administrator-reviewed +maintenance procedure. A fresh install deliberately refuses retained company +data, so do not delete it merely to get past setup. Native removal, reinstallation +and restore tests still require clean Windows VMs; removal-plan fixtures prove +ownership rejection and exclusion of data, not successful SCM/firewall removal. diff --git a/deployment/windows/Uninstall-Server.ps1 b/deployment/windows/Uninstall-Server.ps1 new file mode 100644 index 00000000000..15a2df142b8 --- /dev/null +++ b/deployment/windows/Uninstall-Server.ps1 @@ -0,0 +1,31 @@ +[CmdletBinding(SupportsShouldProcess=$true)] +param() +$ErrorActionPreference = 'Stop' +$identity = [Security.Principal.WindowsIdentity]::GetCurrent() +if (-not ([Security.Principal.WindowsPrincipal]::new($identity)).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { throw 'Server removal requires administrator rights.' } +$install = Join-Path $env:ProgramFiles 'Swan Company Server' +$data = Join-Path $env:ProgramData 'SwanCompanyServer' +$service = Get-CimInstance Win32_Service -Filter "Name='SwanCompanyServer'" +if (-not $service) { throw 'Company service is not installed. Retained files/data require the recovery procedure.' } +$plan = & (Join-Path $PSScriptRoot 'Get-ServerRemovalPlan.ps1') -InstallDirectory $install -DataDirectory $data -ServiceBinary $service.PathName +# Check rule ownership before stopping anything. Never remove unrelated rules. +foreach ($name in $plan.FirewallRules) { + $rule = Get-NetFirewallRule -Name $name -ErrorAction SilentlyContinue + if ($rule -and $rule.Group -ne 'SwanCompanyServer') { throw 'Company firewall rule ownership changed.' } +} +if ($PSCmdlet.ShouldProcess('SwanCompanyServer','Remove company service, owned firewall rules and packaged files; retain company data')) { + Stop-Service -Name SwanCompanyServer + (Get-Service -Name SwanCompanyServer).WaitForStatus([ServiceProcess.ServiceControllerStatus]::Stopped,[TimeSpan]::FromSeconds(30)) + & sc.exe delete SwanCompanyServer | Out-Null + if ($LASTEXITCODE -ne 0) { throw 'Company service removal failed.' } + foreach ($name in $plan.FirewallRules) { + if (Get-NetFirewallRule -Name $name -ErrorAction SilentlyContinue) { Remove-NetFirewallRule -Name $name } + } + foreach ($file in $plan.Files) { if (Test-Path -LiteralPath $file) { Remove-Item -LiteralPath $file } } + # Nonrecursive removal leaves any unexpected files untouched. + foreach ($directory in @((Join-Path $install 'components'),$install)) { + if ((Test-Path -LiteralPath $directory) -and -not @(Get-ChildItem -LiteralPath $directory -Force).Count) { Remove-Item -LiteralPath $directory } + } + Write-Host "Company service removed. Company identity, database, transport keys, TLS storage and logs remain in $data." + Write-Host 'Preserve an encrypted backup before reinstalling or restoring. Other RustDesk and Tailscale services were not changed.' +} diff --git a/deployment/windows/test_server_removal_plan.ps1 b/deployment/windows/test_server_removal_plan.ps1 new file mode 100644 index 00000000000..b3c9170572b --- /dev/null +++ b/deployment/windows/test_server_removal_plan.ps1 @@ -0,0 +1,44 @@ +$ErrorActionPreference = 'Stop' +$root = Join-Path ([IO.Path]::GetTempPath()) ('swan-server-removal-'+[guid]::NewGuid().ToString('N')) +$install = Join-Path $root 'Swan Company Server' +$data = Join-Path $root 'SwanCompanyServer' +New-Item -ItemType Directory -Path $root,$install,$data | Out-Null +$receiptPath = Join-Path $data 'installation.json' +$database = Join-Path $data 'management.sqlite3' +$binary = '"'+(Join-Path $install 'swan-management.exe')+'" --service' +$receipt = @{schema=1;service_name='SwanCompanyServer';install_directory=$install;data_directory=$data;full_stack=$true;firewall_rules=@('SwanCompanyServer-TCP','SwanCompanyServer-UDP')} +function Save-Receipt { [IO.File]::WriteAllText($receiptPath,($receipt | ConvertTo-Json -Depth 4)) } +function Plan { & (Join-Path $PSScriptRoot 'Get-ServerRemovalPlan.ps1') -InstallDirectory $install -DataDirectory $data -ServiceBinary $binary } +function Reject-Plan { $rejected=$false; try { Plan | Out-Null } catch {$rejected=$true}; if (-not $rejected) {throw 'Unsafe removal plan accepted'} } +try { + [IO.File]::WriteAllText($database,'identity preservation fixture') + Save-Receipt + $plan = Plan + if ($plan.Files.Count -ne 8 -or @($plan.Files | Where-Object {$_ -like ($data+'*')}).Count) {throw 'Removal includes company data or omits expected binaries'} + $saved=$binary; $binary='"C:\Windows\System32\svchost.exe" --service'; Reject-Plan; $binary=$saved + $binary=$saved+' --unexpected'; Reject-Plan; $binary=$saved + $receipt.firewall_rules=@('Unrelated-Firewall-Rule'); Save-Receipt; Reject-Plan + $receipt.firewall_rules=@('SwanCompanyServer-TCP','SwanCompanyServer-TCP'); Save-Receipt; Reject-Plan + $receipt.firewall_rules=@('SwanCompanyServer-TCP','SwanCompanyServer-UDP'); $receipt.data_directory=Join-Path $root 'other-company'; Save-Receipt; Reject-Plan + $receipt.data_directory=$data; $receipt.full_stack='false'; Save-Receipt; Reject-Plan + $receipt.full_stack=$false; $receipt.firewall_rules=@(); Save-Receipt; $plan=Plan + if ($plan.Files.Count -ne 1 -or $plan.FirewallRules.Count) {throw 'Management-only removal owns component resources'} + $executableDirectory=Join-Path $install 'swan-management.exe' + New-Item -ItemType Directory -Path $executableDirectory | Out-Null + try { Reject-Plan } finally { [IO.Directory]::Delete($executableDirectory) } + if ($env:OS -eq 'Windows_NT') { + $target=Join-Path $root 'unrelated-directory' + $junction=Join-Path $install 'components' + New-Item -ItemType Directory -Path $target | Out-Null + try { + New-Item -ItemType Junction -Path $junction -Target $target | Out-Null + try { Reject-Plan } finally { [IO.Directory]::Delete($junction) } + if (-not (Test-Path -LiteralPath $target -PathType Container)) {throw 'Planning or junction cleanup removed its target'} + } finally { [IO.Directory]::Delete($target) } + } + if ([IO.File]::ReadAllText($database) -ne 'identity preservation fixture') {throw 'Planning changed company identity'} + Write-Output 'PASS: removal ownership checks reject wrong executable/arguments, company, rule names, duplicates, policy types, directories and junctions; company data is excluded.' +} finally { + foreach ($file in @($receiptPath,$database)) {if (Test-Path -LiteralPath $file) {Remove-Item -LiteralPath $file}} + foreach ($directory in @($install,$data,$root)) {Remove-Item -LiteralPath $directory} +} diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index 0b7057acbd6..a3cc3b56725 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -847,3 +847,16 @@ The privileged installer itself has not run on a clean Windows VM. SCM startup, firewall rollback, reboot/service recovery, public HTTPS/ACME, live Windows transport sessions, repair/uninstall and the graphical setup wrapper remain unverified or incomplete. These process tests do not satisfy those gates. + +Server installation now records public ownership metadata in protected company +storage. `Uninstall-Server.ps1` validates that receipt and exact service command, +refuses redirected directories/files and unexpected firewall ownership, then +removes the company service, its rules and known packaged files while retaining +all company data. Removal-plan checks pass under both PowerShell 7 and Windows +PowerShell 5.1, including wrong executable/arguments, company paths, rule names, +duplicate rules, invalid policy types, a directory substituted for a file and +an actual directory junction. The fixtures preserve their identity data and do +not run SCM or firewall operations. The installer additionally requires a valid +management signature matching an explicit trusted certificate thumbprint. +Native privileged install/uninstall, retained-data reinstallation and full +Windows compatibility remain unverified; receipt planning is not their proof. From 87830e5ff052f64f5186a72ebb0d92e2a31da067 Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Fri, 2 Oct 2026 21:33:51 +0200 Subject: [PATCH 113/652] feat: add graphical Windows company server setup --- .github/workflows/company-product.yml | 6 + deployment/windows/SERVER-COMPONENTS.md | 23 ++- deployment/windows/Setup-Server.ps1 | 184 ++++++++++++++++++++++++ docs/ACCEPTANCE.md | 22 +++ 4 files changed, 234 insertions(+), 1 deletion(-) create mode 100644 deployment/windows/Setup-Server.ps1 diff --git a/.github/workflows/company-product.yml b/.github/workflows/company-product.yml index 935f701c0c9..9ff915e760c 100644 --- a/.github/workflows/company-product.yml +++ b/.github/workflows/company-product.yml @@ -28,6 +28,12 @@ jobs: ./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: 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.' } linux-deployment: runs-on: ubuntu-24.04 steps: diff --git a/deployment/windows/SERVER-COMPONENTS.md b/deployment/windows/SERVER-COMPONENTS.md index e54d44940da..b31e0379d60 100644 --- a/deployment/windows/SERVER-COMPONENTS.md +++ b/deployment/windows/SERVER-COMPONENTS.md @@ -5,7 +5,8 @@ packaged rendezvous, relay and HTTPS components. The service supervises its own children; component failure stops management, and service stop or a service process crash terminates its assigned children. Windows process-job lifecycle tests pass, but clean-machine installation, repair and uninstall are still -unverified. A graphical setup wrapper remains incomplete. +unverified. `Setup-Server.ps1` supplies the graphical setup flow; a signed +packaged launcher and clean-machine wizard installation remain incomplete. `server-components.json` pins the Windows x64 RustDesk server 1.1.15 and Caddy 2.10.2 archives by SHA-256, with upstream source references. Download each exact @@ -34,6 +35,26 @@ Caddy Apache-2.0 license/notices, publish exact corresponding project and transport source, and pass the server lifecycle and Windows compatibility checks. These prepared files are build inputs, not a complete server installer. +Open the graphical wizard from an administrator Windows PowerShell session: + +```powershell +powershell.exe -STA -File ./deployment/windows/Setup-Server.ps1 +``` + +It collects the company hostname, trusted release key and publisher thumbprint, +signed management executable and prepared components. Installation runs in a +background PowerShell instance with structured parameters, so the form stays +responsive; it prevents closing while installation runs. After success, open +company HTTPS setup to configure branding, administrator MFA, accounts, +enrollment and update policy. No secrets are embedded in the wizard or copied +to public installation bundles. Without administrator rights, installation is +disabled. The wizard does not request elevation automatically. + +For layout inspection, `-RenderPreview OUTPUT.png` renders the form without +showing it, starting services or enabling installation/browser actions. The +preview is layout evidence; it does not test privileged installation or backend +completion/error events. Those require the clean Windows test matrix. + For a fresh company deployment, run with administrator rights and a trusted signed management build containing component supervision: diff --git a/deployment/windows/Setup-Server.ps1 b/deployment/windows/Setup-Server.ps1 new file mode 100644 index 00000000000..61ff14c1ddc --- /dev/null +++ b/deployment/windows/Setup-Server.ps1 @@ -0,0 +1,184 @@ +[CmdletBinding()] +param([string]$RenderPreview) +$ErrorActionPreference = 'Stop' +Add-Type -AssemblyName System.Windows.Forms +Add-Type -AssemblyName System.Drawing +[Windows.Forms.Application]::EnableVisualStyles() +if ([Threading.Thread]::CurrentThread.ApartmentState -ne 'STA') { throw 'Run this wizard using Windows PowerShell with -STA.' } +$identity = [Security.Principal.WindowsIdentity]::GetCurrent() +$administrator = ([Security.Principal.WindowsPrincipal]::new($identity)).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) +$script:operation = $null +$script:setupUrl = $null +$form = [Windows.Forms.Form]::new() +$form.Text = 'Swan Remote Support - Company server setup' +$form.ClientSize = [Drawing.Size]::new(820,740) +$form.MinimumSize = $form.Size +$form.MaximumSize = $form.Size +$form.StartPosition = 'CenterScreen' +$form.Font = [Drawing.Font]::new('Segoe UI',10) +$form.BackColor = [Drawing.Color]::White +$form.MaximizeBox = $false +$title = [Windows.Forms.Label]::new() +$title.Text = 'Swan Remote Support' +$title.Font = [Drawing.Font]::new('Segoe UI',22,[Drawing.FontStyle]::Bold) +$title.ForeColor = [Drawing.Color]::FromArgb(0,125,135) +$title.SetBounds(24,18,760,45) +$form.Controls.Add($title) +$intro = [Windows.Forms.Label]::new() +$intro.Text = 'Set up your company server, then configure your company in the HTTPS setup page.' +$intro.SetBounds(26,70,760,38) +$form.Controls.Add($intro) +function Add-Field([string]$Label,[int]$Y,[string]$Help,[bool]$Browse=$false) { + $caption = [Windows.Forms.Label]::new() + $caption.Text = $Label + $caption.SetBounds(26,$Y,760,22) + $form.Controls.Add($caption) + $input = [Windows.Forms.TextBox]::new() + $width = 768 + if ($Browse) {$width=674} + $input.SetBounds(26,($Y+24),$width,28) + $form.Controls.Add($input) + $hint = [Windows.Forms.Label]::new() + $hint.Text = $Help + $hint.ForeColor = [Drawing.Color]::DimGray + $hint.Font = [Drawing.Font]::new('Segoe UI',9) + $hint.SetBounds(26,($Y+54),768,24) + $form.Controls.Add($hint) + return $input +} +$hostInput = Add-Field 'Company public hostname' 116 'For example, support.example.com. Configure DNS and public port forwarding for this server.' +$keyInput = Add-Field 'Trusted project release public key' 195 'Use the base64 release key supplied through your trusted project or company release policy.' +$publisherInput = Add-Field 'Trusted management publisher certificate thumbprint' 274 'Use the certificate fingerprint from your trusted release policy, not from an unverified download.' +$executableInput = Add-Field 'Signed management executable' 353 'Select the signed swan-management.exe from your reviewed release.' $true +$componentsInput = Add-Field 'Prepared server components folder' 432 'Contains pinned rendezvous, relay, HTTPS executables and their license/source notices.' $true +$fileButton = [Windows.Forms.Button]::new() +$fileButton.Text = 'Browse...' +$fileButton.SetBounds(714,377,80,28) +$fileButton.Add_Click({ + $dialog = [Windows.Forms.OpenFileDialog]::new() + $dialog.Filter = 'Management executable (*.exe)|*.exe' + try {if ($dialog.ShowDialog($form) -eq 'OK') {$executableInput.Text=$dialog.FileName}} finally {$dialog.Dispose()} +}) +$form.Controls.Add($fileButton) +$folderButton = [Windows.Forms.Button]::new() +$folderButton.Text = 'Browse...' +$folderButton.SetBounds(714,456,80,28) +$folderButton.Add_Click({ + $dialog = [Windows.Forms.FolderBrowserDialog]::new() + $dialog.Description = 'Select the prepared server components folder' + try {if ($dialog.ShowDialog($form) -eq 'OK') {$componentsInput.Text=$dialog.SelectedPath}} finally {$dialog.Dispose()} +}) +$form.Controls.Add($folderButton) +$status = [Windows.Forms.Label]::new() +$status.Text = 'Company branding, administrator MFA, device policy and update settings follow in web setup.' +if (-not $administrator) {$status.Text='Open this wizard as administrator to install. You can review setup here without installing.'} +if ($RenderPreview) {$status.Text='Preview only. Installation and browser actions are disabled.'} +$status.SetBounds(26,520,768,42) +$form.Controls.Add($status) +$log = [Windows.Forms.TextBox]::new() +$log.Multiline = $true +$log.ReadOnly = $true +$log.ScrollBars = 'Vertical' +$log.SetBounds(26,568,768,80) +$form.Controls.Add($log) +$progress = [Windows.Forms.ProgressBar]::new() +$progress.SetBounds(26,661,768,8) +$form.Controls.Add($progress) +$installButton = [Windows.Forms.Button]::new() +$installButton.Text = 'Install company server' +$installButton.SetBounds(26,687,205,32) +$installButton.BackColor = [Drawing.Color]::FromArgb(0,125,135) +$installButton.ForeColor = [Drawing.Color]::White +$installButton.FlatStyle = 'Flat' +$installButton.Enabled = $administrator -and -not $RenderPreview +$form.Controls.Add($installButton) +$webButton = [Windows.Forms.Button]::new() +$webButton.Text = 'Open company setup' +$webButton.SetBounds(244,687,205,32) +$webButton.Enabled = $false +$webButton.Add_Click({ + if ($script:setupUrl -and -not $RenderPreview) { + $start = [Diagnostics.ProcessStartInfo]::new($script:setupUrl) + $start.UseShellExecute = $true + [Diagnostics.Process]::Start($start) | Out-Null + } +}) +$form.Controls.Add($webButton) +$closeButton = [Windows.Forms.Button]::new() +$closeButton.Text = 'Close' +$closeButton.SetBounds(689,687,105,32) +$closeButton.Add_Click({$form.Close()}) +$form.Controls.Add($closeButton) +$timer = [Windows.Forms.Timer]::new() +$timer.Interval = 250 +$installButton.Add_Click({ + try { + $hostname = $hostInput.Text.Trim() + if ($hostname.Length -gt 253 -or $hostname -notmatch '\.' -or @($hostname.Split('.') | Where-Object {$_ -notmatch '^[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?$'}).Count) {throw 'Enter a valid public company hostname.'} + if ([Convert]::FromBase64String($keyInput.Text.Trim()).Length -ne 32) {throw 'The release public key must decode to 32 bytes.'} + if ($publisherInput.Text.Trim() -notmatch '^[A-Fa-f0-9]{40}$') {throw 'Enter the trusted certificate thumbprint from your release policy.'} + if (-not (Test-Path -LiteralPath $executableInput.Text -PathType Leaf)) {throw 'Select the signed management executable.'} + if (-not (Test-Path -LiteralPath $componentsInput.Text -PathType Container)) {throw 'Select the prepared server components folder.'} + $parameters = @{Executable=$executableInput.Text;ReleasePublicKey=$keyInput.Text.Trim();PublisherThumbprint=$publisherInput.Text.Trim();ComponentsDirectory=$componentsInput.Text;PublicHostname=$hostname} + $worker = [Management.Automation.PowerShell]::Create() + $worker.AddCommand((Join-Path $PSScriptRoot 'Install-Server.ps1')).AddParameters($parameters) | Out-Null + $handle = $worker.BeginInvoke() + $script:operation = @{Worker=$worker;Handle=$handle;Hostname=$hostname} + foreach ($control in @($hostInput,$keyInput,$publisherInput,$executableInput,$componentsInput,$fileButton,$folderButton,$installButton,$closeButton)) {$control.Enabled=$false} + $log.Text = 'Installing company components. Public certificate issuance depends on DNS and reachability.' + $status.Text = 'Installation is running. Keep this wizard open until it finishes.' + $progress.Style = 'Marquee' + $timer.Start() + } catch {$log.Text=$_.Exception.Message} +}) +$timer.Add_Tick({ + if (-not $script:operation -or -not $script:operation.Handle.IsCompleted) {return} + $timer.Stop() + $operation = $script:operation + try { + $operation.Worker.EndInvoke($operation.Handle) | Out-Null + if ($operation.Worker.HadErrors) {throw ($operation.Worker.Streams.Error | Out-String)} + $log.Text = ($operation.Worker.Streams.Information | Out-String).Trim() + $script:setupUrl = 'https://'+$operation.Hostname + $webButton.Enabled = $true + $status.Text = 'Server installed. Complete company setup over HTTPS. Retrieve the one-time token from protected server data.' + } catch { + $log.Text = $_.Exception.Message + $status.Text = 'Installation failed. Review the error and retained files before retrying.' + foreach ($control in @($hostInput,$keyInput,$publisherInput,$executableInput,$componentsInput,$fileButton,$folderButton)) {$control.Enabled=$true} + $installButton.Enabled = $administrator + } finally { + $operation.Worker.Dispose() + $script:operation = $null + $closeButton.Enabled = $true + $progress.Style = 'Blocks' + } +}) +$form.Add_FormClosing({param($sender,$event) + if ($script:operation) {$event.Cancel=$true;$status.Text='Wait for the current installation to finish before closing.'} +}) +try { + if ($RenderPreview) { + $form.CreateControl() + $form.PerformLayout() + $bitmap = [Drawing.Bitmap]::new($form.Width,$form.Height) + try { + $form.DrawToBitmap($bitmap,[Drawing.Rectangle]::new(0,0,$form.Width,$form.Height)) + # Hidden forms suppress child drawing. Render each control without + # showing the form or enabling installation/browser actions. + $graphics = [Drawing.Graphics]::FromImage($bitmap) + $left = [int](($form.Width-$form.ClientSize.Width)/2) + $top = $form.Height-$form.ClientSize.Height-$left + try { + foreach ($control in $form.Controls) { + $child = [Drawing.Bitmap]::new($control.Width,$control.Height) + try { + $control.DrawToBitmap($child,[Drawing.Rectangle]::new(0,0,$control.Width,$control.Height)) + $graphics.DrawImageUnscaled($child,($left+$control.Left),($top+$control.Top)) + } finally {$child.Dispose()} + } + } finally {$graphics.Dispose()} + $bitmap.Save([IO.Path]::GetFullPath($RenderPreview),[Drawing.Imaging.ImageFormat]::Png) + } finally {$bitmap.Dispose()} + } else {[Windows.Forms.Application]::Run($form)} +} finally {$timer.Dispose();$form.Dispose()} diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index a3cc3b56725..51a2b1f2ae1 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -860,3 +860,25 @@ not run SCM or firewall operations. The installer additionally requires a valid management signature matching an explicit trusted certificate thumbprint. Native privileged install/uninstall, retained-data reinstallation and full Windows compatibility remain unverified; receipt planning is not their proof. + +The Windows server setup form now collects company hostname, public release +trust, expected publisher, signed management and prepared components, then +invokes the installer with structured parameters in a background PowerShell +instance. It disables installation without administrator rights and prevents +closing during installation. Successful completion enables HTTPS company +setup; failures show the installer error. Its Windows PowerShell 5.1 preview +renders and was visually inspected, without displaying the form or starting +services. This proves layout construction only. Privileged wizard actions, +interactive setup, backend completion/error events and a signed packaged +launcher remain unverified or incomplete. + +The exact `8cfdd8f` required public source inputs now build the pinned Linux +management image `sha256:0410d99dd9995bf95854dff50621030aeefa567f73b07e5b4f4dd381cdfd5fb3`. +The source archive SHA-256 is `3b40abde298095bad99347aad1bf5ee3a9b5ed855ea1788d8926038cc81cd72a`; +the management binary SHA-256 is `c0182c6785480dc47b5220689146da3bee7d00362f17ee1f400233e39c0199a6`. +The context check includes all 39 required inputs and excludes every private +configuration/key/cache canary. Both deployment drivers from the same source +pass: fresh setup, MFA/replay, approval, restart, management encrypted restore, +and complete transport/TLS replacement recovery using the original trust. +This is exact-source isolated Linux deployment evidence, not public ACME, +external traversal, native sessions or clean Windows compatibility proof. From 2f4004e9b21c6a59090a335414dc5a4c262b4a2d Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Fri, 2 Oct 2026 21:39:41 +0200 Subject: [PATCH 114/652] docs: record fresh-target offline Linux reproducibility evidence --- deployment/linux/README.md | 10 ++++++++-- docs/ACCEPTANCE.md | 10 ++++++++++ 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/deployment/linux/README.md b/deployment/linux/README.md index e6b9e58908f..135fe8de518 100644 --- a/deployment/linux/README.md +++ b/deployment/linux/README.md @@ -55,8 +55,14 @@ Update those digests deliberately and rerun these checks when applying base-imag 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. Bit-for-bit reproducibility still requires a separate rebuild -comparison and is not established by the deployment tests. +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: diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index 51a2b1f2ae1..72a45b6c845 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -882,3 +882,13 @@ pass: fresh setup, MFA/replay, approval, restart, management encrypted restore, and complete transport/TLS replacement recovery using the original trust. This is exact-source isolated Linux deployment evidence, not public ACME, external traversal, native sessions or clean Windows compatibility proof. + +The exact `8cfdd8f` Linux management executable also passes a separate offline +rebuild comparison. The pinned builder retained only downloaded locked sources +and toolchains; its target directory was hidden by a new `volume-nocopy` volume +and verified empty before compilation. A full 84-second release compilation +without network access produced the same `c0182c6785480dc47b5220689146da3bee7d00362f17ee1f400233e39c0199a6` +SHA-256. The first attempt exposed Docker's default volume population and was +discarded as cache reuse, not reproducibility proof. This proves the Linux +management binary in that pinned environment, not byte-identical OCI images, +native Windows binaries, signed packages or all product components. From ba1f6af9c202026342e446ce97d1c1fda938ced2 Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Fri, 2 Oct 2026 21:40:01 +0200 Subject: [PATCH 115/652] docs: clarify isolated rebuild inputs --- docs/ACCEPTANCE.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index 72a45b6c845..e2cd1134df7 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -884,8 +884,8 @@ This is exact-source isolated Linux deployment evidence, not public ACME, external traversal, native sessions or clean Windows compatibility proof. The exact `8cfdd8f` Linux management executable also passes a separate offline -rebuild comparison. The pinned builder retained only downloaded locked sources -and toolchains; its target directory was hidden by a new `volume-nocopy` volume +rebuild comparison. The rebuild reused the pinned toolchain and downloaded +locked dependency sources; old target artifacts were hidden by a new `volume-nocopy` volume and verified empty before compilation. A full 84-second release compilation without network access produced the same `c0182c6785480dc47b5220689146da3bee7d00362f17ee1f400233e39c0199a6` SHA-256. The first attempt exposed Docker's default volume population and was From eb1d580919f0e6eb3a74ac4bfb9891121dd7778d Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Fri, 2 Oct 2026 22:11:30 +0200 Subject: [PATCH 116/652] feat: bundle Windows company server setup as a guarded test executable --- deployment/windows/Build-ServerSetup.ps1 | 66 ++++++++++ deployment/windows/Install-Server.ps1 | 2 + deployment/windows/SERVER-COMPONENTS.md | 37 +++++- deployment/windows/ServerSetupLauncher.cs | 139 ++++++++++++++++++++++ deployment/windows/Setup-Server.ps1 | 13 +- docs/ACCEPTANCE.md | 13 ++ 6 files changed, 266 insertions(+), 4 deletions(-) create mode 100644 deployment/windows/Build-ServerSetup.ps1 create mode 100644 deployment/windows/ServerSetupLauncher.cs diff --git a/deployment/windows/Build-ServerSetup.ps1 b/deployment/windows/Build-ServerSetup.ps1 new file mode 100644 index 00000000000..0c47478f832 --- /dev/null +++ b/deployment/windows/Build-ServerSetup.ps1 @@ -0,0 +1,66 @@ +[CmdletBinding()] +param([Parameter(Mandatory)][string]$ManagementExecutable,[Parameter(Mandatory)][string]$ComponentsDirectory, + [Parameter(Mandatory)][string]$OutputDirectory,[switch]$UnsignedTest) +$ErrorActionPreference = 'Stop' +if (-not $UnsignedTest) { throw 'Only explicitly unsigned test server packages are supported until production signing and release verification are approved.' } +$management = (Resolve-Path -LiteralPath $ManagementExecutable).Path +if ([IO.Path]::GetExtension($management) -ine '.exe') { throw 'Select a Windows management executable.' } +$reader = [IO.BinaryReader]::new([IO.File]::OpenRead($management)) +try { + if ($reader.ReadUInt16() -ne 0x5a4d) { throw 'Management executable DOS header missing.' } + $reader.BaseStream.Position = 60 + $offset = $reader.ReadUInt32() + if ($offset+6 -gt $reader.BaseStream.Length) { throw 'Management executable PE header missing.' } + $reader.BaseStream.Position = $offset + if ($reader.ReadUInt32() -ne 0x4550 -or $reader.ReadUInt16() -ne 0x8664) { throw 'Expected a Windows x64 management executable.' } +} finally {$reader.Dispose()} +$components = (Resolve-Path -LiteralPath $ComponentsDirectory).Path +$output = [IO.Path]::GetFullPath($OutputDirectory) +if (Test-Path -LiteralPath $output) { throw 'Server package output directory already exists.' } +$compiler = Join-Path $env:WINDIR 'Microsoft.NET/Framework64/v4.0.30319/csc.exe' +if (-not (Test-Path -LiteralPath $compiler -PathType Leaf)) { throw 'The Windows x64 .NET Framework compiler is required.' } +$automation = & (Join-Path $env:WINDIR 'System32/WindowsPowerShell/v1.0/powershell.exe') -NoProfile -NonInteractive -Command '[System.Management.Automation.PSObject].Assembly.Location' +if ($LASTEXITCODE -ne 0 -or -not (Test-Path -LiteralPath $automation -PathType Leaf)) { throw 'Windows PowerShell 5 assembly is required.' } +$resources = [ordered]@{ + 'Setup-Server.ps1'=(Join-Path $PSScriptRoot 'Setup-Server.ps1') + 'Install-Server.ps1'=(Join-Path $PSScriptRoot 'Install-Server.ps1') + 'swan-management.exe'=$management + 'LICENCE'=(Resolve-Path -LiteralPath (Join-Path $PSScriptRoot '../../LICENCE')).Path +} +foreach ($name in @('hbbs.exe','hbbr.exe','caddy.exe','Caddy-LICENSE.txt','RustDesk-LICENSE.txt','THIRD-PARTY.txt','server-components.json')) { + $resources['components/'+$name] = Join-Path $components $name +} +$pins=@{'hbbs.exe'='2102e17d32af3ab313a4096d4a4db307984630eccf4f9ed03ef3dfbd4fdb8f83';'hbbr.exe'='5b5fe62f5b5f1fa7df521a243cbfadbeca67bd9df80d5017f23cf62aca59257e';'caddy.exe'='586d4a4cd74bdfd2951b6b81766a32904ffa69c4f1c3d521da3870a2120f1d31'} +$hashes=[ordered]@{} +foreach ($name in $resources.Keys) { + if ($resources[$name].Contains(',')) { throw 'Compiler resource input paths must not contain commas.' } + $hashes[$name]=(Get-FileHash -LiteralPath $resources[$name] -Algorithm SHA256).Hash.ToLowerInvariant() + $basename=[IO.Path]::GetFileName($name) + if ($pins.ContainsKey($basename) -and $hashes[$name] -ne $pins[$basename]) { throw 'Pinned component executable mismatch.' } +} +$revision = & git -C (Join-Path $PSScriptRoot '../..') rev-parse HEAD +if ($LASTEXITCODE -ne 0 -or $revision -notmatch '^[0-9a-f]{40}$') { throw 'Build from the public Git checkout.' } +$dirty = -not [string]::IsNullOrEmpty((& git -C (Join-Path $PSScriptRoot '../..') status --porcelain | Out-String).Trim()) +New-Item -ItemType Directory -Path $output | Out-Null +$sourcePath = Join-Path $output 'SOURCE.json' +$sourceFiles=[ordered]@{} +foreach ($name in @('ServerSetupLauncher.cs','Build-ServerSetup.ps1')) { $sourceFiles[$name]=(Get-FileHash -LiteralPath (Join-Path $PSScriptRoot $name) -Algorithm SHA256).Hash.ToLowerInvariant() } +$source = @{schema=1;unsigned_test=$true;repository='https://github.com/rijff24/SwanRemoteSupport';setup_source_revision=$revision;working_tree_dirty=$dirty;resources=$hashes;build_sources=$sourceFiles} +[IO.File]::WriteAllText($sourcePath,($source | ConvertTo-Json -Depth 5),[Text.UTF8Encoding]::new($false)) +$resources['SOURCE.json']=$sourcePath +$executable = Join-Path $output 'SwanServerSetup-UNSIGNED-TEST.exe' +$arguments = @('/nologo','/target:winexe','/platform:x64','/optimize+',('/out:'+$executable),('/reference:'+$automation),('/reference:System.Security.dll')) +foreach ($name in $resources.Keys) { $arguments += '/resource:'+$resources[$name]+',Swan.'+$name } +$arguments += Join-Path $PSScriptRoot 'ServerSetupLauncher.cs' +& $compiler @arguments +if ($LASTEXITCODE -ne 0) { throw 'Server setup compilation failed; output remains for inspection.' } +$assembly = [Reflection.Assembly]::LoadFile($executable) +foreach ($name in $hashes.Keys) { + $stream = $assembly.GetManifestResourceStream('Swan.'+$name) + if (-not $stream) { throw 'Compiled package resource missing.' } + $hasher = [Security.Cryptography.SHA256]::Create() + try {$actual=[BitConverter]::ToString($hasher.ComputeHash($stream)).Replace('-','').ToLowerInvariant()} finally {$hasher.Dispose();$stream.Dispose()} + if ($actual -ne $hashes[$name]) { throw 'Compiled package resource differs from the pinned input.' } +} +Copy-Item -LiteralPath $resources['LICENCE'] -Destination (Join-Path $output 'LICENCE') +Write-Output $executable diff --git a/deployment/windows/Install-Server.ps1 b/deployment/windows/Install-Server.ps1 index cb7ff0abb72..184e9820736 100644 --- a/deployment/windows/Install-Server.ps1 +++ b/deployment/windows/Install-Server.ps1 @@ -46,6 +46,8 @@ New-Item -ItemType Directory -Path $installDirectory,$dataDirectory | Out-Null if ($LASTEXITCODE -ne 0) { throw 'Cannot protect server data.' } $destination = Join-Path $installDirectory 'swan-management.exe' Copy-Item -LiteralPath $source -Destination $destination +$copiedSignature = Get-AuthenticodeSignature -LiteralPath $destination +if ($copiedSignature.Status -ne 'Valid' -or $copiedSignature.SignerCertificate.Thumbprint -ne $PublisherThumbprint) { throw 'Copied management executable does not match the trusted publisher.' } $serviceEnvironment = @("SWAN_DATA_DIR=$dataDirectory","SWAN_LISTEN=127.0.0.1:$Port","SWAN_RELEASE_PUBLIC_KEY=$ReleasePublicKey") if ($fullStack) { $packaged = Join-Path $installDirectory 'components' diff --git a/deployment/windows/SERVER-COMPONENTS.md b/deployment/windows/SERVER-COMPONENTS.md index b31e0379d60..c75c7773c91 100644 --- a/deployment/windows/SERVER-COMPONENTS.md +++ b/deployment/windows/SERVER-COMPONENTS.md @@ -5,8 +5,9 @@ packaged rendezvous, relay and HTTPS components. The service supervises its own children; component failure stops management, and service stop or a service process crash terminates its assigned children. Windows process-job lifecycle tests pass, but clean-machine installation, repair and uninstall are still -unverified. `Setup-Server.ps1` supplies the graphical setup flow; a signed -packaged launcher and clean-machine wizard installation remain incomplete. +unverified. `Setup-Server.ps1` supplies the graphical setup flow, and an +unsigned test EXE bundles it with management and pinned transport/HTTPS inputs. +Production signing and clean-machine wizard installation remain incomplete. `server-components.json` pins the Windows x64 RustDesk server 1.1.15 and Caddy 2.10.2 archives by SHA-256, with upstream source references. Download each exact @@ -55,6 +56,38 @@ showing it, starting services or enabling installation/browser actions. The preview is layout evidence; it does not test privileged installation or backend completion/error events. Those require the clean Windows test matrix. +## Unsigned test executable + +The x64 .NET Framework launcher uses the Windows PowerShell runtime already +present in the targeted desktop Windows releases. Build only for isolated tests: + +```powershell +./deployment/windows/Build-ServerSetup.ps1 -UnsignedTest ` + -ManagementExecutable ./swan-management.exe ` + -ComponentsDirectory ./prepared-server-components ` + -OutputDirectory ./server-setup-test +``` + +It embeds the wizard and installer scripts, management, pinned transport/HTTPS +executables, licenses and public source/hash metadata. Input PE architecture +and component hashes are checked before output; compiled resources must match +their input hashes. Output is `SwanServerSetup-UNSIGNED-TEST.exe`, with test +status also shown in its window title. Building without `-UnsignedTest` is +rejected. A supplied unsigned management executable may be packaged for layout +tests, but installation still requires its valid trusted publisher signature. +This does not bypass signing gates or grant production publication approval. +Resource input paths containing commas are currently unsupported by this builder. + +Use `--render-preview OUTPUT.png` to inspect the packaged layout without opening +the window or installing services. The launcher executes the installer from its +embedded resource in memory, using structured parameters. Public binary payloads +are extracted under a unique protected directory; elevated extraction grants +only Administrators/SYSTEM access and ownership. Non-elevated previews use the +current user's private temporary directory. Known payload files are cleaned up +on normal exit without recursive deletion. Elevated extraction, interrupted +cleanup and privileged installation remain clean-VM acceptance requirements. +The installed management copy is signature-checked again before service creation. + For a fresh company deployment, run with administrator rights and a trusted signed management build containing component supervision: diff --git a/deployment/windows/ServerSetupLauncher.cs b/deployment/windows/ServerSetupLauncher.cs new file mode 100644 index 00000000000..e8e8771e35c --- /dev/null +++ b/deployment/windows/ServerSetupLauncher.cs @@ -0,0 +1,139 @@ +using System; +using System.Diagnostics; +using System.IO; +using System.Management.Automation; +using System.Management.Automation.Runspaces; +using System.Reflection; +using System.Security.AccessControl; +using System.Security.Principal; +using System.Threading; + +internal static class ServerSetupLauncher +{ + private static readonly string[] PayloadFiles = { + "swan-management.exe", "components/hbbs.exe", "components/hbbr.exe", "components/caddy.exe", + "components/Caddy-LICENSE.txt", "components/RustDesk-LICENSE.txt", "components/THIRD-PARTY.txt", + "components/server-components.json", "SOURCE.json", "LICENCE" + }; + + private static Stream Resource(string name) + { + Stream resource = Assembly.GetExecutingAssembly().GetManifestResourceStream("Swan." + name); + if (resource == null) throw new InvalidOperationException("Missing packaged resource: " + name); + return resource; + } + + private static string Script(string name) + { + using (Stream input = Resource(name)) + using (StreamReader reader = new StreamReader(input)) return reader.ReadToEnd(); + } + + private static string CreatePayloadDirectory() + { + WindowsIdentity identity = WindowsIdentity.GetCurrent(); + bool elevated = new WindowsPrincipal(identity).IsInRole(WindowsBuiltInRole.Administrator); + SecurityIdentifier administrators = new SecurityIdentifier(WellKnownSidType.BuiltinAdministratorsSid, null); + SecurityIdentifier system = new SecurityIdentifier(WellKnownSidType.LocalSystemSid, null); + SecurityIdentifier owner = elevated ? administrators : identity.User; + DirectorySecurity access = new DirectorySecurity(); + access.SetAccessRuleProtection(true, false); + access.SetOwner(owner); + InheritanceFlags inheritance = InheritanceFlags.ContainerInherit | InheritanceFlags.ObjectInherit; + access.AddAccessRule(new FileSystemAccessRule(owner, FileSystemRights.FullControl, inheritance, PropagationFlags.None, AccessControlType.Allow)); + access.AddAccessRule(new FileSystemAccessRule(system, FileSystemRights.FullControl, inheritance, PropagationFlags.None, AccessControlType.Allow)); + string parent = elevated ? Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData) : Path.GetTempPath(); + string root = Path.Combine(parent, "SwanServerSetup-" + Guid.NewGuid().ToString("N")); + if (Directory.Exists(root)) throw new IOException("Payload directory already exists."); + Directory.CreateDirectory(root, access); + DirectoryInfo created = new DirectoryInfo(root); + if ((created.Attributes & FileAttributes.ReparsePoint) != 0 || + !created.GetAccessControl().GetOwner(typeof(SecurityIdentifier)).Equals(owner)) + throw new IOException("Payload directory ownership is invalid."); + return root; + } + + private static void Extract(string root) + { + foreach (string relative in PayloadFiles) + { + string target = Path.Combine(root, relative.Replace('/', Path.DirectorySeparatorChar)); + Directory.CreateDirectory(Path.GetDirectoryName(target)); + using (Stream input = Resource(relative)) + { + if (input.Length > 256L * 1024 * 1024) throw new IOException("Packaged component too large."); + using (FileStream output = new FileStream(target, FileMode.CreateNew, FileAccess.Write, FileShare.None)) input.CopyTo(output); + } + // Explicitly retain the protected root owner for payload children, + // rather than depending on an elevated token's default owner. + IdentityReference owner = new DirectoryInfo(root).GetAccessControl().GetOwner(typeof(SecurityIdentifier)); + FileSecurity fileAccess = File.GetAccessControl(target); + fileAccess.SetOwner(owner); + File.SetAccessControl(target, fileAccess); + string directory = Path.GetDirectoryName(target); + if (directory != root) + { + DirectorySecurity directoryAccess = Directory.GetAccessControl(directory); + directoryAccess.SetOwner(owner); + Directory.SetAccessControl(directory, directoryAccess); + } + } + } + + private static void Cleanup(string root) + { + if (root == null) return; + // Fixed basenames only. Retain unexpected files; never recurse. + foreach (string relative in PayloadFiles) + { + string path = Path.Combine(root, relative.Replace('/', Path.DirectorySeparatorChar)); + if (File.Exists(path)) File.Delete(path); + } + string components = Path.Combine(root, "components"); + if (Directory.Exists(components) && Directory.GetFileSystemEntries(components).Length == 0) Directory.Delete(components); + if (Directory.Exists(root) && Directory.GetFileSystemEntries(root).Length == 0) Directory.Delete(root); + } + + [STAThread] + private static int Main(string[] arguments) + { + string payload = null; + try + { + bool preview = arguments.Length == 2 && arguments[0] == "--render-preview"; + if (arguments.Length != 0 && !preview) throw new ArgumentException("Use no arguments, or --render-preview OUTPUT.png."); + payload = CreatePayloadDirectory(); + Extract(payload); + using (Runspace runspace = RunspaceFactory.CreateRunspace()) + { + runspace.ApartmentState = ApartmentState.STA; + runspace.ThreadOptions = PSThreadOptions.UseCurrentThread; + runspace.Open(); + // Installer code stays in the executable resource, not a + // mutable temporary script executed by an elevated process. + runspace.SessionStateProxy.SetVariable("SwanPackagedInstallerScript", Script("Install-Server.ps1")); + using (PowerShell shell = PowerShell.Create()) + { + shell.Runspace = runspace; + shell.AddScript(Script("Setup-Server.ps1")) + .AddParameter("BundledDirectory", payload) + .AddParameter("UnsignedTestPackage", true); + if (preview) shell.AddParameter("RenderPreview", Path.GetFullPath(arguments[1])); + shell.Invoke(); + if (shell.HadErrors) throw new InvalidOperationException("Server setup failed: " + shell.Streams.Error[0]); + } + } + return 0; + } + catch (Exception error) + { + Console.Error.WriteLine(error.Message); + return 1; + } + finally + { + try { Cleanup(payload); } + catch (Exception error) { Console.Error.WriteLine("Retained setup payload: " + error.Message); } + } + } +} diff --git a/deployment/windows/Setup-Server.ps1 b/deployment/windows/Setup-Server.ps1 index 61ff14c1ddc..7f9b6fe7d4d 100644 --- a/deployment/windows/Setup-Server.ps1 +++ b/deployment/windows/Setup-Server.ps1 @@ -1,5 +1,5 @@ [CmdletBinding()] -param([string]$RenderPreview) +param([string]$RenderPreview,[string]$BundledDirectory,[switch]$UnsignedTestPackage) $ErrorActionPreference = 'Stop' Add-Type -AssemblyName System.Windows.Forms Add-Type -AssemblyName System.Drawing @@ -9,8 +9,13 @@ $identity = [Security.Principal.WindowsIdentity]::GetCurrent() $administrator = ([Security.Principal.WindowsPrincipal]::new($identity)).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) $script:operation = $null $script:setupUrl = $null +$script:installerText = $null +if (Get-Variable -Name SwanPackagedInstallerScript -ErrorAction SilentlyContinue) { + $script:installerText = $SwanPackagedInstallerScript +} else { $script:installerText = Get-Content -LiteralPath (Join-Path $PSScriptRoot 'Install-Server.ps1') -Raw } $form = [Windows.Forms.Form]::new() $form.Text = 'Swan Remote Support - Company server setup' +if ($UnsignedTestPackage) {$form.Text += ' [UNSIGNED TEST]'} $form.ClientSize = [Drawing.Size]::new(820,740) $form.MinimumSize = $form.Size $form.MaximumSize = $form.Size @@ -51,6 +56,10 @@ $keyInput = Add-Field 'Trusted project release public key' 195 'Use the base64 r $publisherInput = Add-Field 'Trusted management publisher certificate thumbprint' 274 'Use the certificate fingerprint from your trusted release policy, not from an unverified download.' $executableInput = Add-Field 'Signed management executable' 353 'Select the signed swan-management.exe from your reviewed release.' $true $componentsInput = Add-Field 'Prepared server components folder' 432 'Contains pinned rendezvous, relay, HTTPS executables and their license/source notices.' $true +if ($BundledDirectory) { + $executableInput.Text = Join-Path $BundledDirectory 'swan-management.exe' + $componentsInput.Text = Join-Path $BundledDirectory 'components' +} $fileButton = [Windows.Forms.Button]::new() $fileButton.Text = 'Browse...' $fileButton.SetBounds(714,377,80,28) @@ -121,7 +130,7 @@ $installButton.Add_Click({ if (-not (Test-Path -LiteralPath $componentsInput.Text -PathType Container)) {throw 'Select the prepared server components folder.'} $parameters = @{Executable=$executableInput.Text;ReleasePublicKey=$keyInput.Text.Trim();PublisherThumbprint=$publisherInput.Text.Trim();ComponentsDirectory=$componentsInput.Text;PublicHostname=$hostname} $worker = [Management.Automation.PowerShell]::Create() - $worker.AddCommand((Join-Path $PSScriptRoot 'Install-Server.ps1')).AddParameters($parameters) | Out-Null + $worker.AddScript($script:installerText).AddParameters($parameters) | Out-Null $handle = $worker.BeginInvoke() $script:operation = @{Worker=$worker;Handle=$handle;Hostname=$hostname} foreach ($control in @($hostInput,$keyInput,$publisherInput,$executableInput,$componentsInput,$fileButton,$folderButton,$installButton,$closeButton)) {$control.Enabled=$false} diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index e2cd1134df7..639d262e675 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -892,3 +892,16 @@ SHA-256. The first attempt exposed Docker's default volume population and was discarded as cache reuse, not reproducibility proof. This proves the Linux management binary in that pinned environment, not byte-identical OCI images, native Windows binaries, signed packages or all product components. + +An x64 Windows .NET Framework test launcher now packages the setup wizard, +installer, management executable, pinned rendezvous/relay/HTTPS binaries and +license/source metadata into one EXE. Its first package compiled and rendered +the bundled form using Windows PowerShell in-process; the window shows +`UNSIGNED TEST`. Non-elevated preview exited successfully and removed its known +temporary payload, without registering services or changing firewall/trust +settings. Implicit production builds and a modified relay input are rejected +before output creation. The builder verifies compiled resource hashes; the +installer rechecks the copied management signature before registering its +service. Final package verification and elevated extraction/installation on +clean Windows VMs remain necessary. Test packaging is not signing approval, +production publication or clean-machine compatibility evidence. From aa4f588125a5b3aaf2002deb429ad17f9554e7c1 Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Fri, 2 Oct 2026 22:43:27 +0200 Subject: [PATCH 117/652] fix: abort company server setup when preflight inspection fails --- deployment/windows/Install-Server.ps1 | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/deployment/windows/Install-Server.ps1 b/deployment/windows/Install-Server.ps1 index 184e9820736..8b929f82952 100644 --- a/deployment/windows/Install-Server.ps1 +++ b/deployment/windows/Install-Server.ps1 @@ -30,16 +30,19 @@ if ($fullStack) { foreach ($name in @('Caddy-LICENSE.txt','RustDesk-LICENSE.txt','THIRD-PARTY.txt','server-components.json')) { if (-not (Test-Path -LiteralPath (Join-Path $ComponentsDirectory $name) -PathType Leaf)) { throw 'Prepared component license or source notice missing.' } } - $ports = @(80,443,21115,21116,21117,$Port) - if (@(Get-NetTCPConnection -State Listen -ErrorAction SilentlyContinue | Where-Object LocalPort -in $ports).Count -or - @(Get-NetUDPEndpoint -LocalPort 21116 -ErrorAction SilentlyContinue).Count) { throw 'A required company server port is already in use. Existing services will not be changed.' } - foreach ($rule in @('SwanCompanyServer-TCP','SwanCompanyServer-UDP')) { - if (Get-NetFirewallRule -Name $rule -ErrorAction SilentlyContinue) { throw 'Company firewall rule already exists.' } - } +} +# Enumerate first: filtering cmdlets by a missing port/name can itself report an +# error. Inspection failures must abort rather than masquerade as a free port. +$ports = if ($fullStack) { @(80,443,21115,21116,21117,$Port) } else { @($Port) } +$listeners = @(Get-NetTCPConnection -ErrorAction Stop | Where-Object { $_.State -eq 'Listen' -and $_.LocalPort -in $ports }) +if ($listeners.Count) { throw 'A required company server port is already in use. Existing services will not be changed.' } +if ($fullStack) { + if (@(Get-NetUDPEndpoint -ErrorAction Stop | Where-Object LocalPort -eq 21116).Count) { throw 'Company rendezvous UDP port is already in use.' } + if (@(Get-NetFirewallRule -ErrorAction Stop | Where-Object Name -in @('SwanCompanyServer-TCP','SwanCompanyServer-UDP')).Count) { throw 'Company firewall rule already exists.' } } $installDirectory = Join-Path $env:ProgramFiles 'Swan Company Server' $dataDirectory = Join-Path $env:ProgramData 'SwanCompanyServer' -if (Get-Service -Name SwanCompanyServer -ErrorAction SilentlyContinue) { throw 'Company server already exists. Use the documented backed-up maintenance upgrade procedure.' } +if (@(Get-Service -ErrorAction Stop | Where-Object Name -eq 'SwanCompanyServer').Count) { throw 'Company server already exists. Use the documented backed-up maintenance upgrade procedure.' } if ((Test-Path -LiteralPath $installDirectory) -or (Test-Path -LiteralPath $dataDirectory)) { throw 'Company install/data directory already exists. Preserve it and use the recovery or maintenance procedure.' } New-Item -ItemType Directory -Path $installDirectory,$dataDirectory | Out-Null & icacls.exe $dataDirectory '/inheritance:r' '/grant:r' '*S-1-5-18:(OI)(CI)F' '*S-1-5-32-544:(OI)(CI)F' | Out-Null From 3d346c01d42938dcf0993a2890f98ca881b12122 Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Fri, 2 Oct 2026 22:44:40 +0200 Subject: [PATCH 118/652] docs: record verified Windows native and bundled server builds --- docs/ACCEPTANCE.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index 639d262e675..52ff93db2e0 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -905,3 +905,22 @@ installer rechecks the copied management signature before registering its service. Final package verification and elevated extraction/installation on clean Windows VMs remain necessary. Test packaging is not signing approval, production publication or clean-machine compatibility evidence. + +The final test server package from clean source `eb1d580` passed compiled +resource verification and its non-elevated, noninteractive preview, leaving no +temporary payload. Its SHA-256 is +`db5a93275f277222b51719b01b2d0d6a909c4e3a556f34d86c691485625a5c25`; +its management input is the existing debug test binary, and the package remains +unsigned. Separately, [Windows x64 build 37030567046](https://github.com/rijff24/SwanRemoteSupport/actions/runs/37030567046) +completed successfully for source `215dfef3fc80799f7077cfe314ecad7b1c1ebc30`, +including the native app and company components. The resulting +`SwanRemoteSupport-1.5.0-x64-unsigned` artifact is a build result, not installation, +session, reproducibility or signing proof. Later source changes still require +their own applicable validation. + +Server installer preflight now aborts if TCP, UDP, firewall or service inspection +fails, and checks the management port for management-only installations too. +Enumeration is checked before filtering, so a missing named resource is not +confused with an inspection failure. Windows PowerShell 5.1 and PowerShell 7 +parse the updated installer; read-only host endpoint and firewall enumeration +succeeded. No privileged installation or host configuration was performed. From 192e4c5ae177fdabd66c9578be454a916015b5e4 Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Fri, 2 Oct 2026 23:08:29 +0200 Subject: [PATCH 119/652] docs: verify native package provenance and diagnose Windows lab setup --- docs/ACCEPTANCE.md | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index 52ff93db2e0..b7dda4ca2cd 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -924,3 +924,28 @@ Enumeration is checked before filtering, so a missing named resource is not confused with an inspection failure. Windows PowerShell 5.1 and PowerShell 7 parse the updated installer; read-only host endpoint and firewall enumeration succeeded. No privileged installation or host configuration was performed. + +The Windows build artifact above was downloaded and verified against GitHub's +published archive digest +`d61e9589d671d10db286cf7007e6cf2c3e58627b34ce1df75c184c8420509d87`. +Safe extraction validated entry paths, duplicates, sizes and ZIP CRCs; all four +EXE/MSI checksums match the included checksums. Read-only MSI inspection confirms +both packages report version `1.5.0`, `x64;1033` and their distinct stable upgrade +codes. The customer MSI SHA-256 is +`b8268332cd61d26c37fd4a2aa8c56fa9c84648b5dd7dc9484a0b04b3d216f47d`; +the technician MSI SHA-256 is +`e997dc9afa490155b9bf9be1c54645cea1ff9fe5a2d73d3e2461454d3bbd8184`. +Both remain unsigned and were not installed on the host. + +A separate fresh Server 2016 Standard Evaluation Desktop Experience VM accepted +private unattended media and completed file preparation, then failed while +processing the `specialize` pass. Its disk is preserved for read-only setup-log +diagnosis. This is a lab provisioning failure, not a product installation result; +the Windows compatibility row remains unverified. +Read-only inspection of its Panther logs identified `Microsoft-Windows-Deployment` +`RunSynchronousCommand/Path` as the invalid value (`0x80220005`). The setup +command was 280 characters; Microsoft's documented limit is +[259 characters](https://learn.microsoft.com/en-us/windows-hardware/customize/desktop/unattend/microsoft-windows-deployment-runsynchronous-runsynchronouscommand-path). +A new private provisioning seed uses a 192-character command and validates this +bound before generation. The failed disk remained hardware read-only throughout +diagnosis. The corrected seed still requires an actual successful installation. From 6840d910c252d8fb7d5c34ca7dffbc2a2ddffb4b Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Fri, 2 Oct 2026 23:14:17 +0200 Subject: [PATCH 120/652] test: add guarded clean Windows MSI lifecycle verification --- deployment/windows/Test-DisposableMsi.ps1 | 86 +++++++++++++++++++++++ deployment/windows/VM-TESTING.md | 29 ++++++++ 2 files changed, 115 insertions(+) create mode 100644 deployment/windows/Test-DisposableMsi.ps1 diff --git a/deployment/windows/Test-DisposableMsi.ps1 b/deployment/windows/Test-DisposableMsi.ps1 new file mode 100644 index 00000000000..52aedd4b502 --- /dev/null +++ b/deployment/windows/Test-DisposableMsi.ps1 @@ -0,0 +1,86 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory)][ValidateSet('customer','technician')][string]$Edition, + [Parameter(Mandatory)][ValidateSet('install','repair','uninstall')][string]$Phase, + [Parameter(Mandatory)][string]$Package, + [Parameter(Mandatory)][ValidatePattern('^[a-fA-F0-9]{64}$')][string]$PackageSha256, + [Parameter(Mandatory)][ValidatePattern('^[a-fA-F0-9]{64}$')][string]$AgentSha256, + [Parameter(Mandatory)][ValidatePattern('^[a-fA-F0-9]{40}$')][string]$SourceRevision, + [Parameter(Mandatory)][string]$ExpectedComputerName, + [Parameter(Mandatory)][string]$ResultDirectory, + [string]$Manifest, + [string]$ManifestSha256, + [string]$TechnicianSha256 +) +$ErrorActionPreference='Stop' +# This deliberately mutates a disposable guest. A private, independently +# provisioned marker and exact machine name prevent accidental host execution. +if ($env:COMPUTERNAME -cne $ExpectedComputerName) { throw 'Wrong test computer.' } +$marker = Get-Content -LiteralPath 'C:/SwanLab/disposable-lab.json' -Raw | ConvertFrom-Json +if ($marker.purpose -cne 'swan-disposable-windows-acceptance' -or $marker.computer_name -cne $ExpectedComputerName) { throw 'Disposable guest marker missing or mismatched.' } +if (-not [Environment]::Is64BitProcess) { throw 'Run the x64 test shell.' } +$identity=[Security.Principal.WindowsIdentity]::GetCurrent() +if (-not ([Security.Principal.WindowsPrincipal]::new($identity)).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { throw 'Disposable MSI tests require an administrator.' } +$packagePath=(Resolve-Path -LiteralPath $Package).Path +if ([IO.Path]::GetExtension($packagePath) -ine '.msi' -or (Get-FileHash -LiteralPath $packagePath -Algorithm SHA256).Hash -ine $PackageSha256) { throw 'MSI hash or format mismatch.' } +$msi=& (Join-Path $PSScriptRoot 'Get-MsiIdentity.ps1') -Package $packagePath | ConvertFrom-Json +$upgrade=if($Edition -eq 'customer'){'{32A585D7-9A78-4AD2-AF72-D0266EFC709D}'}else{'{A4374699-436F-4917-9A4E-223F62A9E634}'} +if ($msi.upgrade_code -ine $upgrade -or $msi.template -notmatch '^x64;' -or $msi.product_code -notmatch '^\{[A-Fa-f0-9-]{36}\}$') { throw 'Unexpected MSI edition or architecture.' } +$compatibility=& (Join-Path $PSScriptRoot 'Get-WindowsCompatibility.ps1') +if ($LASTEXITCODE -ne 0) { throw 'Unsupported guest operating system.' } +$install=if($Edition -eq 'customer'){Join-Path $env:ProgramFiles 'Swan Remote Support'}else{Join-Path $env:LOCALAPPDATA 'SwanRemoteSupport-Technician'} +$main=Join-Path $install $(if($Edition -eq 'customer'){'Swan Remote Support.exe'}else{'SwanRemoteSupport-Technician.exe'}) +$agent=if($Edition -eq 'customer'){Join-Path $env:ProgramData 'SwanRemoteSupport/swan-agent.exe'}else{Join-Path $install 'swan-agent.exe'} +$files=@() +if ($Edition -eq 'technician' -and $TechnicianSha256 -notmatch '^[a-fA-F0-9]{64}$') { throw 'Pinned technician executable hash required.' } +if ($Edition -eq 'customer') { + if ($ManifestSha256 -notmatch '^[a-fA-F0-9]{64}$' -or (Get-FileHash -LiteralPath $Manifest -Algorithm SHA256).Hash -ine $ManifestSha256) { throw 'Pinned customer manifest required.' } + $files=@(Get-Content -LiteralPath $Manifest -Raw | ConvertFrom-Json) + $seen=@{} + foreach($file in $files){ + if ($file.path -notmatch '^[A-Za-z0-9 _\-.@+/]+$' -or @($file.path.Split('/') | Where-Object {$_ -in @('','.', '..') -or $_.EndsWith('.') -or $_.EndsWith(' ')}).Count -or $seen.ContainsKey($file.path) -or $file.sha256 -notmatch '^[a-fA-F0-9]{64}$') { throw 'Unsafe manifest entry.' } + $seen[$file.path]=$true + } + if (@($files | Where-Object path -eq 'LICENSE.txt').Count -ne 1) { throw 'Expected a single packaged license.' } +} +function Assert-Payload { + foreach($path in @($main,$agent)){if(-not(Test-Path -LiteralPath $path -PathType Leaf)){throw 'Installed application or agent missing.'}} + if ((Get-FileHash -LiteralPath $agent -Algorithm SHA256).Hash -ine $AgentSha256) { throw 'Installed agent hash mismatch.' } + if ($Edition -eq 'technician' -and (Get-FileHash -LiteralPath $main -Algorithm SHA256).Hash -ine $TechnicianSha256) { throw 'Installed technician hash mismatch.' } + foreach($file in $files){ + $path=Join-Path $install $file.path + if ((Get-Item -LiteralPath $path).Attributes -band [IO.FileAttributes]::ReparsePoint -or (Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash -ine $file.sha256) { throw "Installed file differs: $($file.path)" } + } + if($Edition -eq 'customer'){ + $service=Get-CimInstance Win32_Service -Filter "Name='Swan Remote Support'" + if(-not $service -or $service.PathName -ine ('"'+$main+'" --service') -or $service.StartName -ne 'LocalSystem' -or $service.State -ne 'Running'){throw 'Customer service ownership, startup or executable mismatch.'} + } +} +if($Phase -eq 'install'){ + if((Test-Path -LiteralPath $install) -or (Test-Path -LiteralPath $agent)){throw 'Fresh installation requires an unused product directory.'} + if($Edition -eq 'customer' -and (Get-CimInstance Win32_Service -Filter "Name='Swan Remote Support'")){throw 'Customer service already present.'} +}else{ + Assert-Payload +} +$resultRoot=[IO.Path]::GetFullPath($ResultDirectory) +if(Test-Path -LiteralPath $resultRoot){throw 'Use a new result directory for each phase.'} +New-Item -ItemType Directory -Path $resultRoot | Out-Null +if($Phase -eq 'repair' -and $Edition -eq 'customer'){ + # Modify only a previously hash-verified, non-executable package key path. + [IO.File]::WriteAllText((Join-Path $install 'LICENSE.txt'),'Disposable repair test: replace this damaged packaged file.') +} +$operation=if($Phase -eq 'install'){'/i'}elseif($Phase -eq 'repair'){'/fvamus'}else{'/x'} +$log=Join-Path $resultRoot 'msi.log' +$process=Start-Process -FilePath (Join-Path ([Environment]::GetFolderPath('System')) 'msiexec.exe') -ArgumentList @($operation,('"'+$packagePath+'"'),'/qn','/norestart','/l*v',('"'+$log+'"')) -WindowStyle Hidden -Wait -PassThru +$report=[ordered]@{edition=$Edition;phase=$Phase;source=$SourceRevision;package_sha256=$PackageSha256.ToLowerInvariant();msi=$msi;platform=$compatibility;os=(Get-CimInstance Win32_OperatingSystem | Select-Object Caption,Version,BuildNumber,OSArchitecture);exit_code=$process.ExitCode;reboot_required=($process.ExitCode -eq 3010);passed=$false;scope='Bare MSI lifecycle only; no company enrollment, publisher, update or session acceptance'} +try{ + if($process.ExitCode -notin @(0,3010)){throw "MSI phase failed ($($process.ExitCode)); inspect private log."} + if($Phase -eq 'uninstall'){ + foreach($path in @($main,$agent)){if(Test-Path -LiteralPath $path){throw 'Uninstall retained the application or agent executable.'}} + if($Edition -eq 'customer' -and (Get-CimInstance Win32_Service -Filter "Name='Swan Remote Support'")){throw 'Uninstall retained the customer service.'} + }else{Assert-Payload} + $report.passed=$true +}finally{ + $report | ConvertTo-Json -Depth 6 | Set-Content -LiteralPath (Join-Path $resultRoot 'result.json') +} +$report | ConvertTo-Json -Depth 6 diff --git a/deployment/windows/VM-TESTING.md b/deployment/windows/VM-TESTING.md index a8a9be05921..5c4012264a3 100644 --- a/deployment/windows/VM-TESTING.md +++ b/deployment/windows/VM-TESTING.md @@ -64,6 +64,35 @@ Official media starting points: Evaluation expiration or missing supported media must be reported as an unverified matrix row, never as a passing compatibility test. +## Bare MSI lifecycle checks + +`Test-DisposableMsi.ps1` runs one actual MSI phase (`install`, `repair`, or +`uninstall`) inside an independently provisioned disposable guest. It requires +an exact `ExpectedComputerName` and a private `C:\SwanLab\disposable-lab.json` +marker with `purpose` equal to `swan-disposable-windows-acceptance` and +`computer_name` equal to that guest's name. Never create this marker on the host +or on a company/customer machine. + +Pass the verified package SHA-256, source revision and agent SHA-256; technician +tests also require the portable executable SHA-256. Customer tests require the +installed-files manifest and its verified SHA-256. Copy `Get-MsiIdentity.ps1` +and `Get-WindowsCompatibility.ps1` alongside the runner. Use a new result +directory for each phase and keep raw MSI logs private. + +Fresh installation refuses existing application/agent paths. Customer checks +verify every manifested file and the running service's exact executable, +arguments and LocalSystem identity. Customer repair first damages the verified +packaged license file, then checks that MSI repair restores the complete +manifest. Both editions check agent hashes, and technician checks its executable +hash. Uninstall checks removal of application/agent executables and the customer +service. Exit code 3010 is recorded as requiring a reboot; perform and verify +that reboot before interpreting later tests as reboot acceptance. + +This runner does not configure a company, approve enrollment, test signatures, +exercise updates or establish a remote session. Successful bare MSI phases +cannot replace those acceptance requirements. Do not edit production installer +verification to accommodate unsigned lab packages. + The first local WHPX probe reported `Unexpected VP exit code 4`; the isolated guest was stopped. Its sparse disk is preserved outside Git. No operating system installation or accelerator compatibility is established by that probe. From e2a50a7fffc974b4823dda8cddf5e5343f1da603 Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Fri, 2 Oct 2026 23:18:11 +0200 Subject: [PATCH 121/652] test: isolate Windows platform detection exit status --- deployment/windows/Test-DisposableMsi.ps1 | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/deployment/windows/Test-DisposableMsi.ps1 b/deployment/windows/Test-DisposableMsi.ps1 index 52aedd4b502..3c94db9f0f0 100644 --- a/deployment/windows/Test-DisposableMsi.ps1 +++ b/deployment/windows/Test-DisposableMsi.ps1 @@ -26,8 +26,8 @@ if ([IO.Path]::GetExtension($packagePath) -ine '.msi' -or (Get-FileHash -Literal $msi=& (Join-Path $PSScriptRoot 'Get-MsiIdentity.ps1') -Package $packagePath | ConvertFrom-Json $upgrade=if($Edition -eq 'customer'){'{32A585D7-9A78-4AD2-AF72-D0266EFC709D}'}else{'{A4374699-436F-4917-9A4E-223F62A9E634}'} if ($msi.upgrade_code -ine $upgrade -or $msi.template -notmatch '^x64;' -or $msi.product_code -notmatch '^\{[A-Fa-f0-9-]{36}\}$') { throw 'Unexpected MSI edition or architecture.' } -$compatibility=& (Join-Path $PSScriptRoot 'Get-WindowsCompatibility.ps1') -if ($LASTEXITCODE -ne 0) { throw 'Unsupported guest operating system.' } +$compatibility=(& (Join-Path ([Environment]::GetFolderPath('System')) 'WindowsPowerShell/v1.0/powershell.exe') -NoProfile -NonInteractive -File (Join-Path $PSScriptRoot 'Get-WindowsCompatibility.ps1') | Out-String).Trim() +if ($LASTEXITCODE -ne 0 -or $compatibility -notmatch '^(windows_10|windows_11|server_2016|server_2019|server_2022|server_2025)$') { throw 'Unsupported guest operating system.' } $install=if($Edition -eq 'customer'){Join-Path $env:ProgramFiles 'Swan Remote Support'}else{Join-Path $env:LOCALAPPDATA 'SwanRemoteSupport-Technician'} $main=Join-Path $install $(if($Edition -eq 'customer'){'Swan Remote Support.exe'}else{'SwanRemoteSupport-Technician.exe'}) $agent=if($Edition -eq 'customer'){Join-Path $env:ProgramData 'SwanRemoteSupport/swan-agent.exe'}else{Join-Path $install 'swan-agent.exe'} @@ -72,7 +72,7 @@ if($Phase -eq 'repair' -and $Edition -eq 'customer'){ $operation=if($Phase -eq 'install'){'/i'}elseif($Phase -eq 'repair'){'/fvamus'}else{'/x'} $log=Join-Path $resultRoot 'msi.log' $process=Start-Process -FilePath (Join-Path ([Environment]::GetFolderPath('System')) 'msiexec.exe') -ArgumentList @($operation,('"'+$packagePath+'"'),'/qn','/norestart','/l*v',('"'+$log+'"')) -WindowStyle Hidden -Wait -PassThru -$report=[ordered]@{edition=$Edition;phase=$Phase;source=$SourceRevision;package_sha256=$PackageSha256.ToLowerInvariant();msi=$msi;platform=$compatibility;os=(Get-CimInstance Win32_OperatingSystem | Select-Object Caption,Version,BuildNumber,OSArchitecture);exit_code=$process.ExitCode;reboot_required=($process.ExitCode -eq 3010);passed=$false;scope='Bare MSI lifecycle only; no company enrollment, publisher, update or session acceptance'} +$report=[ordered]@{edition=$Edition;phase=$Phase;source=$SourceRevision;runner_sha256=(Get-FileHash -LiteralPath $PSCommandPath -Algorithm SHA256).Hash.ToLowerInvariant();package_sha256=$PackageSha256.ToLowerInvariant();agent_sha256=$AgentSha256.ToLowerInvariant();msi=$msi;platform=$compatibility;os=(Get-CimInstance Win32_OperatingSystem | Select-Object Caption,Version,BuildNumber,OSArchitecture);exit_code=$process.ExitCode;reboot_required=($process.ExitCode -eq 3010);passed=$false;scope='Bare MSI lifecycle only; no company enrollment, publisher, update or session acceptance'} try{ if($process.ExitCode -notin @(0,3010)){throw "MSI phase failed ($($process.ExitCode)); inspect private log."} if($Phase -eq 'uninstall'){ From e64f7f86a50ee70129125dc338876b0b24927cf1 Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Fri, 2 Oct 2026 23:23:27 +0200 Subject: [PATCH 122/652] ci: supersede stale automatic checks while preserving manual builds --- .github/workflows/ci.yml | 4 ++++ .github/workflows/company-product.yml | 3 +++ .github/workflows/flutter-ci.yml | 4 ++++ 3 files changed, 11 insertions(+) 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 index 9ff915e760c..2a8b4ed9e6f 100644 --- a/.github/workflows/company-product.yml +++ b/.github/workflows/company-product.yml @@ -7,6 +7,9 @@ on: paths: ['product/**', 'deployment/**', '.dockerignore', '.github/workflows/company-product.yml'] permissions: contents: read +concurrency: + group: ${{ github.workflow }}-${{ github.event_name == 'workflow_dispatch' && github.run_id || github.ref }} + cancel-in-progress: ${{ github.event_name != 'workflow_dispatch' }} jobs: verify: strategy: 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 From b54063006f7353527baa7cf53eab5874e1402a7e Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Fri, 2 Oct 2026 23:42:44 +0200 Subject: [PATCH 123/652] test: enumerate MSI manifests correctly in Windows PowerShell --- deployment/windows/Test-DisposableMsi.ps1 | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/deployment/windows/Test-DisposableMsi.ps1 b/deployment/windows/Test-DisposableMsi.ps1 index 3c94db9f0f0..6d2ae29ddef 100644 --- a/deployment/windows/Test-DisposableMsi.ps1 +++ b/deployment/windows/Test-DisposableMsi.ps1 @@ -35,7 +35,10 @@ $files=@() if ($Edition -eq 'technician' -and $TechnicianSha256 -notmatch '^[a-fA-F0-9]{64}$') { throw 'Pinned technician executable hash required.' } if ($Edition -eq 'customer') { if ($ManifestSha256 -notmatch '^[a-fA-F0-9]{64}$' -or (Get-FileHash -LiteralPath $Manifest -Algorithm SHA256).Hash -ine $ManifestSha256) { throw 'Pinned customer manifest required.' } - $files=@(Get-Content -LiteralPath $Manifest -Raw | ConvertFrom-Json) + # Windows PowerShell 5.1 emits a JSON array as one pipeline object. Assign + # it first, then enumerate its entries consistently in both supported shells. + $decoded=Get-Content -LiteralPath $Manifest -Raw | ConvertFrom-Json + $files=@($decoded) $seen=@{} foreach($file in $files){ if ($file.path -notmatch '^[A-Za-z0-9 _\-.@+/]+$' -or @($file.path.Split('/') | Where-Object {$_ -in @('','.', '..') -or $_.EndsWith('.') -or $_.EndsWith(' ')}).Count -or $seen.ContainsKey($file.path) -or $file.sha256 -notmatch '^[a-fA-F0-9]{64}$') { throw 'Unsafe manifest entry.' } From 0b35de2c69be91488c8ee6902e5ab95fce2a15bc Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Fri, 2 Oct 2026 23:52:21 +0200 Subject: [PATCH 124/652] docs: record real Server 2016 MSI lifecycle results --- docs/ACCEPTANCE.md | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index b7dda4ca2cd..b64b98c94db 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -949,3 +949,24 @@ command was 280 characters; Microsoft's documented limit is A new private provisioning seed uses a 192-character command and validates this bound before generation. The failed disk remained hardware read-only throughout diagnosis. The corrected seed still requires an actual successful installation. + +The corrected private VM subsequently completed installation of Windows Server +2016 Standard Evaluation with Desktop Experience, x64 build `14393`. Encrypted +NTLM WinRM verified the guest identity and administrator context without changing +host trust settings. Both unsigned `1.5.0` MSIs identified above passed actual +`msiexec` install, repair and uninstall with exit code `0` and no requested reboot. +The harness SHA-256 was +`26ad362ee1d5d1793ee3ee35548d18ab84efc2973f4a663a3d62de50a53bf385`. +Customer installation and repair verified all 96 manifest files, the agent hash, +and the running LocalSystem service's exact executable. Repair restored a +deliberately damaged packaged license file. A subsequent read-only cleanup audit +confirmed all 96 files were absent and Windows Installer reported the customer +product unregistered (`ProductState=-1`). Technician checks verified its main +executable and agent hashes; repair did not deliberately damage a technician +file. Both lifecycle tests ran under the guest administrator account. + +This is bare MSI lifecycle evidence for one Windows build. Standard-user +technician installation, upgrade, reboot, company bootstrap/enrollment, trusted +publisher validation, automatic updates, remote sessions and the remaining +Windows compatibility matrix are still unverified. VM credentials and raw +installation logs remain in the private lab outside the repository. From f8025c8c8b1f40cdc8af26930c4b1814cbe93160 Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Sat, 3 Oct 2026 00:20:14 +0200 Subject: [PATCH 125/652] test: record standard-user MSI failures and verify complete cleanup --- deployment/windows/Test-DisposableMsi.ps1 | 26 +++++++++++++++++--- deployment/windows/VM-TESTING.md | 8 ++++-- docs/ACCEPTANCE.md | 30 +++++++++++++++++++++++ docs/INSTALLER_REPAIR.md | 7 ++++++ 4 files changed, 65 insertions(+), 6 deletions(-) diff --git a/deployment/windows/Test-DisposableMsi.ps1 b/deployment/windows/Test-DisposableMsi.ps1 index 6d2ae29ddef..7962bfdbcdc 100644 --- a/deployment/windows/Test-DisposableMsi.ps1 +++ b/deployment/windows/Test-DisposableMsi.ps1 @@ -10,7 +10,8 @@ param( [Parameter(Mandatory)][string]$ResultDirectory, [string]$Manifest, [string]$ManifestSha256, - [string]$TechnicianSha256 + [string]$TechnicianSha256, + [switch]$RequireStandardUser ) $ErrorActionPreference='Stop' # This deliberately mutates a disposable guest. A private, independently @@ -20,7 +21,9 @@ $marker = Get-Content -LiteralPath 'C:/SwanLab/disposable-lab.json' -Raw | Conve if ($marker.purpose -cne 'swan-disposable-windows-acceptance' -or $marker.computer_name -cne $ExpectedComputerName) { throw 'Disposable guest marker missing or mismatched.' } if (-not [Environment]::Is64BitProcess) { throw 'Run the x64 test shell.' } $identity=[Security.Principal.WindowsIdentity]::GetCurrent() -if (-not ([Security.Principal.WindowsPrincipal]::new($identity)).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { throw 'Disposable MSI tests require an administrator.' } +$administrator=([Security.Principal.WindowsPrincipal]::new($identity)).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) +if ($Edition -eq 'customer' -and -not $administrator) { throw 'Customer MSI tests require an administrator.' } +if ($RequireStandardUser -and ($Edition -ne 'technician' -or $administrator)) { throw 'Expected a standard-user technician test.' } $packagePath=(Resolve-Path -LiteralPath $Package).Path if ([IO.Path]::GetExtension($packagePath) -ine '.msi' -or (Get-FileHash -LiteralPath $packagePath -Algorithm SHA256).Hash -ine $PackageSha256) { throw 'MSI hash or format mismatch.' } $msi=& (Join-Path $PSScriptRoot 'Get-MsiIdentity.ps1') -Package $packagePath | ConvertFrom-Json @@ -31,6 +34,8 @@ if ($LASTEXITCODE -ne 0 -or $compatibility -notmatch '^(windows_10|windows_11|se $install=if($Edition -eq 'customer'){Join-Path $env:ProgramFiles 'Swan Remote Support'}else{Join-Path $env:LOCALAPPDATA 'SwanRemoteSupport-Technician'} $main=Join-Path $install $(if($Edition -eq 'customer'){'Swan Remote Support.exe'}else{'SwanRemoteSupport-Technician.exe'}) $agent=if($Edition -eq 'customer'){Join-Path $env:ProgramData 'SwanRemoteSupport/swan-agent.exe'}else{Join-Path $install 'swan-agent.exe'} +$installer=New-Object -ComObject WindowsInstaller.Installer +$shortcut=if($Edition -eq 'technician'){Join-Path ([Environment]::GetFolderPath('Programs')) 'Swan Remote Support Technician.lnk'}else{$null} $files=@() if ($Edition -eq 'technician' -and $TechnicianSha256 -notmatch '^[a-fA-F0-9]{64}$') { throw 'Pinned technician executable hash required.' } if ($Edition -eq 'customer') { @@ -47,9 +52,11 @@ if ($Edition -eq 'customer') { if (@($files | Where-Object path -eq 'LICENSE.txt').Count -ne 1) { throw 'Expected a single packaged license.' } } function Assert-Payload { + if ($installer.ProductState($msi.product_code) -ne 5) { throw 'MSI product is not registered as installed for this account.' } foreach($path in @($main,$agent)){if(-not(Test-Path -LiteralPath $path -PathType Leaf)){throw 'Installed application or agent missing.'}} if ((Get-FileHash -LiteralPath $agent -Algorithm SHA256).Hash -ine $AgentSha256) { throw 'Installed agent hash mismatch.' } if ($Edition -eq 'technician' -and (Get-FileHash -LiteralPath $main -Algorithm SHA256).Hash -ine $TechnicianSha256) { throw 'Installed technician hash mismatch.' } + if ($Edition -eq 'technician' -and -not (Test-Path -LiteralPath $shortcut -PathType Leaf)) { throw 'Technician shortcut missing.' } foreach($file in $files){ $path=Join-Path $install $file.path if ((Get-Item -LiteralPath $path).Attributes -band [IO.FileAttributes]::ReparsePoint -or (Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash -ine $file.sha256) { throw "Installed file differs: $($file.path)" } @@ -60,6 +67,7 @@ function Assert-Payload { } } if($Phase -eq 'install'){ + if ($installer.ProductState($msi.product_code) -ne -1) { throw 'Fresh installation requires an unregistered product.' } if((Test-Path -LiteralPath $install) -or (Test-Path -LiteralPath $agent)){throw 'Fresh installation requires an unused product directory.'} if($Edition -eq 'customer' -and (Get-CimInstance Win32_Service -Filter "Name='Swan Remote Support'")){throw 'Customer service already present.'} }else{ @@ -74,12 +82,22 @@ if($Phase -eq 'repair' -and $Edition -eq 'customer'){ } $operation=if($Phase -eq 'install'){'/i'}elseif($Phase -eq 'repair'){'/fvamus'}else{'/x'} $log=Join-Path $resultRoot 'msi.log' -$process=Start-Process -FilePath (Join-Path ([Environment]::GetFolderPath('System')) 'msiexec.exe') -ArgumentList @($operation,('"'+$packagePath+'"'),'/qn','/norestart','/l*v',('"'+$log+'"')) -WindowStyle Hidden -Wait -PassThru -$report=[ordered]@{edition=$Edition;phase=$Phase;source=$SourceRevision;runner_sha256=(Get-FileHash -LiteralPath $PSCommandPath -Algorithm SHA256).Hash.ToLowerInvariant();package_sha256=$PackageSha256.ToLowerInvariant();agent_sha256=$AgentSha256.ToLowerInvariant();msi=$msi;platform=$compatibility;os=(Get-CimInstance Win32_OperatingSystem | Select-Object Caption,Version,BuildNumber,OSArchitecture);exit_code=$process.ExitCode;reboot_required=($process.ExitCode -eq 3010);passed=$false;scope='Bare MSI lifecycle only; no company enrollment, publisher, update or session acceptance'} +$windows=Get-ItemProperty -LiteralPath 'HKLM:/SOFTWARE/Microsoft/Windows NT/CurrentVersion' +$os=[ordered]@{Caption=$windows.ProductName;Version=('{0}.{1}.{2}' -f $windows.CurrentMajorVersionNumber,$windows.CurrentMinorVersionNumber,$windows.CurrentBuildNumber);BuildNumber=$windows.CurrentBuildNumber;OSArchitecture='64-bit';revision=$windows.UBR} +$report=[ordered]@{edition=$Edition;phase=$Phase;source=$SourceRevision;administrator=$administrator;standard_user_required=[bool]$RequireStandardUser;runner_sha256=(Get-FileHash -LiteralPath $PSCommandPath -Algorithm SHA256).Hash.ToLowerInvariant();package_sha256=$PackageSha256.ToLowerInvariant();agent_sha256=$AgentSha256.ToLowerInvariant();msi=$msi;platform=$compatibility;os=$os;exit_code=$null;reboot_required=$false;passed=$false;scope='Bare MSI lifecycle only; no company enrollment, publisher, update or session acceptance'} try{ + $process=Start-Process -FilePath (Join-Path ([Environment]::GetFolderPath('System')) 'msiexec.exe') -ArgumentList @($operation,('"'+$packagePath+'"'),'/qn','/norestart','/l*v',('"'+$log+'"')) -WindowStyle Hidden -Wait -PassThru + $report.exit_code=$process.ExitCode + $report.reboot_required=($process.ExitCode -eq 3010) if($process.ExitCode -notin @(0,3010)){throw "MSI phase failed ($($process.ExitCode)); inspect private log."} if($Phase -eq 'uninstall'){ + if ($installer.ProductState($msi.product_code) -ne -1) { throw 'Uninstall retained MSI product registration.' } foreach($path in @($main,$agent)){if(Test-Path -LiteralPath $path){throw 'Uninstall retained the application or agent executable.'}} + foreach($file in $files){if(Test-Path -LiteralPath (Join-Path $install $file.path)){throw "Uninstall retained packaged file: $($file.path)"}} + if($Edition -eq 'technician'){ + foreach($path in @($shortcut,(Join-Path $install 'Open-Technician.ps1'),(Join-Path $install 'LICENSE.txt'))){if(Test-Path -LiteralPath $path){throw 'Uninstall retained technician payload or shortcut.'}} + foreach($name in @('Executable','Agent','Launcher','License')){if(Get-ItemProperty -LiteralPath 'HKCU:\Software\SwanRemoteSupport\Technician' -Name $name -ErrorAction SilentlyContinue){throw 'Uninstall retained technician component registration.'}} + } if($Edition -eq 'customer' -and (Get-CimInstance Win32_Service -Filter "Name='Swan Remote Support'")){throw 'Uninstall retained the customer service.'} }else{Assert-Payload} $report.passed=$true diff --git a/deployment/windows/VM-TESTING.md b/deployment/windows/VM-TESTING.md index 5c4012264a3..5e2d27424e7 100644 --- a/deployment/windows/VM-TESTING.md +++ b/deployment/windows/VM-TESTING.md @@ -84,8 +84,12 @@ verify every manifested file and the running service's exact executable, arguments and LocalSystem identity. Customer repair first damages the verified packaged license file, then checks that MSI repair restores the complete manifest. Both editions check agent hashes, and technician checks its executable -hash. Uninstall checks removal of application/agent executables and the customer -service. Exit code 3010 is recorded as requiring a reboot; perform and verify +hash. Use `-RequireStandardUser` for technician tests to reject an elevated +account; customer phases require administrator access. Reports record the +actual administrator status. Both editions check Windows Installer registration. +Uninstall checks all customer manifest files, application/agent executables, +the customer service, and technician launcher, license, shortcut and component +registration. Exit code 3010 is recorded as requiring a reboot; perform and verify that reboot before interpreting later tests as reboot acceptance. This runner does not configure a company, approve enrollment, test signatures, diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index b64b98c94db..f8123d17e84 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -970,3 +970,33 @@ technician installation, upgrade, reboot, company bootstrap/enrollment, trusted publisher validation, automatic updates, remote sessions and the remaining Windows compatibility matrix are still unverified. VM credentials and raw installation logs remain in the private lab outside the repository. + +A separate standard account was tested on the same Server 2016 VM. Initial +batch launch was denied by the guest's batch-logon rights; granting only that +right allowed the task to execute without administrator membership. SSH and +batch MSI attempts returned `1601` because they could not connect to Windows +Installer. After an actual standard-user desktop logon, the MSI instead returned +`1625`. Its log reports effective `DisableMsi=1`, rejects a non-assigned +application for a non-admin account, and confirms the package is marked for +installation without elevation. No installer policy was changed. The denied +installation left the product unregistered and endpoint/agent absent. This +does not establish successful standard-user installation; company-managed +assignment and client Windows editions still need testing. See Microsoft's +[DisableMSI policy](https://learn.microsoft.com/en-us/windows/win32/msi/disablemsi). +The temporary private VM logon password was removed automatically, with cleanup +and disabled auto-logon verified after reboot. This reboot provisioned the test +desktop; it is not product reboot acceptance. + +The lifecycle runner now records actual account elevation, supports an explicit +standard-user technician requirement, and checks installer registration plus +packaged-file, shortcut and component-registration cleanup. It reads OS version +from the registry so standard-user reports do not require WMI permission, and +writes a failed result even if process creation fails. The failed interactive +test recorded runner SHA-256 +`4c70cfe5a4f8ce60e599460febb7bfab5a166860af995e9260df13f12be96396`. + +[Native Windows build 37065751842](https://github.com/rijff24/SwanRemoteSupport/actions/runs/37065751842) +completed successfully for source +`6840d910c252d8fb7d5c34ca7dffbc2a2ddffb4b`, including the later company +management and transport-supervision source. Its unsigned artifacts still +require their own integrity, installation, company setup and session checks. diff --git a/docs/INSTALLER_REPAIR.md b/docs/INSTALLER_REPAIR.md index 449124a5698..930cb553f5a 100644 --- a/docs/INSTALLER_REPAIR.md +++ b/docs/INSTALLER_REPAIR.md @@ -5,6 +5,13 @@ reviewing the company HTTPS hostname. Customer installation requires administrator access. Technician installation uses the current user's local application directory. +Windows Installer policy still applies to technician MSI setup and repair. +On the tested Server 2016 build, a standard desktop account was blocked from +installing the non-assigned MSI with error `1625`, while administrator lifecycle +tests succeeded. Company IT must arrange an approved deployment in the intended +user context when policy blocks unmanaged per-user packages. Setup does not +change [Windows Installer policy](https://learn.microsoft.com/en-us/windows/win32/msi/disablemsi). + Repair requires a previously recorded installation and its signed `installed-release.json`. The supplied release envelope must exactly match that record, and its sequence must equal the durable installed sequence. The agent From c27e9b75a2cf655dbe875327403024922e68a36f Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Sat, 3 Oct 2026 00:47:24 +0200 Subject: [PATCH 126/652] fix: verify RFC3161 company signatures with the Windows SDK --- deployment/windows/COMPANY-SIGNING.md | 35 ++++++++++++++---- deployment/windows/Sign-CompanyArtifact.ps1 | 13 +++++-- docs/ACCEPTANCE.md | 39 +++++++++++++++++++++ 3 files changed, 78 insertions(+), 9 deletions(-) diff --git a/deployment/windows/COMPANY-SIGNING.md b/deployment/windows/COMPANY-SIGNING.md index b295a27274d..f991068dae0 100644 --- a/deployment/windows/COMPANY-SIGNING.md +++ b/deployment/windows/COMPANY-SIGNING.md @@ -7,7 +7,7 @@ keys. Default distribution consumes unchanged project-signed artifacts. `Sign-CompanyArtifact.ps1` signs a new copy of one locally prepared EXE or MSI. Supply its independently verified input SHA-256, a new output path, the exact company publisher, a certificate thumbprint in the local Personal store, a -Microsoft-signed Windows SDK SignTool path and the company's chosen HTTPS +Microsoft-signed Windows SDK SignTool path and the company's chosen HTTP or HTTPS RFC3161 timestamp endpoint. Select `LocalMachine` only if that is where the worker account's certificate is configured. The script does not import or export keys, install trust roots, accept remote scripts or modify its input. @@ -19,6 +19,19 @@ SignTool uses SHA-256 file and timestamp digests; subsequent verification pins the trusted Authenticode publisher and certificate SHA-256 and requires a timestamp. A mismatched input is rejected before signing. +SignTool verifies every signature under Authenticode policy with `/pa /all /tw`; +any error or warning prevents publication, including a missing timestamp. The +SDK check is authoritative for RFC3161 timestamps because the tested Server +2016 PowerShell reports no timestamp certificate even after SDK verification +confirms a valid RFC3161 timestamp. + +The timestamp URL is explicit; the tool does not retry with a different scheme. +Credentials, query parameters and fragments are rejected so the public receipt +cannot carry timestamp-service credentials. RFC3161 responses remain signed +and trusted timestamp verification is required. The tested SDK rejected the +HTTPS DigiCert URL; DigiCert documents +[its HTTP RFC3161 endpoint](https://knowledge.digicert.com/general-information/rfc3161-compliant-time-stamp-authority-server). + The output `.signing.json` records the original and signed hashes, publisher, certificate fingerprint and timestamp endpoint. It is evidence for preparing release metadata, not a signed release manifest or administrator approval. @@ -37,11 +50,21 @@ Changing an existing deployment's pinned release issuer requires a separate trusted migration; changing an environment variable does not authorize clients to accept another issuer. Automated issuer migration remains incomplete. -No actual company signature has been produced in this test environment. -PowerShell parsing and wrong-input-hash rejection were checked against a real -unsigned agent; its bytes stayed unchanged and no output was created. Valid -certificate/provider signing, timestamp verification, worker consumption and -signed install/update acceptance remain to be tested. +A private Server 2016 VM now exercises actual software-provider signing with a +non-exportable lab key and a self-signed certificate trusted only in that VM. +The integration signed a copy of the native agent, obtained a DigiCert RFC3161 +timestamp, passed SDK verification and matched the pinned publisher/certificate +through `Verify-Package.ps1`, including from a standard account. The unsigned +input stayed unchanged. An otherwise trusted signature without a timestamp +produced SDK warning exit `2` and was rejected. Tampered bytes, a different +publisher, a different certificate, and timestamp URLs containing credentials, +queries or fragments were rejected. Earlier wrong-input-hash rejection also +left the input unchanged and created no output. + +This is isolated signing integration evidence, not production provider approval. +MSI signing, hardware/cloud providers, worker consumption and complete signed +install/update acceptance remain unverified. No lab signing key was exported or +added to the host's trust stores. See [Microsoft SignTool documentation](https://learn.microsoft.com/en-us/windows/win32/seccrypto/signtool) for certificate-store selection and RFC3161 signing options. diff --git a/deployment/windows/Sign-CompanyArtifact.ps1 b/deployment/windows/Sign-CompanyArtifact.ps1 index 6f50b842f13..40a47f71b2d 100644 --- a/deployment/windows/Sign-CompanyArtifact.ps1 +++ b/deployment/windows/Sign-CompanyArtifact.ps1 @@ -19,7 +19,10 @@ $destination=[IO.Path]::GetFullPath($OutputPath) if ([IO.Path]::GetExtension($destination).ToLowerInvariant() -ne $extension -or (Test-Path -LiteralPath $destination) -or (Test-Path -LiteralPath ($destination+'.signing.json'))) { throw 'Use a new output filename with the same artifact extension.' } if (-not (Test-Path -LiteralPath ([IO.Path]::GetDirectoryName($destination)) -PathType Container)) { throw 'Output directory must already exist.' } $timestamp=[Uri]$TimestampUrl -if (-not $timestamp.IsAbsoluteUri -or $timestamp.Scheme -ne 'https' -or -not $timestamp.Host -or $timestamp.UserInfo -or $timestamp.Fragment) { throw 'Use an HTTPS RFC3161 timestamp endpoint without embedded credentials.' } +# RFC3161 responses are signed and verified by SignTool/Authenticode. SDK tools +# and common providers use HTTP; accept the operator's explicit URL without +# falling back or recording credentials in the public signing receipt. +if (-not $timestamp.IsAbsoluteUri -or $timestamp.Scheme -notin @('http','https') -or -not $timestamp.Host -or $timestamp.UserInfo -or $timestamp.Query -or $timestamp.Fragment) { throw 'Use an HTTP or HTTPS RFC3161 timestamp endpoint without credentials, query parameters or fragments.' } $tool=(Resolve-Path -LiteralPath $SignToolPath).Path $toolSignature=Get-AuthenticodeSignature -LiteralPath $tool if ($toolSignature.Status -ne 'Valid' -or $toolSignature.SignerCertificate.GetNameInfo([Security.Cryptography.X509Certificates.X509NameType]::SimpleName,$false) -ne 'Microsoft Corporation') { throw 'SignTool must be a trusted Microsoft-signed Windows SDK executable.' } @@ -37,11 +40,15 @@ try { if ($CertificateStore -eq 'LocalMachine') {$arguments+='/sm'} & $tool @arguments $staging if ($LASTEXITCODE -ne 0) { throw 'Company signing failed; no output artifact was published.' } + & $tool verify /pa /all /tw $staging + if ($LASTEXITCODE -ne 0) { throw 'Company signature or trusted timestamp verification failed; no output artifact was published.' } $null=& (Join-Path $PSScriptRoot 'Verify-Package.ps1') -Path $staging -Publisher $Publisher -CertificateSha256 $certificateHash - if (-not (Get-AuthenticodeSignature -LiteralPath $staging).TimeStamperCertificate) { throw 'Company signature requires a timestamp.' } + # Windows PowerShell 5.1 on Server 2016 can return a null timestamp + # certificate for a valid RFC3161 signature. SignTool /tw above warns + # (nonzero exit) when a timestamp is absent and verifies its trust. $hash=(Get-FileHash -LiteralPath $staging -Algorithm SHA256).Hash [IO.File]::Move($staging,$destination) - $record=@{schema=1;input_sha256=$ExpectedSha256.ToLowerInvariant();sha256=$hash.ToLowerInvariant();publisher=$Publisher;publisher_certificate_sha256=$certificateHash.ToLowerInvariant();timestamp_url=$timestamp.AbsoluteUri;artifact=[IO.Path]::GetFileName($destination)} + $record=@{schema=1;input_sha256=$ExpectedSha256.ToLowerInvariant();sha256=$hash.ToLowerInvariant();publisher=$Publisher;publisher_certificate_sha256=$certificateHash.ToLowerInvariant();timestamp_url=$timestamp.AbsoluteUri;timestamp_verification='signtool-verify-pa-all-tw';artifact=[IO.Path]::GetFileName($destination)} [IO.File]::WriteAllText($destination+'.signing.json',($record | ConvertTo-Json)) Write-Output ('Verified company-signed artifact: '+$destination) } catch { diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index f8123d17e84..a2c4a2265c7 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -1000,3 +1000,42 @@ completed successfully for source `6840d910c252d8fb7d5c34ca7dffbc2a2ddffb4b`, including the later company management and transport-supervision source. Its unsigned artifacts still require their own integrity, installation, company setup and session checks. + +That newer artifact was subsequently downloaded and checked against GitHub's +published SHA-256 +`4099acc0e992e8369738515c74a34d26625e51fc567ba5e3955f82b76d28a813`. +Safe ZIP extraction checked all 18 entry paths, sizes, CRCs and included payload +checksums. Its unsigned management, agent and worker binaries remain separate +from the earlier MSI lifecycle results. + +Actual optional company signing now passes on the private Server 2016 VM using +Microsoft Windows SDK SignTool from the Microsoft-owned NuGet package +`Microsoft.Windows.SDK.BuildTools 10.0.26100.1`. The package matched its published +SHA-512; the x64 tool SHA-256 is +`38da9179b56d88adc0694ebf7393b9c44727c344e2df8dfa32399a4c9694715c`. +Its Microsoft Authenticode signature was verified on the host and guest before +execution. A non-exportable software-provider lab key and a certificate trusted +only in the disposable guest signed a new copy of the source `6840d91` agent. +The original agent SHA-256 +`d36cc9e4fea3b3331d64013a9c8a25f17e80f1456c84be16aef074b4d2bdd526` +stayed unchanged; the signed copy is +`956ecc4462841cfb94372429be6d33dfe1b82a25069213693b601aca4f9722cf`. + +The first SDK attempt rejected the HTTPS timestamp URL. The integration now +accepts the operator's explicit HTTP or HTTPS RFC3161 URL, without automatic +fallback, and rejects credentials, queries and fragments. DigiCert's documented +HTTP endpoint returned a timestamp that SDK verification accepted. Server 2016 +PowerShell nevertheless returned a null `TimeStamperCertificate`; the integration +now relies on SDK `/pa /all /tw` verification rather than that unreliable field. +An otherwise trusted signature with no timestamp produced warning exit `2` and +was rejected by the same verification gate. The valid signed agent passes the +pinned publisher/certificate verifier under both administrator and standard +accounts. Wrong publisher, wrong certificate and a one-byte tamper are rejected; +the tampered fixture reports `HashMismatch`. Unsafe timestamp URLs created no +artifact or signing receipt. The tested signing-script SHA-256 is +`2c1baf6d00fe7e876f553341d221389b97dbd10fe6a4eeed6c5b3801aa2d1f4c`. + +No lab signing key was exported, no host trust store changed, and no production +signing approval was obtained. This validates one EXE signing integration, not +signed MSI publication, worker consumption, complete company installation, +automatic updates or native sessions. Raw logs and test artifacts stay private. From 82c8c65c1539c882f874156968fa14c336a9e25f Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Sat, 3 Oct 2026 01:16:45 +0200 Subject: [PATCH 127/652] Verify signed packages without writing to deployment directories --- docs/ACCEPTANCE.md | 49 ++++++++++++++++++++++++++++++++++++- product/agent/src/update.rs | 20 ++++++++++++--- 2 files changed, 64 insertions(+), 5 deletions(-) diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index a2c4a2265c7..49a63ca1a4d 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -1,11 +1,58 @@ # Configurable product acceptance report -Development status, 2 October 2026. **The product is not complete or approved for +Development status, 3 October 2026. **The product is not complete or approved for production publication.** A passing component test is not evidence of a working native support session or a clean-machine installation. ## Evidence obtained +### Windows full-stack company server and technician worker, 3 October + +The disposable Windows Server 2016 Standard Evaluation guest (Desktop +Experience, build 14393) installed the native management service from source +`6840d910c252d8fb7d5c34ca7dffbc2a2ddffb4b` using the installer at `c27e9b7`. +Its mandatory publisher verification passed with a private, guest-trusted lab +code-signing certificate. The installer deployed the pinned rendezvous, relay +and HTTPS components. Stopping the management service stopped all three owned +children; restarting it restored service. This does not prove crash recovery +or product behavior after reboot. + +A private HTTPS fixture deliberately disabled automatic CA trust installation. +TLS 1.2 requests rejected its untrusted certificate, then succeeded after +explicitly importing its public CA into the guest's root store. Fresh company +setup, separate customer/technician branding, administrator TOTP login, +rejection of the consumed setup code, and worker registration passed over +HTTPS. Host trust, DNS, production services and firewall were not changed. + +The real outbound Windows worker consumed an administrator-approved, +Ed25519-signed test release, downloaded and verified its company-signed +technician EXE and agent, and uploaded a completed ten-entry installation +bundle. Anonymous technician download was rejected. An authenticated download +matched SHA-256 +`8308a3842d1536150585097b019acb77f4219a0cd19246778ff9c743bdfacbc6`. +The bootstrap contained only schema, edition, company identity, HTTPS endpoint +and public profile/release verification keys. Credentials and signing private +keys remain in the isolated lab, outside the repository. + +These results prove a technician **EXE bundle generation and access-control +flow** on that guest. They do not prove installation of that bundle, customer +package generation, MSI signing, automatic updates, native sessions, public +Internet reachability, other Windows versions, or production signing approval. + +The subsequent standard-user bundle installation passed HTTPS company setup +and profile pinning, then failed package verification with Windows access +denied: the agent attempted to write its verifier beside the package in the +administrator-extracted, read-only bundle directory. The agent now runs its +embedded verifier as an encoded PowerShell command with quoted literal +arguments, without creating or trusting a helper in that directory. A rebuilt +GNU development agent passed package verification under the same standard +account against the same signed package and read-only directory. Its agent +suite passed 21 tests with two explicit integration fixtures ignored. This is +verification regression evidence only: the replacement development agent is +unsigned, was not published in a bundle, and has not completed installation. + +### Earlier component evidence + - Public baseline tag `swan-single-company-baseline-1.4.9` preserves the original Swan deployment and build instructions. Its source was not replaced. - Thirty-three product tests pass on the local Windows development host at `6438ce2` (18 agent, eight management, seven protocol), including diff --git a/product/agent/src/update.rs b/product/agent/src/update.rs index 63fcfb8607a..0604ddbe853 100644 --- a/product/agent/src/update.rs +++ b/product/agent/src/update.rs @@ -250,13 +250,25 @@ pub fn powershell_command()->Result { #[cfg(windows)] fn verify_publisher(path:&Path,release:&Release)->Result<()> { - let script=path.parent().context("Missing update directory")?.join("Verify-Package.ps1"); - std::fs::write(&script,include_str!("../../../deployment/windows/Verify-Package.ps1"))?; - let status=powershell_command()?.args(["-NoLogo","-NoProfile","-NonInteractive","-ExecutionPolicy","Bypass","-File"]) - .arg(script).arg("-Path").arg(path).arg("-Publisher").arg(&release.publisher).arg("-CertificateSha256").arg(&release.publisher_certificate_sha256).arg("-Sha256").arg(&release.sha256).status()?; + // Packages may reside in a read-only deployment share. Execute the embedded + // verifier without writing beside the package or trusting a mutable helper. + let encoded=publisher_verification_command(path,release)?; + let status=powershell_command()?.args(["-NoLogo","-NoProfile","-NonInteractive","-ExecutionPolicy","Bypass","-EncodedCommand"]) + .arg(encoded).status()?; ensure!(status.success(),"Package publisher verification failed");Ok(()) } +#[cfg(any(windows,test))] +fn publisher_verification_command(path:&Path,release:&Release)->Result { + let literal=|value:&str|format!("'{}'",value.replace('\'',"''")); + let command=format!("& {{\n{}\n}} -Path {} -Publisher {} -CertificateSha256 {} -Sha256 {}", + include_str!("../../../deployment/windows/Verify-Package.ps1"), + literal(path.to_str().context("Package path is not valid Unicode")?), + literal(&release.publisher),literal(&release.publisher_certificate_sha256),literal(&release.sha256)); + let bytes:Vec=command.encode_utf16().flat_map(u16::to_le_bytes).collect(); + Ok(STANDARD.encode(bytes)) +} + #[cfg(windows)] async fn verify_downloaded_publisher(path:&Path,release:&Release)->Result<()> { let path=path.to_owned();let release=release.clone(); From cea124fb14c7d861e6d50cbd20a5027ace57846f Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Sat, 3 Oct 2026 01:32:01 +0200 Subject: [PATCH 128/652] Recover failed company setup with a newer trusted release --- deployment/windows/CUSTOMER-MSI.md | 11 ++++++ deployment/windows/Install-Company.ps1 | 3 +- docs/ACCEPTANCE.md | 42 ++++++++++++++++++++++ product/agent/src/main.rs | 5 +-- product/agent/src/update.rs | 49 +++++++++++++++++++++++++- 5 files changed, 106 insertions(+), 4 deletions(-) diff --git a/deployment/windows/CUSTOMER-MSI.md b/deployment/windows/CUSTOMER-MSI.md index e2b603db49d..28292544806 100644 --- a/deployment/windows/CUSTOMER-MSI.md +++ b/deployment/windows/CUSTOMER-MSI.md @@ -40,6 +40,17 @@ configuration input without embedding credentials. The bare MSI does not enroll or silently select a company. Full deployment must use the company setup flow; new devices stay unavailable until approved. +If setup failed after creating its durable installation marker, retry the same +bundle first. To replace it with a corrected, newer signed release, run that +bundle's `Install-Company.ps1 -ReplaceFailedSetup` with the usual company-domain +confirmation. This explicit recovery retains the original signed receipt under +the protected state directory's `cancelled-setups` folder. It requires the pinned +release issuer, matching edition and a strictly newer sequence; it keeps support +blocked until the replacement's complete installed identity is verified. It does +not waive package signing or permit replacement during an active session/update. +Use `-Repair` for an already recorded release; it cannot be combined with +`-ReplaceFailedSetup`. + ## Evidence and remaining verification A real unsigned package was built from the hash-verified native `31b13aa` diff --git a/deployment/windows/Install-Company.ps1 b/deployment/windows/Install-Company.ps1 index 677e132b5d4..87f4ad128ac 100644 --- a/deployment/windows/Install-Company.ps1 +++ b/deployment/windows/Install-Company.ps1 @@ -1,5 +1,5 @@ [CmdletBinding()] -param([switch]$UnattendedConsent,[switch]$Repair,[string]$BootstrapPath,[string]$ConfirmedCompanyDomain) +param([switch]$UnattendedConsent,[switch]$Repair,[switch]$ReplaceFailedSetup,[string]$BootstrapPath,[string]$ConfirmedCompanyDomain) $ErrorActionPreference = 'Stop' $root = $PSScriptRoot if (-not $BootstrapPath) { $BootstrapPath = Join-Path $root 'bootstrap.json' } @@ -46,6 +46,7 @@ if ($Repair) { $packageArguments += '--repair'; $recordArguments += '--repair' } if ($LASTEXITCODE -ne 0) { throw 'Installer metadata, hash or publisher validation failed.' } $prepareArguments = @('prepare-installation',(Join-Path $root 'release.json'),$installers[0].FullName) if ($Repair) { $prepareArguments += '--repair' } +if ($ReplaceFailedSetup) { $prepareArguments += '--replace-failed-setup' } & $agent @prepareArguments if ($LASTEXITCODE -ne 0) { throw 'Cannot prepare explicit installation or preserve cancelled update evidence.' } $systemDirectory = [Environment]::GetFolderPath('System') diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index 49a63ca1a4d..bd4b3c5b7a2 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -51,6 +51,48 @@ suite passed 21 tests with two explicit integration fixtures ignored. This is verification regression evidence only: the replacement development agent is unsigned, was not published in a bundle, and has not completed installation. +### Customer package signing, installation and explicit setup recovery + +On the same Server 2016 guest, the worker generated a customer MSI bundle and +the server served it without authentication. Its downloaded hash matched the +worker result. Signing only that MSI container was deliberately insufficient: +setup enrolled the device but final installed-publisher verification rejected +the unsigned embedded customer executable. The durable setup marker remained +and the configuration task was not enabled; this was a failed acceptance test, +not a successful installation. + +The customer executable was then signed and timestamped in the guest. The +other native files were compared with the original 96-file source manifest +before export. Pinned WiX built a lab-only replacement MSI with that signed +executable and the signed agent. The host used the explicit test-input build +mode because the private signing root was installed only in the guest; no +host trust bypass was introduced. The replacement MSI was separately signed, +timestamped and verified in the guest, imported as signed test metadata, +approved, bundled by the worker, and downloaded publicly. Its bundle SHA-256 +was `2e7a4fe91820f2b67b2220cdc545fab3434a85987e966e51fdd6eab1941cf27d`. +Package version 1.5.1 is a private fixture built from the unchanged native +`6840d91` payload with a newly signed executable, not a published software release. + +Explicit recovery now requires a strictly newer, same-edition release from the +pinned issuer, retains the original failed setup receipt, and atomically replaces +the blocking marker under session/update exclusion. Tests reject another issuer, +another edition and an older sequence while retaining state and the old marker. +The actual guest recovery used a separately rebuilt, unsigned GNU development +agent to exercise this new preparation command, followed by the worker bundle's +normal installer and its signed agent. This is not yet proof of the new +`-ReplaceFailedSetup` switch in a newly generated native release bundle. + +The corrected company installation completed all package, installed-payload +and publisher checks. Both native customer and company services run, and the +SYSTEM `watch` task runs from its exact installed agent path. The setup marker +cleared, sequence two was recorded, and the original failed receipt was retained. +Device identity, device credential, consent revision and default attended-only +consent were unchanged. The server still reports the device as pending approval. +The guest has no Tailscale service. This proves local company installation and +enrollment recovery; it does not prove Internet transport, reboot, desktop +sessions, UAC, automatic-update recovery, fresh signed installation on another +guest, or compatibility on the other advertised Windows versions. + ### Earlier component evidence - Public baseline tag `swan-single-company-baseline-1.4.9` preserves the original diff --git a/product/agent/src/main.rs b/product/agent/src/main.rs index 06e9bf485ec..7a76c6a0c25 100644 --- a/product/agent/src/main.rs +++ b/product/agent/src/main.rs @@ -135,13 +135,14 @@ async fn main()->Result<()> { } #[cfg(windows)] "prepare-installation"=>{ - let file=PathBuf::from(args.get(2).context("Usage: swan-agent prepare-installation release.json INSTALLER [--repair]")?); + let file=PathBuf::from(args.get(2).context("Usage: swan-agent prepare-installation release.json INSTALLER [--repair | --replace-failed-setup]")?); let package=PathBuf::from(args.get(3).context("Missing original installer path")?); let prepare_directory=directory.clone();let repair=args.iter().any(|value|value=="--repair"); + let replace_failed_setup=args.iter().any(|value|value=="--replace-failed-setup"); tokio::task::spawn_blocking(move ||->Result<()> { let envelope:SignedEnvelope=serde_json::from_slice(&std::fs::read(file)?)?; let state=AgentState::load(&prepare_directory)?; - state.prepare_installation(&prepare_directory,&envelope,&package,repair) + state.prepare_installation_with_recovery(&prepare_directory,&envelope,&package,repair,replace_failed_setup) }).await.context("Explicit installation preparation failed")??; println!("Verified setup prepared; any cancelled update evidence was retained."); } diff --git a/product/agent/src/update.rs b/product/agent/src/update.rs index 0604ddbe853..f04eebadafe 100644 --- a/product/agent/src/update.rs +++ b/product/agent/src/update.rs @@ -120,6 +120,31 @@ fn prepare_installation_marker(directory:&Path,envelope:&SignedEnvelope)->Result } } +#[cfg(any(windows,test))] +fn replace_failed_installation_marker(state:&AgentState,directory:&Path,envelope:&SignedEnvelope)->Result<()> { + let path=directory.join("pending-install.json"); + let bytes=std::fs::read(&path).context("Explicit setup replacement requires a pending setup")?; + let stored:SignedEnvelope=serde_json::from_slice(&bytes)?; + if stored.payload==envelope.payload && stored.signature==envelope.signature {return Ok(());} + let key=public_key(&state.bootstrap.release_public_key)?; + let previous:Release=stored.verify(&key)?; + previous.validate(&state.bootstrap.edition,state.last_release_sequence,now().min(previous.expires_at.saturating_sub(1)))?; + let replacement:Release=envelope.verify(&key)?; + replacement.validate(&state.bootstrap.edition,previous.sequence,now())?; + let archive=directory.join("cancelled-setups");std::fs::create_dir_all(&archive)?; + let target=archive.join(format!("{}-{}.json",previous.sequence,digest(&bytes))); + if target.exists(){ensure!(std::fs::read(&target)?==bytes,"Conflicting cancelled setup evidence");} + else{publish_initial_bytes(&target,&bytes)?;} + // Retain the original receipt before atomically replacing the blocking marker. + // A crash before replacement leaves the old setup blocked; after replacement + // it leaves the new setup blocked until full installation verification. + let temporary=directory.join(format!("swan-setup-{}.tmp",random_token())); + publish_initial_bytes(&temporary,&serde_json::to_vec(envelope)?)?; + let result=crate::replace_state(&temporary,&path); + if result.is_err() && temporary.exists(){std::fs::remove_file(&temporary)?;} + result +} + #[cfg(any(windows,test))] fn validate_pending_receipt(state:&AgentState,receipt:&Receipt)->Result { ensure!(receipt.rollback_protocol<=1,"Unsupported pending rollback protocol"); @@ -616,10 +641,16 @@ fn launch_update_helper(directory:&Path,release:&Release)->Result<()> { impl AgentState { #[cfg(windows)] pub fn prepare_installation(&self,directory:&Path,envelope:&SignedEnvelope,package:&Path,repair:bool)->Result<()> { + self.prepare_installation_with_recovery(directory,envelope,package,repair,false) + } + #[cfg(windows)] + pub fn prepare_installation_with_recovery(&self,directory:&Path,envelope:&SignedEnvelope,package:&Path,repair:bool,replace_failed_setup:bool)->Result<()> { + ensure!(!repair || !replace_failed_setup,"Repair cannot replace another pending setup"); let activity=activity_file(directory)?;fs2::FileExt::try_lock_exclusive(&activity).context("Explicit setup requires all sessions and update helpers to close")?; let state=AgentState::load(directory)?; if repair {verify_repair_package(&state,directory,envelope,package)?;}else{verify_package(&state,directory,envelope,package)?;} - prepare_installation_marker(directory,envelope)?; + if replace_failed_setup {replace_failed_installation_marker(&state,directory,envelope)?;} + else{prepare_installation_marker(directory,envelope)?;} archive_cancelled_update(&state,directory)?; Ok(()) } @@ -1081,6 +1112,22 @@ pub(crate) fn test_failed_release_quarantine(state:&AgentState,release:&Release, let mut competing=setup.clone();competing.sequence=4; assert!(prepare_installation_marker(&cancellation_folder,&SignedEnvelope::sign(&competing,key).unwrap()).is_err()); assert_eq!(std::fs::read(cancellation_folder.join("pending-install.json")).unwrap(),marker_bytes); + let foreign_key=SigningKey::from_bytes(&[250;32]); + assert!(replace_failed_installation_marker(state,&cancellation_folder,&SignedEnvelope::sign(&competing,&foreign_key).unwrap()).is_err()); + let mut older=competing.clone();older.sequence=2; + assert!(replace_failed_installation_marker(state,&cancellation_folder,&SignedEnvelope::sign(&older,key).unwrap()).is_err()); + let mut wrong_edition=competing.clone();wrong_edition.edition=if state.bootstrap.edition==Edition::Customer {Edition::Technician}else{Edition::Customer}; + assert!(replace_failed_installation_marker(state,&cancellation_folder,&SignedEnvelope::sign(&wrong_edition,key).unwrap()).is_err()); + assert_eq!(std::fs::read(cancellation_folder.join("pending-install.json")).unwrap(),marker_bytes); + let newer=SignedEnvelope::sign(&competing,key).unwrap(); + replace_failed_installation_marker(state,&cancellation_folder,&newer).unwrap(); + let retained=cancellation_folder.join("cancelled-setups").join(format!("3-{}.json",digest(&marker_bytes))); + assert_eq!(std::fs::read(retained).unwrap(),marker_bytes); + let replaced:SignedEnvelope=serde_json::from_slice(&std::fs::read(cancellation_folder.join("pending-install.json")).unwrap()).unwrap(); + assert_eq!(replaced.payload,newer.payload); + assert!(lock_session(&cancellation_folder).is_err(),"Replacement setup must still block support"); + assert_eq!(std::fs::read(cancellation_folder.join("managed-state.json")).unwrap(),original_state); + std::fs::write(cancellation_folder.join("pending-install.json"),&marker_bytes).unwrap(); prepare_installation_marker(&cancellation_folder,&setup_envelope).unwrap(); archive_cancelled_update(state,&cancellation_folder).unwrap(); let archive=cancellation_folder.join("updates/2/cancelled-update.json"); From 533a9da4ebba026f0e8811fb5bbea20497f19702 Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Sat, 3 Oct 2026 01:36:54 +0200 Subject: [PATCH 129/652] Record installed customer branding and cached trust checks --- docs/ACCEPTANCE.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index bd4b3c5b7a2..40d040c1c33 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -93,6 +93,17 @@ enrollment recovery; it does not prove Internet transport, reboot, desktop sessions, UAC, automatic-update recovery, fresh signed installation on another guest, or compatibility on the other advertised Windows versions. +The installed signed customer agent also fetched branding revision two over +HTTPS without reinstallation. Device identity/credential, consent and software +sequence stayed unchanged. With the owned company service stopped, the agent +validated its cached profile and installed release; the service was restored +afterward. Separate protected copies of state rejected a tampered profile +signature, an older genuinely signed profile, and a mismatched company identity. +The actual installed state remained unchanged by those negative tests. This +proves agent cache behavior while management is unavailable, not visual branding +in Flutter, operation on a completely disconnected network, or offline permission +to establish a support session. + ### Earlier component evidence - Public baseline tag `swan-single-company-baseline-1.4.9` preserves the original From f5192306d347c6f64863ec0cf0e4f879f59f06af Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Sat, 3 Oct 2026 02:03:15 +0200 Subject: [PATCH 130/652] Keep Windows package inspection read-only for standard users --- docs/ACCEPTANCE.md | 38 +++++++++++++++++++++++++++-- product/agent/src/update.rs | 48 +++++++++++++++++++------------------ 2 files changed, 61 insertions(+), 25 deletions(-) diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index 40d040c1c33..c64a120499a 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -42,11 +42,11 @@ Internet reachability, other Windows versions, or production signing approval. The subsequent standard-user bundle installation passed HTTPS company setup and profile pinning, then failed package verification with Windows access denied: the agent attempted to write its verifier beside the package in the -administrator-extracted, read-only bundle directory. The agent now runs its +administrator-extracted bundle's protected verifier file. The agent now runs its embedded verifier as an encoded PowerShell command with quoted literal arguments, without creating or trusting a helper in that directory. A rebuilt GNU development agent passed package verification under the same standard -account against the same signed package and read-only directory. Its agent +account against the same signed package and protected verifier file. Its agent suite passed 21 tests with two explicit integration fixtures ignored. This is verification regression evidence only: the replacement development agent is unsigned, was not published in a bundle, and has not completed installation. @@ -104,6 +104,40 @@ proves agent cache behavior while management is unavailable, not visual branding in Flutter, operation on a completely disconnected network, or offline permission to establish a support session. +### Standard-user technician installation and read-only verification + +The actual worker-generated technician EXE bundle from `6840d91` completed +installation from the standard account's Downloads directory. Package, +installed application and signed-agent verification passed; sequence one was +recorded and the pending marker cleared. The Start menu shortcut targets the +installed launcher. That launcher refreshed branding to revision two and +started the native app in the standard desktop session. Its rendered Flutter +screen shows the updated company name/logo, company HTTPS domain, username, +password and authenticator fields, plus product/upstream/AGPL attribution. +This proves installation and login-screen rendering, not successful GUI login, +inventory interaction or a remote session. + +Further inspection showed that the earlier verifier regression covered an +administrator-owned helper file, rather than a directory denying every write. +Compatibility, MSI identity and MSI mode helpers still wrote beside inputs. +They now execute embedded scripts with encoded commands and literal arguments. +A new guest fixture explicitly grants the standard account read/execute only; +an attempted new file was denied. A rebuilt unsigned development agent verified +both the signed technician EXE and signed customer MSI from that fixture, without +changing any directory inputs. The customer verification fixture contains only +public bootstrap/profile information and no device credential. The agent suite +passed 21 tests with two integration fixtures ignored, including rejection of +a publisher string attempting to execute a command; no injection file appeared. +These changes still require a new native build and signed bundle verification. + +The [native build at `82c8c65`](https://github.com/rijff24/SwanRemoteSupport/actions/runs/37076900806) +passed. Its 18-entry unsigned artifact was downloaded, checked against GitHub's +SHA-256 `9f9728adfda93a9cf15f8ade9338ae5986f3ebfc83d6c49c305c9f6ce6db697f`, +and extracted with path, size, CRC and published payload checksum verification. +It predates setup-replacement recovery and the remaining read-only helper fixes. +No production signing, new package installation or compatibility claim follows +from this build alone. + ### Earlier component evidence - Public baseline tag `swan-single-company-baseline-1.4.9` preserves the original diff --git a/product/agent/src/update.rs b/product/agent/src/update.rs index f04eebadafe..0affeb6b068 100644 --- a/product/agent/src/update.rs +++ b/product/agent/src/update.rs @@ -277,21 +277,25 @@ pub fn powershell_command()->Result { fn verify_publisher(path:&Path,release:&Release)->Result<()> { // Packages may reside in a read-only deployment share. Execute the embedded // verifier without writing beside the package or trusting a mutable helper. - let encoded=publisher_verification_command(path,release)?; - let status=powershell_command()?.args(["-NoLogo","-NoProfile","-NonInteractive","-ExecutionPolicy","Bypass","-EncodedCommand"]) - .arg(encoded).status()?; + let status=embedded_powershell_command(include_str!("../../../deployment/windows/Verify-Package.ps1"),&[ + ("Path",path.to_str().context("Package path is not valid Unicode")?), + ("Publisher",&release.publisher),("CertificateSha256",&release.publisher_certificate_sha256),("Sha256",&release.sha256), + ])?.status()?; ensure!(status.success(),"Package publisher verification failed");Ok(()) } -#[cfg(any(windows,test))] -fn publisher_verification_command(path:&Path,release:&Release)->Result { +#[cfg(windows)] +fn embedded_powershell_command(script:&str,arguments:&[(&str,&str)])->Result { let literal=|value:&str|format!("'{}'",value.replace('\'',"''")); - let command=format!("& {{\n{}\n}} -Path {} -Publisher {} -CertificateSha256 {} -Sha256 {}", - include_str!("../../../deployment/windows/Verify-Package.ps1"), - literal(path.to_str().context("Package path is not valid Unicode")?), - literal(&release.publisher),literal(&release.publisher_certificate_sha256),literal(&release.sha256)); - let bytes:Vec=command.encode_utf16().flat_map(u16::to_le_bytes).collect(); - Ok(STANDARD.encode(bytes)) + let mut script=format!("$ProgressPreference='SilentlyContinue'; & {{\n{script}\n}}"); + for (name,value) in arguments { + ensure!(name.starts_with(|character:char|character.is_ascii_alphabetic()) && name.bytes().all(|byte|byte.is_ascii_alphanumeric()),"Invalid embedded helper parameter"); + script.push_str(&format!(" -{name} {}",literal(value))); + } + let bytes:Vec=script.encode_utf16().flat_map(u16::to_le_bytes).collect(); + let mut command=powershell_command()?; + command.args(["-NoLogo","-NoProfile","-NonInteractive","-ExecutionPolicy","Bypass","-EncodedCommand"]).arg(STANDARD.encode(bytes)); + Ok(command) } #[cfg(windows)] @@ -351,9 +355,8 @@ fn validate_msi_identity(identity:&MsiIdentity,edition:&Edition,version:&str)->R #[cfg(windows)] fn read_msi_identity(package:&Path)->Result { - let script=package.parent().context("Missing MSI directory")?.join("Get-MsiIdentity.ps1"); - std::fs::write(&script,include_str!("../../../deployment/windows/Get-MsiIdentity.ps1"))?; - let output=powershell_command()?.args(["-NoLogo","-NoProfile","-NonInteractive","-ExecutionPolicy","Bypass","-File"]).arg(script).arg("-Package").arg(package).output()?; + let output=embedded_powershell_command(include_str!("../../../deployment/windows/Get-MsiIdentity.ps1"), + &[("Package",package.to_str().context("MSI path is not valid Unicode")?)])?.output()?; ensure!(output.status.success(),"Cannot inspect MSI identity without installation"); let identity:MsiIdentity=serde_json::from_slice(&output.stdout)?; Ok(identity) @@ -375,11 +378,8 @@ pub fn verify_msi_release_identity(package:&Path,release:&Release)->Result<()> { #[cfg(windows)] fn msi_install_command(package:&Path,release:&Release)->Result { verify_msi_identity(package,release)?; - let script=package.parent().context("Missing update directory")?.join("Get-MsiInstallMode.ps1"); - std::fs::write(&script,include_str!("../../../deployment/windows/Get-MsiInstallMode.ps1"))?; - let output=powershell_command()? - .args(["-NoLogo","-NoProfile","-NonInteractive","-ExecutionPolicy","Bypass","-File"]) - .arg(script).arg("-Package").arg(package).output()?; + let output=embedded_powershell_command(include_str!("../../../deployment/windows/Get-MsiInstallMode.ps1"), + &[("Package",package.to_str().context("MSI path is not valid Unicode")?)])?.output()?; ensure!(output.status.success(),"Unable to determine MSI installation or repair mode"); let mode=std::str::from_utf8(&output.stdout)?.trim(); ensure!(matches!(mode,"/i"|"/fvamus"),"Unexpected MSI installation mode"); @@ -409,10 +409,8 @@ pub fn verify_repair_package(state:&AgentState,directory:&Path,envelope:&SignedE } #[cfg(windows)] -fn verify_compatibility(directory:&Path,release:&Release)->Result<()> { - let script=directory.join("Get-WindowsCompatibility.ps1"); - std::fs::write(&script,include_str!("../../../deployment/windows/Get-WindowsCompatibility.ps1"))?; - let output=powershell_command()?.args(["-NoLogo","-NoProfile","-NonInteractive","-ExecutionPolicy","Bypass","-File"]).arg(script).output()?; +fn verify_compatibility(_directory:&Path,release:&Release)->Result<()> { + let output=embedded_powershell_command(include_str!("../../../deployment/windows/Get-WindowsCompatibility.ps1"),&[])?.output()?; ensure!(output.status.success(),"Unsupported Windows version or installation type"); let platform=String::from_utf8(output.stdout)?; ensure!(release.windows_versions.iter().any(|v|v==platform.trim()),"Release does not support this Windows version");Ok(()) @@ -1029,6 +1027,10 @@ pub(crate) fn test_failed_release_quarantine(state:&AgentState,release:&Release, assert_eq!(std::fs::read(preparation.join("managed-state.json")).unwrap(),initial_state); let mut wrong_publisher=installer.clone();wrong_publisher.publisher="Unapproved publisher".into(); assert!(retain_installer(&source,&target,&wrong_publisher).is_err()); + let injected=folder.join("publisher-injection.txt"); + wrong_publisher.publisher=format!("Unapproved'; Set-Content -LiteralPath '{}' -Value injected; #",injected.display()); + assert!(retain_installer(&source,&target,&wrong_publisher).is_err()); + assert!(!injected.exists(),"Publisher values must remain literal arguments, never commands"); assert!(staged_hash_matches(&target,&installer.sha256).unwrap(),"Publisher rejection retains the previous complete cache"); let mut wrong_certificate=installer.clone();wrong_certificate.publisher_certificate_sha256="0".repeat(64); assert!(retain_installer(&source,&target,&wrong_certificate).is_err()); From 1702af8559f7ff7d6cb971e7177a21cf5caf4219 Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Sat, 3 Oct 2026 02:37:17 +0200 Subject: [PATCH 131/652] Verify installed MSI registration before committing updates --- deployment/windows/Restart-Configuration.ps1 | 6 ++++- deployment/windows/Verify-MsiRegistration.ps1 | 7 ++++++ docs/ACCEPTANCE.md | 23 +++++++++++++++++++ product/agent/src/update.rs | 14 ++++++++++- 4 files changed, 48 insertions(+), 2 deletions(-) create mode 100644 deployment/windows/Verify-MsiRegistration.ps1 diff --git a/deployment/windows/Restart-Configuration.ps1 b/deployment/windows/Restart-Configuration.ps1 index 3beb43259c5..0cfec839729 100644 --- a/deployment/windows/Restart-Configuration.ps1 +++ b/deployment/windows/Restart-Configuration.ps1 @@ -2,16 +2,20 @@ param([Parameter(Mandatory=$true)][string]$Directory) $ErrorActionPreference = 'Stop' $expectedAgent = [IO.Path]::GetFullPath((Join-Path $Directory 'swan-agent.exe')) +if ((Get-AuthenticodeSignature -LiteralPath $expectedAgent).Status -ne 'Valid') { throw 'Installed agent cannot be restarted until its signature is valid.' } $deadline = [DateTime]::UtcNow.AddSeconds(30) +$stopRequested = $false do { $tasks = @(Get-ScheduledTask | Where-Object { $_.TaskName -eq 'Swan Company Configuration' }) if ($tasks.Count -ne 1) { throw 'Expected one company configuration task.' } $task = $tasks[0] if ($task.Actions.Count -ne 1 -or [IO.Path]::GetFullPath($task.Actions[0].Execute) -ine $expectedAgent -or $task.Actions[0].Arguments -cne 'watch' -or $task.Principal.UserId -notin @('SYSTEM','S-1-5-18','NT AUTHORITY\SYSTEM')) { throw 'Configuration task identity does not match this installation.' } if ($task.State -ne 'Running') { break } + # An explicit update can be launched while the ordinary watcher is sleeping. + # Stop only the task whose exact action and SYSTEM identity were validated. + if (-not $stopRequested) { $task | Stop-ScheduledTask; $stopRequested = $true } if ([DateTime]::UtcNow -ge $deadline) { throw 'Previous configuration task has not exited.' } Start-Sleep -Milliseconds 100 } while ($true) -if ((Get-AuthenticodeSignature -LiteralPath $expectedAgent).Status -ne 'Valid') { throw 'Installed agent cannot be restarted until its signature is valid.' } $task | Enable-ScheduledTask | Out-Null $task | Start-ScheduledTask diff --git a/deployment/windows/Verify-MsiRegistration.ps1 b/deployment/windows/Verify-MsiRegistration.ps1 new file mode 100644 index 00000000000..dd69b94088d --- /dev/null +++ b/deployment/windows/Verify-MsiRegistration.ps1 @@ -0,0 +1,7 @@ +[CmdletBinding()] +param([Parameter(Mandatory=$true)][string]$ProductCode, [Parameter(Mandatory=$true)][string]$Version) +$ErrorActionPreference = 'Stop' +$installer = New-Object -ComObject WindowsInstaller.Installer +if ($installer.ProductState($ProductCode) -ne 5 -or $installer.ProductInfo($ProductCode, 'VersionString') -cne $Version) { + throw 'The expected MSI product and version are not installed.' +} diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index c64a120499a..162399906dd 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -6,6 +6,29 @@ native support session or a clean-machine installation. ## Evidence obtained +### Automatic MSI update failure and focused corrections, 3 October + +The signed Server 2016 customer fixture approved release sequence 3 (MSI +1.5.2) after verifying pause, zero-percent rollout and closed maintenance +window rejection. Its agent committed sequence 3 and removed the pending +receipt, but Windows Installer still registered MSI 1.5.1. Both packages +contained the same signed native binaries: checking executable hashes alone +incorrectly skipped installation. The helper also failed to restart the +already-running configuration watcher. **This automatic upgrade failed +acceptance; a committed sequence is not proof of an installed MSI upgrade.** + +The correction checks the signed package's product code and version against +Windows Installer before skipping installation, after installation and during +explicit completion recovery. A read-only test on the actual guest accepted +its registered 1.5.1 and rejected 1.5.2. The restart script now stops only the +validated installation-owned SYSTEM task before its bounded wait. Executing +that script in the disposable guest replaced exactly one watcher and preserved +device identity, credentials and consent. Agent tests passed (21 passed, +two explicitly gated integration fixtures ignored). These focused checks do +not prove a corrected signed automatic upgrade or durable post-commit recovery; +both still require end-to-end verification. The failed fixture's registered +MSI remains 1.5.1, with agent state claiming sequence 3, pending reconciliation. + ### Windows full-stack company server and technician worker, 3 October The disposable Windows Server 2016 Standard Evaluation guest (Desktop diff --git a/product/agent/src/update.rs b/product/agent/src/update.rs index 0affeb6b068..8d2014b1e3c 100644 --- a/product/agent/src/update.rs +++ b/product/agent/src/update.rs @@ -375,6 +375,16 @@ pub fn verify_msi_release_identity(package:&Path,release:&Release)->Result<()> { verify_msi_identity(package,release)?;Ok(()) } +#[cfg(windows)] +fn verify_msi_registration(package:&Path,release:&Release)->Result<()> { + if release.format!="msi" {return Ok(());} + let identity=verify_msi_identity(package,release)?; + let status=embedded_powershell_command(include_str!("../../../deployment/windows/Verify-MsiRegistration.ps1"), + &[("ProductCode",&identity.product_code),("Version",&release.version)])?.status()?; + ensure!(status.success(),"Expected MSI product and version are not installed; recovery remains pending"); + Ok(()) +} + #[cfg(windows)] fn msi_install_command(package:&Path,release:&Release)->Result { verify_msi_identity(package,release)?; @@ -737,6 +747,7 @@ impl AgentState { verify_installed(directory,&release)?; let cache=installer_cache(directory,&release); let package=if cache.is_file(){cache.clone()}else{directory.join("updates").join(release.sequence.to_string()).join(format!("SwanRemoteSupport-install.{}",release.format))}; + verify_msi_registration(&package,&release)?; retain_installer(&package,&cache,&release)?; save_installed_metadata(directory,&receipt.release)?; // A crashed updater may have installed successfully or saved state before @@ -835,7 +846,7 @@ impl AgentState { ensure!(digest(std::fs::read(&package)?).eq_ignore_ascii_case(&release.sha256),"Staged installer hash mismatch"); verify_publisher(&package,&release)?; let installation=(||->Result<()> { - if let Err(error)=verify_installed(directory,&release) { + if let Err(error)=verify_installed(directory,&release).and_then(|()|verify_msi_registration(&package,&release)) { eprintln!("Installed payload requires recovery: {error:#}"); if let Some(previous)=receipt.previous_release.as_ref(){ verify_rollback_snapshot(&latest,&folder.join("rollback"),previous,receipt.previous_sequence)?; @@ -865,6 +876,7 @@ impl AgentState { } } verify_installed(directory,&release)?; + verify_msi_registration(&package,&release)?; Ok(()) })(); if let Err(error)=installation { From fdf50780f163d0f884ab553b84fcfa567d4c4f74 Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Sat, 3 Oct 2026 02:40:10 +0200 Subject: [PATCH 132/652] Retain durable recovery until customer configuration restarts --- docs/ACCEPTANCE.md | 9 +++++++++ product/agent/src/lib.rs | 3 ++- product/agent/src/main.rs | 7 ++----- product/agent/src/update.rs | 31 +++++++++++++++++++++++++++++++ 4 files changed, 44 insertions(+), 6 deletions(-) diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index 162399906dd..5c780378204 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -29,6 +29,15 @@ not prove a corrected signed automatic upgrade or durable post-commit recovery; both still require end-to-end verification. The failed fixture's registered MSI remains 1.5.1, with agent state claiming sequence 3, pending reconciliation. +The subsequent recovery change writes a signed `configuration-restart.json` +marker before committing a customer update. Successful restart removes it; +the staged recovery task retries it after validating the committed release, +installed payload and MSI registration. Support sessions remain blocked while +the marker exists, including after the updater's process lock disappears. +The agent suite passed again, and the extended session-exclusion regression +passed. A newly built, signed native agent and an interrupted-update VM test +are still required to prove this recovery behavior end to end. + ### Windows full-stack company server and technician worker, 3 October The disposable Windows Server 2016 Standard Evaluation guest (Desktop diff --git a/product/agent/src/lib.rs b/product/agent/src/lib.rs index 236e1a39a4d..9f233778a86 100644 --- a/product/agent/src/lib.rs +++ b/product/agent/src/lib.rs @@ -240,7 +240,8 @@ pub fn lock_session(directory:&Path)->Result { let file=activity_file(directory)?;fs2::FileExt::try_lock_shared(&file).context("An update or installation is in progress")?; ensure!(!directory.join("pending-uninstall").exists(),"Customer uninstall prevents new support sessions"); ensure!(!directory.join("pending-install.json").exists(),"Company installation requires recovery before support sessions can start"); - ensure!(!directory.join("pending-update.json").exists(),"Software update requires recovery before support sessions can start");Ok(file) + ensure!(!directory.join("pending-update.json").exists(),"Software update requires recovery before support sessions can start"); + ensure!(!directory.join("configuration-restart.json").exists(),"Configuration restart requires recovery before support sessions can start");Ok(file) } pub fn state_directory()->PathBuf { if let Some(directory)=std::env::var_os("SWAN_STATE_DIR"){return directory.into();} diff --git a/product/agent/src/main.rs b/product/agent/src/main.rs index 7a76c6a0c25..5be36ef1d98 100644 --- a/product/agent/src/main.rs +++ b/product/agent/src/main.rs @@ -111,12 +111,9 @@ async fn main()->Result<()> { state.apply_pending_update(&update_directory) }).await.context("Update helper task failed").and_then(|result|result); if customer && (matches!(&outcome,Ok(true)) || outcome.is_err()) { - let script=directory.join("Restart-Configuration.ps1"); - std::fs::write(&script,include_str!("../../../deployment/windows/Restart-Configuration.ps1"))?; - let status=swan_agent::update::powershell_command()?.args(["-NoLogo","-NoProfile","-NonInteractive","-ExecutionPolicy","Bypass","-File"]).arg(script).arg("-Directory").arg(&directory).status()?; - if !status.success(){ + if let Err(restart_error)=swan_agent::update::finish_configuration_restart(&directory){ if let Err(error)=&outcome {eprintln!("Update remains pending: {error:#}");} - bail!("Configuration task restart failed; inspect the protected update log and resume recovery"); + return Err(restart_error).context("Configuration task restart failed; durable recovery will retry"); } } let installed=outcome?; diff --git a/product/agent/src/update.rs b/product/agent/src/update.rs index 8d2014b1e3c..3c344b0d64d 100644 --- a/product/agent/src/update.rs +++ b/product/agent/src/update.rs @@ -447,6 +447,29 @@ fn save_installed_metadata(directory:&Path,envelope:&SignedEnvelope)->Result<()> crate::replace_state(&temporary,&directory.join("installed-release.json")) } +#[cfg(windows)] +pub fn finish_configuration_restart(directory:&Path)->Result<()> { + let activity=activity_file(directory)?; + fs2::FileExt::try_lock_exclusive(&activity).context("Configuration restart requires update and session exclusion")?; + let marker=directory.join("configuration-restart.json"); + if marker.exists() { + ensure!(!directory.join("pending-uninstall").exists(),"Explicit uninstall cancels configuration restart"); + let state=AgentState::load_for_refresh(directory)?; + let envelope:SignedEnvelope=serde_json::from_slice(&std::fs::read(&marker)?)?; + let release=validate_previous_release(&state,&envelope,state.last_release_sequence)?; + ensure!(release.edition==Edition::Customer,"Configuration restart requires a customer release"); + let installed:SignedEnvelope=serde_json::from_slice(&std::fs::read(directory.join("installed-release.json"))?)?; + ensure!(installed.payload==envelope.payload && installed.signature==envelope.signature,"Restart marker differs from installed release"); + verify_installed(directory,&release)?; + verify_msi_registration(&installer_cache(directory,&release),&release)?; + } + let status=embedded_powershell_command(include_str!("../../../deployment/windows/Restart-Configuration.ps1"), + &[("Directory",directory.to_str().context("Configuration path is not valid Unicode")?)])?.status()?; + ensure!(status.success(),"Configuration task restart remains pending"); + if marker.exists(){std::fs::remove_file(marker)?;} + Ok(()) +} + pub(crate) fn validate_previous_release(state:&AgentState,envelope:&SignedEnvelope,previous_sequence:u64)->Result { ensure!(previous_sequence>0,"Rollback requires a recorded signed installation"); let release:Release=envelope.verify(&public_key(&state.bootstrap.release_public_key)?)?; @@ -673,6 +696,9 @@ impl AgentState { ensure!(sequence>0 && helper==check_directory.join("updates").join(sequence.to_string()).join("swan-agent.exe"),"Recovery task must run from its exact staged helper"); let receipt_path=check_directory.join("pending-update.json"); if !receipt_path.exists() || check_directory.join("pending-uninstall").exists(){ + if state.last_release_sequence==sequence && check_directory.join("configuration-restart.json").exists() && !check_directory.join("pending-uninstall").exists() { + finish_configuration_restart(&check_directory)?; + } let installed:SignedEnvelope=serde_json::from_slice(&std::fs::read(check_directory.join("installed-release.json"))?)?; let mut release:Release=installed.verify(&public_key(&state.bootstrap.release_public_key)?)?; ensure!(release.edition==state.bootstrap.edition,"Installed release belongs to another edition"); @@ -752,6 +778,7 @@ impl AgentState { save_installed_metadata(directory,&receipt.release)?; // A crashed updater may have installed successfully or saved state before // removing the receipt. Exact signed executable identity proves either case. + if release.edition==Edition::Customer {publish_download(&directory.join("configuration-restart.json"),&serde_json::to_vec(&receipt.release)?)?;} self.last_release_sequence=release.sequence;self.save(directory)?; std::fs::remove_file(receipt_path)?;Ok(()) } @@ -890,6 +917,7 @@ impl AgentState { } save_installed_metadata(directory,&receipt.release)?; retain_installer(&package,&installer_cache(directory,&release),&release)?; + if release.edition==Edition::Customer {publish_download(&directory.join("configuration-restart.json"),&serde_json::to_vec(&receipt.release)?)?;} latest.last_release_sequence=release.sequence;latest.save(directory)?; std::fs::remove_file(receipt_path)?;*self=latest;Ok(true) } @@ -1277,6 +1305,9 @@ mod tests { drop(updater); assert!(lock_session(&folder).is_err()); std::fs::remove_file(folder.join("pending-update.json")).unwrap(); + std::fs::write(folder.join("configuration-restart.json"),b"committed update awaiting restart").unwrap(); + assert!(lock_session(&folder).is_err()); + std::fs::remove_file(folder.join("configuration-restart.json")).unwrap(); assert!(lock_session(&folder).is_ok());std::fs::remove_dir_all(folder).unwrap(); } #[cfg(windows)] From 4768d530ebc79f84e84260bed42db608dd439c9c Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Sat, 3 Oct 2026 02:43:04 +0200 Subject: [PATCH 133/652] Quiesce customer watcher before applying signed updates --- deployment/windows/Restart-Configuration.ps1 | 5 +++-- docs/ACCEPTANCE.md | 10 ++++++++++ product/agent/src/update.rs | 14 ++++++++++++++ 3 files changed, 27 insertions(+), 2 deletions(-) diff --git a/deployment/windows/Restart-Configuration.ps1 b/deployment/windows/Restart-Configuration.ps1 index 0cfec839729..885fdfbd5c2 100644 --- a/deployment/windows/Restart-Configuration.ps1 +++ b/deployment/windows/Restart-Configuration.ps1 @@ -1,8 +1,8 @@ [CmdletBinding()] -param([Parameter(Mandatory=$true)][string]$Directory) +param([Parameter(Mandatory=$true)][string]$Directory, [switch]$StopOnly) $ErrorActionPreference = 'Stop' $expectedAgent = [IO.Path]::GetFullPath((Join-Path $Directory 'swan-agent.exe')) -if ((Get-AuthenticodeSignature -LiteralPath $expectedAgent).Status -ne 'Valid') { throw 'Installed agent cannot be restarted until its signature is valid.' } +if (-not $StopOnly -and (Get-AuthenticodeSignature -LiteralPath $expectedAgent).Status -ne 'Valid') { throw 'Installed agent cannot be restarted until its signature is valid.' } $deadline = [DateTime]::UtcNow.AddSeconds(30) $stopRequested = $false do { @@ -17,5 +17,6 @@ do { if ([DateTime]::UtcNow -ge $deadline) { throw 'Previous configuration task has not exited.' } Start-Sleep -Milliseconds 100 } while ($true) +if ($StopOnly) { return } $task | Enable-ScheduledTask | Out-Null $task | Start-ScheduledTask diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index 5c780378204..0589c84e1ef 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -38,6 +38,16 @@ The agent suite passed again, and the extended session-exclusion regression passed. A newly built, signed native agent and an interrupted-update VM test are still required to prove this recovery behavior end to end. +The updater now quiesces the validated configuration task before replacing +customer files, including updates launched independently of the watcher. +The disposable Server 2016 test confirmed that stop-only left no installed +watcher process, then restart created exactly one watcher and preserved +identity and consent. Explicit setup completion also checks MSI registration +and retires a previous restart marker only after verifying its installation; +another automatic update is rejected while restart recovery remains pending. +The Windows GNU agent build passed. These changes still need verification in +the signed native update flow. + ### Windows full-stack company server and technician worker, 3 October The disposable Windows Server 2016 Standard Evaluation guest (Desktop diff --git a/product/agent/src/update.rs b/product/agent/src/update.rs index 3c344b0d64d..6451b9cdfbd 100644 --- a/product/agent/src/update.rs +++ b/product/agent/src/update.rs @@ -752,9 +752,14 @@ impl AgentState { } verify_compatibility(directory,&release)?; verify_installed(directory,&release)?; + verify_msi_registration(package,&release)?; retain_installer(package,&installer_cache(directory,&release),&release)?; save_installed_metadata(directory,envelope)?; latest.last_release_sequence=release.sequence;latest.save(directory)?; + // Explicit verified setup takes responsibility for registering and + // starting its watcher after this command returns. + let restart_marker=directory.join("configuration-restart.json"); + if restart_marker.exists(){std::fs::remove_file(restart_marker)?;} if setup_marker.exists(){std::fs::remove_file(setup_marker)?;} let uninstall_marker=directory.join("pending-uninstall"); if uninstall_marker.exists(){std::fs::remove_file(uninstall_marker)?;} @@ -872,6 +877,13 @@ impl AgentState { let package=folder.join(format!("SwanRemoteSupport-install.{}",release.format)); ensure!(digest(std::fs::read(&package)?).eq_ignore_ascii_case(&release.sha256),"Staged installer hash mismatch"); verify_publisher(&package,&release)?; + if release.edition==Edition::Customer { + let script=folder.join("Stop-Configuration.ps1"); + std::fs::write(&script,include_str!("../../../deployment/windows/Restart-Configuration.ps1"))?; + let status=powershell_command()?.args(["-NoLogo","-NoProfile","-NonInteractive","-ExecutionPolicy","Bypass","-File"]) + .arg(script).arg("-Directory").arg(directory).arg("-StopOnly").status()?; + ensure!(status.success(),"Configuration watcher could not stop; signed update recovery remains pending"); + } let installation=(||->Result<()> { if let Err(error)=verify_installed(directory,&release).and_then(|()|verify_msi_registration(&package,&release)) { eprintln!("Installed payload requires recovery: {error:#}"); @@ -926,6 +938,7 @@ impl AgentState { #[cfg(windows)] { ensure!(!directory.join("pending-uninstall").exists(),"Explicit uninstall cancels automatic updates"); ensure!(!directory.join("pending-install.json").exists(),"Company setup recovery is required before automatic updates"); + ensure!(!directory.join("configuration-restart.json").exists(),"Committed update requires configuration restart recovery before another update"); if directory.join("pending-update.json").exists(){ return self.resume_pending_update_online(directory,false).await; } @@ -961,6 +974,7 @@ impl AgentState { ensure!(!directory.join("pending-install.json").exists(),"Company setup started while the update was downloading"); ensure!(!directory.join("pending-uninstall").exists(),"Explicit uninstall started while the update was downloading"); ensure!(!receipt_path.exists(),"Another update already requires recovery"); + ensure!(!directory.join("configuration-restart.json").exists(),"Another update awaits configuration restart"); // Re-read consent and enrollment immediately before installation. let latest=AgentState::load(&directory)?; if !update_policy_open(&latest)?{return Ok(None);} From b6d7f78a0e516404083cfafc6d9e8d312ccb9ab9 Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Sat, 3 Oct 2026 02:46:40 +0200 Subject: [PATCH 134/652] Preserve company HTTPS recipe in encrypted backups --- docs/ACCEPTANCE.md | 12 ++++++++++++ docs/SERVER_BACKUP.md | 5 +++++ product/management/src/backup.rs | 15 ++++++++++++++- 3 files changed, 31 insertions(+), 1 deletion(-) diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index 0589c84e1ef..2e259c28817 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -6,6 +6,18 @@ native support session or a clean-machine installation. ## Evidence obtained +### HTTPS configuration backup correction, 3 October + +Review of the installed Windows server found that its company-data `Caddyfile` +was omitted from encrypted backups, even when certificate storage was included. +Export now includes that optional configuration, rejects links/reparse points, +and restore accepts its fixed filename in the protected replacement directory. +The complete backup test verifies its exact contents alongside transport keys, +environment and nested TLS storage. All three backup tests passed, including +wrong-password, tampering, nonempty-target and unsafe archive rejection. +Existing archives without the configuration remain supported. A full Windows +deployment restore using this correction is still unverified. + ### Automatic MSI update failure and focused corrections, 3 October The signed Server 2016 customer fixture approved release sequence 3 (MSI diff --git a/docs/SERVER_BACKUP.md b/docs/SERVER_BACKUP.md index dd8c14ac0bd..dc533bbffc6 100644 --- a/docs/SERVER_BACKUP.md +++ b/docs/SERVER_BACKUP.md @@ -27,6 +27,10 @@ Alternatively, replace the final option with `--tls-directory HTTPS_STORAGE_DIRE Both options can be included when the deployment uses separate storage and identity files. Directory exports reject symbolic links, Windows reparse points, special files, unsafe paths, and more than 4096 total files. The 512 MiB total plaintext limit applies. +When the company data directory contains `Caddyfile`, export also encrypts that +HTTPS configuration and restore places it back in the replacement data directory. +Keep configuration edits stopped during export. Older archives without this +file remain supported; reconstruct and validate their HTTPS recipe before use. The transport directory must contain both `id_ed25519` and `id_ed25519.pub`. When present, `db_v2.sqlite3` is also snapshotted using SQLite's backup API. @@ -68,6 +72,7 @@ protected directory; restore never overwrites external service files: | `deployment.env` | Private deployment environment configuration | | `tls-identity` | Company HTTPS identity location, or replace through certificate reissuance | | `tls-storage/` | Complete HTTPS service data directory; copy while that service is stopped | +| `Caddyfile` | HTTPS recipe in the replacement company data directory; validate paths and hostnames before starting | Keep transport services stopped while placing their restored data. Preserve private ownership and permissions appropriate to the service accounts. Use the diff --git a/product/management/src/backup.rs b/product/management/src/backup.rs index 702019365f1..1ec6c35d244 100644 --- a/product/management/src/backup.rs +++ b/product/management/src/backup.rs @@ -64,6 +64,17 @@ pub fn export_deployment(data:&Path,destination:&Path,password:&str,transport:Op if let Some(path)=configuration {files.push(("deployment.env".into(),path.to_path_buf()));} if let Some(path)=tls_identity {files.push(("tls-identity".into(),path.to_path_buf()));} if let Some(directory)=tls_directory {collect_tls(directory,directory,&mut files)?;} + let proxy=data.join("Caddyfile"); + match std::fs::symlink_metadata(&proxy) { + Ok(metadata)=>{ + ensure!(metadata.is_file() && !metadata.file_type().is_symlink(),"HTTPS configuration must be a regular file"); + #[cfg(windows)] {use std::os::windows::fs::MetadataExt;ensure!(metadata.file_attributes()&0x400==0,"HTTPS configuration must not be a reparse point");} + files.push(("Caddyfile".into(),proxy)); + }, + Err(error) if error.kind()==std::io::ErrorKind::NotFound=>{}, + Err(error)=>return Err(error).context("Cannot inspect HTTPS configuration for backup"), + } + ensure!(files.len()<=4096,"Too many backup files"); let mut total=0u64; for (name,path) in files { let size=std::fs::metadata(&path)?.len();total=total.checked_add(size).context("Backup size overflow")?; @@ -91,7 +102,7 @@ pub fn restore(data:&Path,source:&Path,password:&str)->Result<()> { let plaintext=cipher.decrypt(Nonce::from_slice(nonce),&bytes[offset+28..]).map_err(|_|anyhow::anyhow!("Backup password incorrect or backup modified"))?; let mut archive=zip::ZipArchive::new(Cursor::new(plaintext))?; ensure!((3..=4096).contains(&archive.len()),"Unexpected backup contents"); - let allowed=["management.sqlite3","profile-key.hex","setup-token.txt","transport-id_ed25519","transport-id_ed25519.pub","transport-db.sqlite3","deployment.env","tls-identity"]; + let allowed=["management.sqlite3","profile-key.hex","setup-token.txt","transport-id_ed25519","transport-id_ed25519.pub","transport-db.sqlite3","deployment.env","tls-identity","Caddyfile"]; let mut names=std::collections::HashSet::new(); for index in 0..archive.len(){let entry=archive.by_index(index)?;ensure!((allowed.contains(&entry.name()) || tls_entry(entry.name())) && !entry.is_dir() && names.insert(entry.name().to_string()),"Unknown or duplicate backup entry");} let mut portable_names=std::collections::HashSet::new(); @@ -143,12 +154,14 @@ mod tests { let tls_storage=root.join("caddy");let certificate=tls_storage.join("certificates/acme.example/company.example/company.key"); std::fs::create_dir_all(certificate.parent().unwrap()).unwrap();std::fs::write(&certificate,b"ACME private key fixture").unwrap(); std::fs::write(tls_storage.join("account.json"),b"ACME account fixture").unwrap(); + std::fs::write(data.join("Caddyfile"),b"company.example { reverse_proxy 127.0.0.1:24440 }").unwrap(); let archive=root.join("complete.swan-backup");export_deployment(&data,&archive,"complete backup passphrase",Some(&transport),Some(&configuration),Some(&tls),Some(&tls_storage)).unwrap(); let destination=root.join("restored");restore(&destination,&archive,"complete backup passphrase").unwrap(); assert_eq!(std::fs::read(destination.join("transport-id_ed25519")).unwrap(),b"private transport key");assert_eq!(std::fs::read(destination.join("transport-id_ed25519.pub")).unwrap(),b"public transport key"); assert_eq!(std::fs::read(destination.join("deployment.env")).unwrap(),std::fs::read(configuration).unwrap());assert_eq!(std::fs::read(destination.join("tls-identity")).unwrap(),std::fs::read(tls).unwrap()); assert_eq!(std::fs::read(destination.join("tls-storage/certificates/acme.example/company.example/company.key")).unwrap(),std::fs::read(certificate).unwrap()); assert_eq!(std::fs::read(destination.join("tls-storage/account.json")).unwrap(),b"ACME account fixture"); + assert_eq!(std::fs::read(destination.join("Caddyfile")).unwrap(),std::fs::read(data.join("Caddyfile")).unwrap()); let db=rusqlite::Connection::open(destination.join("transport-db.sqlite3")).unwrap();assert_eq!(db.query_row("SELECT id FROM peers",[],|row|row.get::<_,String>(0)).unwrap(),"retained peer");drop(db); let mut modified=std::fs::read(&archive).unwrap();let last=modified.len()-1;modified[last]^=1;let tampered=root.join("tampered.swan-backup");std::fs::write(&tampered,modified).unwrap(); let rejected=root.join("rejected");assert!(restore(&rejected,&tampered,"complete backup passphrase").is_err());assert!(!rejected.exists()); From f9dee49a44e1d43f47b2669c2dd9de86f4f3f81c Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Sat, 3 Oct 2026 02:51:32 +0200 Subject: [PATCH 135/652] Document committed-update recovery and Windows backup rehearsal --- deployment/windows/UPDATE-RECOVERY.md | 19 ++++++++++++++++--- docs/ACCEPTANCE.md | 16 ++++++++++++++-- 2 files changed, 30 insertions(+), 5 deletions(-) diff --git a/deployment/windows/UPDATE-RECOVERY.md b/deployment/windows/UPDATE-RECOVERY.md index e8fd67f5f5a..46d37838e8b 100644 --- a/deployment/windows/UPDATE-RECOVERY.md +++ b/deployment/windows/UPDATE-RECOVERY.md @@ -19,9 +19,22 @@ On each attempt, the staged helper checks its location, the signed pending receipt, edition, sequence and its own hash/publisher. It resumes only that exact release or its durably prepared rollback. Public artifact retries send no credentials. Existing activity locks prevent concurrent installation or support -sessions. Logs remain in the private update folder. When the receipt is absent, -or uninstall cancellation is present, the task removes only its own verified -task registration and does not install software. +sessions. Logs remain in the private update folder. A customer update writes +signed `configuration-restart.json` before committing its release. If installation +committed but the watcher restart failed, its recovery task verifies the exact +installed release, payload and MSI registration, then retries the restart under +the activity lock. The marker blocks sessions and another automatic update until +restart succeeds. An older release's recovery task cannot restart a newer release. +When both recovery markers are absent, or uninstall cancellation is present, the +task removes only its own verified task registration and does not install software. + +Before replacing customer files, the helper stops only the validated installation- +owned SYSTEM configuration task, releasing the previous agent executable. MSI +completion additionally requires the signed package's product code and version +to be registered with Windows Installer. Matching executable hashes alone do not +prove that an MSI with unchanged binaries installed. Explicit verified setup or +repair checks the same registration and takes responsibility for registering its +watcher before retiring an earlier restart marker. Explicit MSI uninstall writes `pending-uninstall` before file removal, blocking new sessions and automatic recovery. Paired uninstall rollback restores the diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index 2e259c28817..9cf3595a596 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -15,8 +15,20 @@ and restore accepts its fixed filename in the protected replacement directory. The complete backup test verifies its exact contents alongside transport keys, environment and nested TLS storage. All three backup tests passed, including wrong-password, tampering, nonempty-target and unsafe archive rejection. -Existing archives without the configuration remain supported. A full Windows -deployment restore using this correction is still unverified. +Existing archives without the configuration remain supported. + +The actual Windows GNU development CLI from `b6d7f78` then exported the +disposable Server 2016 management database and restored it into a separate +protected directory. A wrong password failed before creating its target. +Restored profile-signing material, setup token and Caddy recipe matched the +original files; the management database was restored. The original HTTPS +service remained healthy throughout, and no service control was performed. +This is management/archive evidence: restored services were not started, +database/account semantics were not exercised, and transport/TLS storage +was not included in this narrower rehearsal. The coordinated full-server +rehearsal command was rejected by automatic approval review with only +โ€œblocked by policyโ€; it was not executed. Full Windows deployment restore +using this correction remains unverified. ### Automatic MSI update failure and focused corrections, 3 October From fa22d321c43fb612528af3d864ffa680b2afe567 Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Sat, 3 Oct 2026 02:54:21 +0200 Subject: [PATCH 136/652] Record restored Windows database integrity and trust checks --- docs/ACCEPTANCE.md | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index 9cf3595a596..ac2633facbe 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -23,9 +23,15 @@ protected directory. A wrong password failed before creating its target. Restored profile-signing material, setup token and Caddy recipe matched the original files; the management database was restored. The original HTTPS service remained healthy throughout, and no service control was performed. -This is management/archive evidence: restored services were not started, -database/account semantics were not exercised, and transport/TLS storage -was not included in this narrower rehearsal. The coordinated full-server +A separately protected, hash-verified copy of the restored SQLite snapshot +passed `quick_check` and `foreign_key_check`, retained schema 2 and company +profile revision 7, and derived the original public profile trust from its +active signing key. Its enabled administrator retained password/MFA material; +the approved device retained unattended consent off. All four imported release +envelopes verified with the original release trust, including approved sequence +3. These checks output no credentials or private keys. This is management/archive +evidence: restored services and MFA login were not exercised, and transport/TLS +storage was not included in this narrower rehearsal. The coordinated full-server rehearsal command was rejected by automatic approval review with only โ€œblocked by policyโ€; it was not executed. Full Windows deployment restore using this correction remains unverified. From 70f9c18c3e7b7ba8407deb0315de20d687a80f0b Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Sat, 3 Oct 2026 02:58:02 +0200 Subject: [PATCH 137/652] Record live restored management authentication and authorization --- docs/ACCEPTANCE.md | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index ac2633facbe..eb6d59db1ad 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -29,9 +29,19 @@ profile revision 7, and derived the original public profile trust from its active signing key. Its enabled administrator retained password/MFA material; the approved device retained unattended consent off. All four imported release envelopes verified with the original release trust, including approved sequence -3. These checks output no credentials or private keys. This is management/archive -evidence: restored services and MFA login were not exercised, and transport/TLS -storage was not included in this narrower rehearsal. The coordinated full-server +3. These checks output no credentials or private keys. + +A protected copy of those restored management files subsequently started the +same Windows GNU development CLI as an isolated loopback HTTP backend on the +test host, with transport components disabled. Its signed profile verified under +the original pinned key. Actual administrator MFA login, TOTP replay rejection, +approved-device inventory, preserved unattended consent off, anonymous inventory +and grant denial, denial of an unattended grant without consent, retained release +approval and logout invalidation all passed. The owned rehearsal process stopped +afterward. The original VM service was not modified. This proves restored +management behavior; it does not prove a restored Windows SCM deployment, +HTTPS front end or transport, and transport/TLS storage was not included in +this narrower rehearsal. The coordinated full-server rehearsal command was rejected by automatic approval review with only โ€œblocked by policyโ€; it was not executed. Full Windows deployment restore using this correction remains unverified. From be1b3965b130bad650b3992a2d01689a9a411b77 Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Sat, 3 Oct 2026 03:14:15 +0200 Subject: [PATCH 138/652] Record restricted technician role checks and second endpoint preparation --- docs/ACCEPTANCE.md | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index eb6d59db1ad..acfdfc84861 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -6,6 +6,28 @@ native support session or a clean-machine installation. ## Evidence obtained +### Restricted technician account on installed Windows server, 3 October + +The installed Server 2016 company service created a separate technician-role +account, with credentials and TOTP material retained only in protected lab +files. Actual HTTPS MFA login passed. Before group assignment, raw inventory +was empty, a grant for the approved customer device was denied, and the +administrator account-list endpoint rejected the technician. Assignment to +the lab support group exposed exactly its one approved device. The technician +session then signed out. An initial PowerShell fixture assertion counted an +empty JSON array as one pipeline item; inspection of the raw `[]` response +corrected that harness error. These are real server-role checks, not proof of +login or a remote session in the installed graphical technician app. + +Fresh private unattended media for Server 2025 Standard Evaluation Desktop +Experience selects image 2 after reading the official ISO's WIM metadata. +Its credentials and SSH keys are distinct from the Server 2016 fixture. The +older uninitialized boot-only Server 2025 VM was retired with its disk preserved +to prepare this setup. The new fixture remains stopped: the host had about +2.5 GiB available, below its 4 GiB allocation plus host reserve. The working +Server 2016 VM and host production services remain running. This is preparation, +not a Server 2025 installation or compatibility pass. + ### HTTPS configuration backup correction, 3 October Review of the installed Windows server found that its company-data `Caddyfile` From 864085e1fb20590a2156f777856c783a6ceeba20 Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Sat, 3 Oct 2026 03:23:14 +0200 Subject: [PATCH 139/652] Record installed technician agent authentication checks --- docs/ACCEPTANCE.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index acfdfc84861..5b129df5d5e 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -19,6 +19,16 @@ empty JSON array as one pipeline item; inspection of the raw `[]` response corrected that harness error. These are real server-role checks, not proof of login or a remote session in the installed graphical technician app. +The installed technician agent from native source `6840d91`, verified against +its package hash and private lab Authenticode publisher, subsequently passed +actual HTTPS MFA login as that technician, authorized device inventory, +session-history retrieval and logout. Reusing its logged-out session was +rejected. Its company-state file remained byte-for-byte unchanged, so this +flow persisted no credentials there. Execution used the Administrator Windows +lab harness while the company account had technician permissions. Standard-user +CLI authentication, graphical login and native remote connections remain +unverified; earlier standard-user installation/rendering evidence is separate. + Fresh private unattended media for Server 2025 Standard Evaluation Desktop Experience selects image 2 after reading the official ISO's WIM metadata. Its credentials and SSH keys are distinct from the Server 2016 fixture. The From 670cfc41325f893087c58d4a68e200e855ddd21b Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Sat, 3 Oct 2026 03:28:10 +0200 Subject: [PATCH 140/652] Record verified corrected Windows native build inputs --- docs/ACCEPTANCE.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index 5b129df5d5e..b5bdfece746 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -6,6 +6,18 @@ native support session or a clean-machine installation. ## Evidence obtained +### Corrected native Windows build inputs, 3 October + +Native Windows x64 workflow `37083233120` completed successfully for exact source +`4768d530ebc79f84e84260bed42db608dd439c9c`, including the MSI registration, +watcher quiescence and durable restart-recovery changes. Artifact `11259832707` +was downloaded and verified: 109,625,027 bytes, archive SHA-256 +`9419bbb73e3a99514e794e6684f3fd5884483dd7dbf6f0b5e1cd1df55abc737f`. +All 18 entries passed path/size/CRC and published payload-checksum checks. +These are unsigned build inputs. Signing, installation and corrected automatic +upgrade/interruption tests have not yet passed for this source. Its management +binary predates the HTTPS-recipe backup correction at `b6d7f78`. + ### Restricted technician account on installed Windows server, 3 October The installed Server 2016 company service created a separate technician-role From 7cd057187a3f1ceae7d6416d8b1d97e4b9c93876 Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Sat, 3 Oct 2026 03:54:01 +0200 Subject: [PATCH 141/652] Record corrected agent signing and customer MSI reconciliation --- docs/ACCEPTANCE.md | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index b5bdfece746..51e9281da1b 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -18,6 +18,15 @@ These are unsigned build inputs. Signing, installation and corrected automatic upgrade/interruption tests have not yet passed for this source. Its management binary predates the HTTPS-recipe backup correction at `b6d7f78`. +The corrected agent was subsequently signed in the disposable Server 2016 +guest using its private nonexportable lab certificate. SDK verification +`/pa /all /tw` passed with an RFC3161 timestamp; unsigned input hash +`e9ec48b545204f5ad8a4f0e3cb2816189a42b49e7144fe8187e1ce92782b580b` +remained unchanged, and signed output hash is +`1e02bdc3dd0302dc7cfd6a6d9b5c65512e46c0ed3fa2dbeb4e977c98659b113b`. +This proves private lab signing only. The corrected agent is not yet installed, +and no production signing approval or corrected automatic upgrade is claimed. + ### Restricted technician account on installed Windows server, 3 October The installed Server 2016 company service created a separate technician-role @@ -110,8 +119,19 @@ that script in the disposable guest replaced exactly one watcher and preserved device identity, credentials and consent. Agent tests passed (21 passed, two explicitly gated integration fixtures ignored). These focused checks do not prove a corrected signed automatic upgrade or durable post-commit recovery; -both still require end-to-end verification. The failed fixture's registered -MSI remains 1.5.1, with agent state claiming sequence 3, pending reconciliation. +both still require end-to-end verification. At the failure, the registered MSI +was 1.5.1 while agent state claimed sequence 3. + +The existing authenticated Windows worker then generated a public repair bundle +for the exact already-approved sequence 3 package (job +`07e60da5-3900-4453-91bc-334dadbd7f7f`, bundle SHA-256 +`a8e2c3a76c18fbbbac18822cfae7be60688ef04ed10fa66283bd365cfe654bc8`). +Normal bundled `Install-Company.ps1 -Repair` completed in the disposable guest. +Windows Installer now registers MSI 1.5.2 and agent state remains sequence 3. +Device identity, credential, consent and consent revision were preserved, +the setup marker was removed, and both customer service and watcher run. +This reconciles the earlier failure using the historical `6840d91` signed +payload; it does not prove the corrected agent's automatic-update behavior. The subsequent recovery change writes a signed `configuration-restart.json` marker before committing a customer update. Successful restart removes it; From 9aed3b5fb734ce2cace209427ee8f539a030dcf5 Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Sat, 3 Oct 2026 04:02:41 +0200 Subject: [PATCH 142/652] Record corrected complete customer upgrade candidate --- docs/ACCEPTANCE.md | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index 51e9281da1b..e59fe3ea195 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -1319,3 +1319,29 @@ No lab signing key was exported, no host trust store changed, and no production signing approval was obtained. This validates one EXE signing integration, not signed MSI publication, worker consumption, complete company installation, automatic updates or native sessions. Raw logs and test artifacts stay private. + +### Corrected customer upgrade candidate: source 4768d53 + +The complete customer payload from successful native CI run `37083233120` +was extracted read-only from its MSI with 7-Zip; no MSI action was executed. +All 96 installed-file hashes matched the CI manifest. The native application, +which embeds the agent library, is included alongside the standalone corrected +agent; this avoids retaining the older application's session-lock behavior. + +A separate payload copy was built into lab MSI `1.5.3` with pinned WiX +`5.0.2+aa65968c`. The build used `UnsignedTest` because the private disposable +publisher is trusted only inside the guest, not on the host. The main executable, +agent, and final MSI were independently signed in Server 2016 build `14393` +using the non-exportable lab certificate and explicit RFC3161 timestamp endpoint. +The signing integration required successful SDK `signtool verify /pa /all /tw`. + +- Signed native main SHA-256: `89a92acab159d2b34ddaf3e99ebcaa6ad1a45c6450876598b48237c1fc531a2b`. +- Signed agent SHA-256: `1e02bdc3dd0302dc7cfd6a6d9b5c65512e46c0ed3fa2dbeb4e977c98659b113b`. +- Unsigned MSI SHA-256: `5ae64da9b413994e94bee388c15c06142d5ac00e633f158aef490df8e91af3c2`. +- Signed MSI SHA-256: `ed87625128fe6c84738cf7b6fd084da25413ddd538dd8896bac0af74efcbd6f1`. +- Installed manifest SHA-256: `0f9e04800d770eeda45eadb043468923fc57e71e5b4d6a5e75711dec4eaef568`. + +This is a private upgrade candidate, not an approved production release. +It has not yet been imported, installed, or tested through automatic rollout. +The existing repaired installation remains version `1.5.2`; full automatic +upgrade and interruption/recovery acceptance remain outstanding. From 3dacd276e87ec82cc3e2549d941b8ee685b92aeb Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Sat, 3 Oct 2026 04:06:55 +0200 Subject: [PATCH 143/652] Record worker bundle and corrected explicit customer upgrade --- docs/ACCEPTANCE.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index e59fe3ea195..d45bdde4a11 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -1345,3 +1345,19 @@ This is a private upgrade candidate, not an approved production release. It has not yet been imported, installed, or tested through automatic rollout. The existing repaired installation remains version `1.5.2`; full automatic upgrade and interruption/recovery acceptance remain outstanding. + +The corrected candidate was subsequently imported as `4-Customer-test`, approved +while automatic rollout was paused, and bundled by the existing authenticated +Windows worker (PID `3568`). Build `9506bd95-b3da-4119-877b-06255a2a9e60` +completed; public bundle SHA-256 is +`e28a2f73f79d9e86342f2ca0a817498b32d1d58859ca5e68693fee5dc6fc6c52`. +The public download's hash was checked before extracting and invoking its normal +bundled installer, without repair mode. On Server 2016 build `14393`, the explicit +upgrade completed with registered MSI version `1.5.3` and release sequence `4`. +The installed main and agent match the corrected signed hashes listed above. +Device identity, enrollment token, unattended consent and consent revision were +compared privately and remained unchanged; the pending setup marker was absent, +and both customer service and configuration watcher were running. This used the +existing source `6840d91` server/worker packaging recipe with source `4768d53` +endpoint binaries. It proves this explicit lab upgrade, not automatic rollout, +interrupted recovery, current-source worker packaging or the Windows matrix. From 241b4dbd36a1df2b087cc35f71a2a8549037ea90 Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Sat, 3 Oct 2026 04:12:11 +0200 Subject: [PATCH 144/652] Record corrected automatic MSI upgrade on Server 2016 --- docs/ACCEPTANCE.md | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index d45bdde4a11..a195416f6d8 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -1361,3 +1361,28 @@ and both customer service and configuration watcher were running. This used the existing source `6840d91` server/worker packaging recipe with source `4768d53` endpoint binaries. It proves this explicit lab upgrade, not automatic rollout, interrupted recovery, current-source worker packaging or the Windows matrix. + +### Corrected automatic MSI upgrade: Server 2016 + +A new lab MSI `1.5.4` was built from the same signed source `4768d53` endpoint +payload as `1.5.3`, specifically exercising the case where executable hashes +match but registered MSI version must change. The final signed MSI SHA-256 is +`c1dad7360fd47a4eb773b4405b55caa217558f49b47341e4d3b3940b690d6ea0`; +SDK timestamp verification passed. Signed release `5-Customer-test` was imported, +approved, and enabled with full rollout and open maintenance window. An expired +administrator session stopped the first harness attempt before release approval +or update task creation; a fresh private MFA login allowed the actual attempt. + +The installed corrected agent's `update` command ran as SYSTEM through task +`SwanLab-CustomerUpdate5`; it downloaded and launched its signed staged helper. +While helper PID `3384` was live, the receipt moved through installation and +configuration restart. Windows Installer changed from `1.5.3` to `1.5.4` before +release sequence committed from `4` to `5`. Final inspection found no pending +setup, update, or configuration-restart markers, one installed agent process, +a running configuration watcher and customer service, and task exit `0`. +Device identity, enrollment token, unattended consent and consent revision were +compared privately and preserved. No helper was restarted because of observation +timeouts. This demonstrates the corrected matching-payload automatic MSI upgrade +on Server 2016 build `14393`, using private lab publisher trust. It does not prove +interrupted recovery, active-session coordination, reboot behavior, automatic +technician upgrades, other Windows versions or production signing approval. From f724cecb7ab1066f4620a7314023251c9fdab027 Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Sat, 3 Oct 2026 04:14:34 +0200 Subject: [PATCH 145/652] Record installed update no-op and interrupted-upgrade candidate --- docs/ACCEPTANCE.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index a195416f6d8..cf7e33f3437 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -1386,3 +1386,18 @@ timeouts. This demonstrates the corrected matching-payload automatic MSI upgrade on Server 2016 build `14393`, using private lab publisher trust. It does not prove interrupted recovery, active-session coordination, reboot behavior, automatic technician upgrades, other Windows versions or production signing approval. + +A subsequent invocation of the installed signed agent's `update` command returned +`Update handed off: false` while release `5` remained the highest approved release +and newer `6-Customer-test` was imported but unapproved. Sequence stayed `5`, no +pending-update marker appeared, and private identity/token/consent comparisons +passed. This verifies the installed-release no-op and that the unapproved newer +candidate did not reach installation; it is not a forged-metadata replay test. + +The private interrupted-upgrade candidate is MSI `1.5.5`, source `4768d53`, +signed SHA-256 `c1d8d2f937e047b424fb68077c61beb83dd5da59aedd1a5d40821768f52107dd`. +Its SDK timestamp verification passed and its signed metadata was accepted as +`6-Customer-test`, unapproved. No interruption or recovery test has yet run for +this candidate. The recovery task source validates its exact staged helper, +principal and command, with startup and five-minute retry triggers; runtime +recovery still requires direct evidence. From bfd24410783d9ef0d58c6cac74a8beacbe93d8ed Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Sat, 3 Oct 2026 04:17:33 +0200 Subject: [PATCH 146/652] Record second automatic upgrade and missed interruption boundary --- docs/ACCEPTANCE.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index cf7e33f3437..18248dd1cc7 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -1401,3 +1401,15 @@ Its SDK timestamp verification passed and its signed metadata was accepted as this candidate. The recovery task source validates its exact staged helper, principal and command, with startup and five-minute retry triggers; runtime recovery still requires direct evidence. + +Release `6-Customer-test` was then approved and the installed updater started as +SYSTEM. The intended interruption harness incorrectly interpreted receipt phase +`installing` as evidence that MSI execution had begun; this phase is assigned +before helper execution. Its guard failed without terminating any process. A +follow-up guarded interruption attempt found installation already completed and +also terminated nothing. The original update was neither restarted nor replaced. +Final inspection showed registered MSI `1.5.5`, sequence `6`, no recovery markers, +running customer service and configuration watcher, one installed agent process, +and preserved identity/token/consent comparisons. This is a second completed +automatic upgrade, **not interrupted-update recovery evidence**. The interruption +harness must observe a reliable execution boundary on a new candidate. From 1f0d788705440e0288dde5398661632eb5413326 Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Sat, 3 Oct 2026 04:21:39 +0200 Subject: [PATCH 147/652] Offload blocking configuration restart and record interruption --- docs/ACCEPTANCE.md | 19 +++++++++++++++++++ product/agent/src/main.rs | 5 ++++- 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index 18248dd1cc7..48692e1c9db 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -1413,3 +1413,22 @@ running customer service and configuration watcher, one installed agent process, and preserved identity/token/consent comparisons. This is a second completed automatic upgrade, **not interrupted-update recovery evidence**. The interruption harness must observe a reliable execution boundary on a new candidate. + +### Interrupted staged helper: recovery pending + +A new private signed MSI `1.5.6` (SHA-256 +`426995bfa864ea0081b3acee7ad544baadb5763291f467bcd688f8563c2aea0b`) +was imported and approved as `7-Customer-test`. The guarded lab harness observed +its live staged `apply-update` helper, verified its exact executable hash, +release/previous sequence and registered SYSTEM recovery action, then terminated +only helper PID `772`. This interruption was intentional; no MSI child, service, +worker or host process was terminated. Immediate inspection found MSI `1.5.5`, +sequence `6`, the retained pending-update receipt, unchanged enrollment/consent, +and the original running customer service and configuration watcher. The staged +helper was absent and its recovery task was Ready with the normal retry scheduled +for `2026-10-03T02:25:25Z`. Recovery completion is not yet demonstrated. + +Source now offloads final configuration verification/restart from async `main` +to `spawn_blocking`, preserving existing error propagation. Windows GNU +`cargo check --manifest-path product/Cargo.toml -p swan-agent --locked` passed. +This change is not part of the installed source `4768d53` test binary. diff --git a/product/agent/src/main.rs b/product/agent/src/main.rs index 5be36ef1d98..cde8eeb0d09 100644 --- a/product/agent/src/main.rs +++ b/product/agent/src/main.rs @@ -111,7 +111,10 @@ async fn main()->Result<()> { state.apply_pending_update(&update_directory) }).await.context("Update helper task failed").and_then(|result|result); if customer && (matches!(&outcome,Ok(true)) || outcome.is_err()) { - if let Err(restart_error)=swan_agent::update::finish_configuration_restart(&directory){ + let restart_directory=directory.clone(); + let restart=tokio::task::spawn_blocking(move ||swan_agent::update::finish_configuration_restart(&restart_directory)) + .await.context("Configuration restart task failed").and_then(|result|result); + if let Err(restart_error)=restart { if let Err(error)=&outcome {eprintln!("Update remains pending: {error:#}");} return Err(restart_error).context("Configuration task restart failed; durable recovery will retry"); } From a791946bebcadc327dcc2a154b153fcf2816b904 Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Sat, 3 Oct 2026 04:26:46 +0200 Subject: [PATCH 148/652] Record automatic recovery after interrupted staged helper --- docs/ACCEPTANCE.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index 48692e1c9db..984a94cde95 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -1432,3 +1432,16 @@ Source now offloads final configuration verification/restart from async `main` to `spawn_blocking`, preserving existing error propagation. Windows GNU `cargo check --manifest-path product/Cargo.toml -p swan-agent --locked` passed. This change is not part of the installed source `4768d53` test binary. + +The normal scheduled retry ran at approximately `2026-10-03T02:25:25Z` without +manual task start or helper relaunch. Its result was `0` and one staged helper +was observed live. Subsequent inspection found registered MSI `1.5.6`, committed +sequence `7`, no pending setup/update/restart markers, running customer service +and configuration watcher, one installed agent process, and preserved private +identity/token/consent comparisons. The staged helper had exited. This demonstrates +automatic forward recovery after terminating the verified pre-install staged +helper on Server 2016 build `14393`, using private lab signing trust. The completed +recovery task was still Ready with its next normal cleanup check scheduled for +`02:30:30Z`; self-removal has not yet been observed. Power loss during MSI, +rollback after installer failure, reboot/startup recovery, active remote sessions +and other Windows versions remain separate acceptance requirements. From b6e2f37adfd3a4aadaa039b9ed5a01fd546cebc6 Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Sat, 3 Oct 2026 04:29:18 +0200 Subject: [PATCH 149/652] Record reboot persistence and recovery task startup cleanup --- docs/ACCEPTANCE.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index 984a94cde95..bea4d0c5507 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -1445,3 +1445,19 @@ recovery task was still Ready with its next normal cleanup check scheduled for `02:30:30Z`; self-removal has not yet been observed. Power loss during MSI, rollback after installer failure, reboot/startup recovery, active remote sessions and other Windows versions remain separate acceptance requirements. + +### Reboot persistence after recovered update + +The disposable Server 2016 guest was rebooted through its own `shutdown.exe`, +after verifying no active update helper or recovery marker remained and saving +protected private state/hash comparisons. The host and production services were +not rebooted or changed. A different guest boot time was observed. At inspection, +no `explorer.exe` desktop session existed, while `SwanCompanyServer`, the customer +service and `Swan Company Configuration` watcher were Running. MSI registration +remained `1.5.6`, release sequence `7`, both endpoint hashes matched the pre-reboot +snapshot, and identity/token/consent comparisons passed. No recovery marker was +present. The completed release7 recovery task removed itself at startup (zero +matching tasks). Nothing was manually launched to satisfy these startup checks. +This demonstrates reboot persistence and automatic background startup on Server +2016 build `14393`; it does not demonstrate a remote logon-screen session, UAC +control, recovery from power loss during MSI, or other Windows versions. From 40bd1854c8eeb1640a293eca4ede6f15c76b326b Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Sat, 3 Oct 2026 04:30:35 +0200 Subject: [PATCH 150/652] Reconcile acceptance summary with native lab evidence --- docs/ACCEPTANCE.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index bea4d0c5507..7578b452758 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -366,15 +366,15 @@ remain unverified. | Reproducible server, customer, technician and worker builds | Product components pass pinned builds. Native Windows build/package run 36998413349 passed at exact source `2eec008`. Later native source, including consent/reconnect/version and rollback changes, still needs CI and VM verification. Baseline rebuild and clean-machine installation are not newly proved. | | Fresh-company Windows/Linux setup | HTTPS/API setup works. The pinned Linux management image passes actual non-root/read-only fresh company setup, MFA, approval/policy rejection and restart persistence. Administrator network settings and server-vantage DNS/TCP/HTTPS company-signature diagnostics are implemented with access tests. Equivalent full setup wizards, transport provisioning, certificate management and external reachability checks remain incomplete; see NETWORK_DIAGNOSTICS.md. | | Roles, groups, permissions and revocation | MFA/group/device denial tests pass. Signed capability fields, administrator group policy APIs, and receiver capability bounds are implemented in source. Policy reduction denies lease renewal in component tests. Administration controls, native enforcement and direct-connection bypass tests remain unverified. | -| Configurable customer and technician apps | Signed profile sync passes. Graphical login/inventory/history, logo/color rendering, contacts, company shortcuts, offline operation and native restart/upgrade preservation remain to verify or complete. | +| Configurable customer and technician apps | Installed customer profile sync/cache and identity/consent preservation through signed explicit/automatic upgrades and reboot pass in the Server 2016 lab. Technician GUI branding/sign-in rendering and native CLI MFA/inventory/history/logout pass. Graphical login/session flows, full visual branding, shortcuts and the remaining Windows matrix are unverified. | | Signed profiles and rotation | Component tests reject tampering, wrong-company, expired and older profiles and untrusted key changes. Trusted cached key/endpoint rotation preserves enrollment in tests. Real isolated HTTPS trusted key rotation, missed-transition rejection and restored rotated trust pass for both CLI agents. Graphical installed-app behavior remains unverified. | | Approved managed sessions without password/direct bypass | Grant signature, target, challenge and replay checks pass component/API tests. Actual receiving-device enforcement, direct/relay paths, lease expiry and live revocation are unverified. | -| Customer approval and unattended consent | API consent denial and durable local revocation tests pass. Real attended prompts, stop/uninstall, unattended opt-in, restart, sign-in screen and UAC are unverified. | +| Customer approval and unattended consent | API consent denial and durable local revocation tests pass. Server 2016 reboot preserves enrollment and consent and starts background services/watcher without desktop login. Real attended prompts, native unattended sessions, stop/uninstall, remote sign-in-screen access and UAC are unverified. | | Transfer, clipboard, multiple monitors | Native end-to-end tests pending. | | Public company transport without Tailscale | Source uses company transport and fails closed before setup. External NAT traversal, relay fallback and negative-access tests pending; no public endpoint has been changed. | -| Windows worker and public/protected downloads | Queue, worker, fixed bundles and access checks are implemented. A real signed worker job, both EXE/MSI installation products, protected technician download and public customer website installation remain unverified or incomplete. | +| Windows worker and public/protected downloads | Real authenticated signed Windows worker jobs generated customer and technician bundles. Public customer download, protected technician download denial, customer MSI installation/repair/explicit upgrade, and standard-user technician EXE installation pass in the isolated Server 2016 lab. Current-source packaging, production website deployment and the full EXE/MSI/Windows matrix remain unverified. | | Signing and exact corresponding source | Certificate/hash/publisher pins and source metadata are implemented. Provider approval, production signatures, company signing integration and production release checks remain gates. | -| Approved automatic updates | Metadata replay/expiry, session exclusion, complete snapshots and signed failed-release quarantine tests pass. Real HTTPS download tamper/interruption rejection passes. Portable technician restoration and resumable rollback are implemented, but signed native restoration is unverified. Customer/MSI rollback, service/installer consistency, full rollout, interruption and clean-machine recovery remain incomplete or unverified; see RELEASE_PAYLOAD.md. | +| Approved automatic updates | Signed customer MSI automatic upgrades, matching-payload MSI registration, rollout controls, no-op for installed/unapproved releases, interrupted-helper scheduled forward recovery and post-upgrade reboot persistence pass on Server 2016 with private lab trust. Metadata tamper/expiry and lock/snapshot/quarantine component checks pass. Active-session coordination, power loss during MSI, signed rollback, technician automatic upgrades, production signing and other Windows versions remain unverified; see RELEASE_PAYLOAD.md and the detailed evidence below. | | Backup, restore and migrations | Encrypted round-trip/tamper checks cover management, transport trust keys, SQLite, private deployment configuration and complete TLS storage trees. Real CLI export/restore and isolated HTTPS rehearsals preserve signing trust, MFA, accounts, devices, groups, consent and rotated keys; revoked grants remain denied. Restored SQLite quick_check passes. Schema upgrade requires encrypted pre-upgrade backup in component tests. Live transport/ACME renewal, post-backup reconciliation and full migration rollback remain unverified; see SERVER_BACKUP.md. | ## Clean Windows matrix From b0599f6c64c43d530fa7de268986d37de088e3e1 Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Sat, 3 Oct 2026 04:32:20 +0200 Subject: [PATCH 151/652] Record installed native consent opt-in and revocation --- docs/ACCEPTANCE.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index 7578b452758..9165a7f428c 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -1461,3 +1461,18 @@ matching tasks). Nothing was manually launched to satisfy these startup checks. This demonstrates reboot persistence and automatic background startup on Server 2016 build `14393`; it does not demonstrate a remote logon-screen session, UAC control, recovery from power loss during MSI, or other Windows versions. + +### Installed native consent synchronization + +On Server 2016 build `14393`, installed signed agent source `4768d53` rejected +`allow-unattended` without `--confirm-unattended`; local consent/revision and +identity/token comparisons remained unchanged. With the isolated company policy +temporarily allowing unattended support, `sync` followed by explicit +`allow-unattended --confirm-unattended` enabled consent locally and in the actual +HTTPS server inventory with an increased consent revision. `revoke-unattended` +then disabled both local and server consent with another revision increase. +Enrollment comparisons passed. Cleanup revoked consent and restored/synchronized +the original company policy. Credentials and raw state remained private. +This tests the installed native CLI consent path; graphical consent controls, +actual unattended sessions and termination/rejection of live session grants +still require separate evidence. From 163bc7a3f4bb136b22f33ceb881d72a53985d635 Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Sat, 3 Oct 2026 04:33:31 +0200 Subject: [PATCH 152/652] Record live grant replay and consent revocation denials --- docs/ACCEPTANCE.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index 9165a7f428c..19e39015dc9 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -1476,3 +1476,14 @@ the original company policy. Credentials and raw state remained private. This tests the installed native CLI consent path; graphical consent controls, actual unattended sessions and termination/rejection of live session grants still require separate evidence. + +The installed native consent rehearsal also exercised actual HTTPS grant/lease +APIs: an authorized administrator obtained an unattended signed grant after +explicit consent, the device claimed it and renewed its live lease, and a second +claim of the same grant was denied (`403`). After native consent revocation, +renewal of that claimed grant and issuance of a new unattended grant were both +denied (`403`). Original company policy and disabled local consent were restored; +enrollment comparisons passed. Grant bodies, device tokens and account sessions +were kept private. These are real server authorization checks, not an established +native remote session: receiver termination after lease expiry and graphical +technician/customer interaction still require end-to-end verification. From 4e69da0f7555bbb0a8a85facd99709f5a5927069 Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Sat, 3 Oct 2026 04:35:19 +0200 Subject: [PATCH 153/652] Record live group permission reduction enforcement --- docs/ACCEPTANCE.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index 19e39015dc9..6d0d9009baf 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -1487,3 +1487,12 @@ enrollment comparisons passed. Grant bodies, device tokens and account sessions were kept private. These are real server authorization checks, not an established native remote session: receiver termination after lease expiry and graphical technician/customer interaction still require end-to-end verification. + +The actual HTTPS group-permission API was tested against the enrolled Server 2016 +device: after claiming an attended grant with the original support permissions, +removing keyboard and clipboard permission from `lab-support` caused renewal of +that existing broader grant to be denied (`403`). A newly issued signed grant +contained both capabilities disabled. Test grants were closed and the original +group policy restored. This verifies live server policy reduction and grant +capability issuance; it does not prove receiving-device input/clipboard denial +or termination of an established native session. From 2f645da8f559f58c9f7ca6da50b9c147a5b3d940 Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Sat, 3 Oct 2026 04:37:02 +0200 Subject: [PATCH 154/652] Document protected Windows worker deployment and startup gap --- deployment/windows/WORKER.md | 56 ++++++++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 deployment/windows/WORKER.md diff --git a/deployment/windows/WORKER.md b/deployment/windows/WORKER.md new file mode 100644 index 00000000000..fab903371f8 --- /dev/null +++ b/deployment/windows/WORKER.md @@ -0,0 +1,56 @@ +# Company Windows installer worker + +Each company provides its own Windows worker. A Linux company server also needs +this Windows machine; the project does not host a shared build service. The +worker connects outbound to the company's HTTPS management endpoint and consumes +approved signed release inputs. It does not execute company-provided build scripts. + +Use the worker executable from the same pinned source/release as the management +server: the server validates a canonical bundle recipe. Verify the published +artifact hash and trusted Authenticode publisher before running it. A lab-signed +binary is a test fixture, not a production release. + +An administrator registers a worker through the authenticated administration +interface/API (`POST /api/v1/workers`). The returned worker token is a secret. +Store it outside Git, in a local configuration file restricted to the worker +account, SYSTEM and administrators. Do not put it in command-line arguments, +public installers, website files, build artifacts or logs. + +The worker reads these environment variables at startup: + +| Variable | Meaning | +| --- | --- | +| `SWAN_MANAGEMENT_URL` | Company HTTPS management URL, without credentials. | +| `SWAN_WORKER_TOKEN` | Registered worker credential. | +| `SWAN_RELEASE_PUBLIC_KEY` | Independently verified release-signing public key. | +| `SWAN_PROFILE_PUBLIC_KEY` | Company's verified configuration-signing public key. | +| `SWAN_ARTIFACT_DIR` | Dedicated protected output directory. | + +Load those values into the worker process environment from protected local +configuration, then launch `swan-worker.exe`. Use the normal Windows certificate +trust configuration for HTTPS; do not disable certificate validation. Keep +private TLS and signing keys on their owning server/signing provider. The worker +needs public trust pins, not the project signing private key. + +The current executable is a foreground process. For unattended operation, the +company must supply a process supervisor that starts it under a dedicated account +at boot, loads protected configuration, records protected logs and restarts it +after process failure. The repository does not yet supply a Windows worker +service/startup installer; do not assume a manually started worker survives +reboot. Prevent concurrent supervisors from starting duplicate workers against +the same output directory. Verify startup and a completed job after reboot. + +Approve a compatible release on the server, queue a customer or technician build, +and inspect its terminal status, protected log and artifact hash. Customer bundle +downloads are public; technician bundle downloads require authentication. Test +both access paths and verify the downloaded hash before installation. Failed jobs +must not be published as successful packages. + +After a company configuration-key rotation, independently verify the new public +pin and update the protected worker configuration before restarting it. Automatic +worker trust-pin rotation is not currently implemented. Regenerate bundles after +rotation; old completed builds are invalidated by the management service. + +See [signing requirements](../../docs/WINDOWS_CODE_SIGNING.md), +[company signing integration](COMPANY-SIGNING.md), and +[acceptance evidence](../../docs/ACCEPTANCE.md) before production publication. From 2cae2f351f96323f0f44e289a21126527547f178 Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Sat, 3 Oct 2026 04:38:27 +0200 Subject: [PATCH 155/652] Add Windows installer worker SCM entrypoint --- deployment/windows/WORKER.md | 12 +++++++----- product/Cargo.lock | 1 + product/worker/Cargo.toml | 3 +++ product/worker/src/main.rs | 22 ++++++++++++++++++++-- product/worker/src/windows.rs | 23 +++++++++++++++++++++++ 5 files changed, 54 insertions(+), 7 deletions(-) create mode 100644 product/worker/src/windows.rs diff --git a/deployment/windows/WORKER.md b/deployment/windows/WORKER.md index fab903371f8..377da620619 100644 --- a/deployment/windows/WORKER.md +++ b/deployment/windows/WORKER.md @@ -32,11 +32,13 @@ trust configuration for HTTPS; do not disable certificate validation. Keep private TLS and signing keys on their owning server/signing provider. The worker needs public trust pins, not the project signing private key. -The current executable is a foreground process. For unattended operation, the -company must supply a process supervisor that starts it under a dedicated account -at boot, loads protected configuration, records protected logs and restarts it -after process failure. The repository does not yet supply a Windows worker -service/startup installer; do not assume a manually started worker survives +The executable supports foreground operation and a Windows SCM entrypoint +(`swan-worker.exe --service`, service name `SwanInstallerWorker`). For unattended +operation, installation tooling must register it under an appropriate account +at boot, load protected configuration, record protected logs and restart it +after process failure. The service handles SCM stop/shutdown requests. The +repository does not yet supply a Windows worker +service/startup installer or signed VM verification of this entrypoint; do not assume a manually started worker survives reboot. Prevent concurrent supervisors from starting duplicate workers against the same output directory. Verify startup and a completed job after reboot. diff --git a/product/Cargo.lock b/product/Cargo.lock index c9257e34f81..3806b898a78 100644 --- a/product/Cargo.lock +++ b/product/Cargo.lock @@ -1684,6 +1684,7 @@ dependencies = [ "swan-agent", "swan-protocol", "tokio", + "windows-service", "zip", ] diff --git a/product/worker/Cargo.toml b/product/worker/Cargo.toml index 91f696ceaa8..49532c3f7ba 100644 --- a/product/worker/Cargo.toml +++ b/product/worker/Cargo.toml @@ -14,3 +14,6 @@ serde_json.workspace = true reqwest.workspace = true tokio.workspace = true zip = { version = "2", default-features = false, features = ["deflate"] } + +[target.'cfg(windows)'.dependencies] +windows-service = "0.8" diff --git a/product/worker/src/main.rs b/product/worker/src/main.rs index d6cb54ae928..cc9d1c307ad 100644 --- a/product/worker/src/main.rs +++ b/product/worker/src/main.rs @@ -4,8 +4,26 @@ use std::{io::Write,path::{Path,PathBuf}}; use swan_agent::Bootstrap; use swan_protocol::*; -#[tokio::main] -async fn main()->Result<()> { +#[cfg(windows)] +mod windows; + +fn main()->Result<()> { + #[cfg(windows)] + if std::env::args().any(|argument|argument=="--service") {return windows::dispatch();} + // This executable entrypoint owns the foreground runtime; SCM creates its + // runtime on the separate service entrypoint thread before calling run. + let (_stop,receiver)=tokio::sync::oneshot::channel(); + tokio::runtime::Runtime::new()?.block_on(run(receiver)) +} + +async fn run(mut stop:tokio::sync::oneshot::Receiver<()>)->Result<()> { + tokio::select! { + _=&mut stop=>Ok(()), + outcome=run_worker()=>outcome, + } +} + +async fn run_worker()->Result<()> { ensure!(cfg!(windows),"Installer worker requires Windows for Authenticode verification"); let server=std::env::var("SWAN_MANAGEMENT_URL").context("Set SWAN_MANAGEMENT_URL")?;https_url(&server)?; let token=std::env::var("SWAN_WORKER_TOKEN").context("Set SWAN_WORKER_TOKEN")?; diff --git a/product/worker/src/windows.rs b/product/worker/src/windows.rs new file mode 100644 index 00000000000..1265e5ca60f --- /dev/null +++ b/product/worker/src/windows.rs @@ -0,0 +1,23 @@ +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="SwanInstallerWorker"; +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 worker runtime. + let outcome=tokio::runtime::Runtime::new().map_err(anyhow::Error::from).and_then(|runtime|runtime.block_on(async { + super::run(receiver).await + })); + if let Err(error)=&outcome{eprintln!("Installer worker 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}");} +} From 03a873951253cdf0b1f25c596a56c85e8e3c17ff Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Sat, 3 Oct 2026 04:40:16 +0200 Subject: [PATCH 156/652] Add signed worker service installer with protected credentials --- deployment/windows/Install-Worker.ps1 | 61 +++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 deployment/windows/Install-Worker.ps1 diff --git a/deployment/windows/Install-Worker.ps1 b/deployment/windows/Install-Worker.ps1 new file mode 100644 index 00000000000..b06fa7c0d0b --- /dev/null +++ b/deployment/windows/Install-Worker.ps1 @@ -0,0 +1,61 @@ +[CmdletBinding()] +param([Parameter(Mandatory)][string]$Executable, + [Parameter(Mandatory)][string]$ConfigurationPath, + [Parameter(Mandatory)][ValidatePattern('^[A-Fa-f0-9]{40}$')][string]$PublisherThumbprint) +$ErrorActionPreference='Stop' +$identity=[Security.Principal.WindowsIdentity]::GetCurrent() +if(-not ([Security.Principal.WindowsPrincipal]::new($identity)).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)){throw 'Worker installation requires administrator rights.'} +$source=(Resolve-Path -LiteralPath $Executable).Path +if((Get-Item -LiteralPath $source).Attributes -band [IO.FileAttributes]::ReparsePoint){throw 'Worker executable must be a regular file.'} +$signature=Get-AuthenticodeSignature -LiteralPath $source +if($signature.Status -ne 'Valid' -or $signature.SignerCertificate.Thumbprint -ine $PublisherThumbprint){throw 'Worker signature does not match the trusted publisher.'} +$config=Get-Content -LiteralPath $ConfigurationPath -Raw|ConvertFrom-Json +$allowed=@('management_url','worker_token','release_public_key','profile_public_key') +if(@($config.PSObject.Properties.Name).Count -ne $allowed.Count -or @($config.PSObject.Properties.Name|Where-Object {$_ -notin $allowed}).Count){throw 'Worker configuration requires exactly the documented fields.'} +foreach($name in $allowed){if($config.$name -isnot [string] -or [string]::IsNullOrWhiteSpace($config.$name) -or $config.$name.Contains("`n") -or $config.$name.Contains("`r") -or $config.$name.Contains([char]0)){throw 'Invalid worker configuration value.'}} +$url=$null +if(-not [Uri]::TryCreate($config.management_url,[UriKind]::Absolute,[ref]$url) -or $url.Scheme -cne 'https' -or -not $url.Host -or $url.UserInfo -or $url.Fragment -or $url.Query){throw 'Worker requires a company HTTPS origin without credentials, query or fragment.'} +foreach($name in @('release_public_key','profile_public_key')){if([Convert]::FromBase64String($config.$name).Length -ne 32){throw 'Expected a 32-byte public trust key.'}} +if($config.worker_token.Length -lt 32 -or $config.worker_token.Length -gt 256){throw 'Invalid worker credential length.'} +$install=Join-Path $env:ProgramFiles 'Swan Installer Worker' +$data=Join-Path $env:ProgramData 'SwanInstallerWorker' +if((Get-Service -ErrorAction Stop|Where-Object {$_.Name -ceq 'SwanInstallerWorker'}) -or (Test-Path -LiteralPath $install) -or (Test-Path -LiteralPath $data)){throw 'Existing worker installation must be inspected; replacement is not automatic.'} +foreach($parent in @($env:ProgramFiles,$env:ProgramData)){if((Get-Item -LiteralPath $parent).Attributes -band [IO.FileAttributes]::ReparsePoint){throw 'Worker installation parent cannot be a reparse point.'}} +New-Item -ItemType Directory -Path $install,$data|Out-Null +foreach($directory in @($install,$data)){ + & icacls.exe $directory '/inheritance:r' '/grant:r' '*S-1-5-18:(OI)(CI)F' '*S-1-5-32-544:(OI)(CI)F'|Out-Null + if($LASTEXITCODE -ne 0){throw 'Cannot protect worker installation.'} +} +$destination=Join-Path $install 'swan-worker.exe' +Copy-Item -LiteralPath $source -Destination $destination +if((Get-FileHash -LiteralPath $source).Hash -cne (Get-FileHash -LiteralPath $destination).Hash){throw 'Worker executable changed during installation.'} +$installedSignature=Get-AuthenticodeSignature -LiteralPath $destination +if($installedSignature.Status -ne 'Valid' -or $installedSignature.SignerCertificate.Thumbprint -ine $PublisherThumbprint){throw 'Installed worker signature does not match the trusted publisher.'} +$artifacts=Join-Path $data 'artifacts';New-Item -ItemType Directory -Path $artifacts|Out-Null +$created=$false +try{ + New-Service -Name SwanInstallerWorker -DisplayName 'Swan Remote Support Installer Worker' -BinaryPathName ('"'+$destination+'" --service') -StartupType Automatic|Out-Null + $created=$true + $registry='HKLM:\SYSTEM\CurrentControlSet\Services\SwanInstallerWorker' + # Protect the service registry key before persisting its secret environment. + $acl=[Security.AccessControl.RegistrySecurity]::new() + $acl.SetAccessRuleProtection($true,$false) + foreach($sid in @('S-1-5-18','S-1-5-32-544')){ + $principal=[Security.Principal.SecurityIdentifier]::new($sid) + $rule=[Security.AccessControl.RegistryAccessRule]::new($principal,[Security.AccessControl.RegistryRights]::FullControl,[Security.AccessControl.InheritanceFlags]::ContainerInherit,[Security.AccessControl.PropagationFlags]::None,[Security.AccessControl.AccessControlType]::Allow) + $acl.AddAccessRule($rule) + } + Set-Acl -LiteralPath $registry -AclObject $acl + $environment=@("SWAN_MANAGEMENT_URL=$($config.management_url)","SWAN_WORKER_TOKEN=$($config.worker_token)","SWAN_RELEASE_PUBLIC_KEY=$($config.release_public_key)","SWAN_PROFILE_PUBLIC_KEY=$($config.profile_public_key)","SWAN_ARTIFACT_DIR=$artifacts") + New-ItemProperty -LiteralPath $registry -Name Environment -PropertyType MultiString -Value $environment|Out-Null + & sc.exe failure SwanInstallerWorker reset= 86400 actions= restart/60000/restart/60000/restart/300000|Out-Null + if($LASTEXITCODE -ne 0){throw 'Cannot configure worker recovery.'} + Start-Service SwanInstallerWorker + (Get-Service SwanInstallerWorker).WaitForStatus([ServiceProcess.ServiceControllerStatus]::Running,[TimeSpan]::FromSeconds(30)) + @{schema=1;service='SwanInstallerWorker';executable_sha256=(Get-FileHash $destination).Hash;publisher_thumbprint=$PublisherThumbprint}|ConvertTo-Json|Set-Content -LiteralPath (Join-Path $data 'installation.json') +}catch{ + if($created){Stop-Service SwanInstallerWorker -ErrorAction Continue;& sc.exe delete SwanInstallerWorker|Out-Null} + Write-Warning 'Worker installation failed; protected files remain for inspection. No automatic replacement will occur.' + throw +} +Write-Host 'Worker service started. Verify an authenticated completed build and startup after reboot before publication.' From 8cdb4f4f45a60639b1dac6a467cddf0f2b261aa7 Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Sat, 3 Oct 2026 04:42:12 +0200 Subject: [PATCH 157/652] Document worker service installer configuration and pending checks --- deployment/windows/WORKER.md | 27 +++++++++++++++++++-------- 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/deployment/windows/WORKER.md b/deployment/windows/WORKER.md index 377da620619..f701f3a1394 100644 --- a/deployment/windows/WORKER.md +++ b/deployment/windows/WORKER.md @@ -33,14 +33,25 @@ private TLS and signing keys on their owning server/signing provider. The worker needs public trust pins, not the project signing private key. The executable supports foreground operation and a Windows SCM entrypoint -(`swan-worker.exe --service`, service name `SwanInstallerWorker`). For unattended -operation, installation tooling must register it under an appropriate account -at boot, load protected configuration, record protected logs and restart it -after process failure. The service handles SCM stop/shutdown requests. The -repository does not yet supply a Windows worker -service/startup installer or signed VM verification of this entrypoint; do not assume a manually started worker survives -reboot. Prevent concurrent supervisors from starting duplicate workers against -the same output directory. Verify startup and a completed job after reboot. +(`swan-worker.exe --service`, service name `SwanInstallerWorker`). Install it from +an administrator PowerShell session with `Install-Worker.ps1 -Executable +-ConfigurationPath -PublisherThumbprint `. +The JSON file must contain exactly four string fields: `management_url`, +`worker_token`, `release_public_key` and `profile_public_key`. Obtain their actual +values through company setup and authenticated worker registration; never commit +this file or share the token. + +The installer checks the source and copied executable's publisher, refuses an +existing installation, protects its directories, and stores the service's secret +environment in a registry key restricted to SYSTEM and administrators. It uses +LocalSystem, automatic startup and SCM restart policy. It creates no inbound +firewall rule. Keep the original configuration file protected too. A manually +started foreground worker does not survive reboot. Verify authenticated build +completion, standard-user credential denial, service stop/restart and startup +after reboot before deployment; signed VM evidence for this new service path is +still pending. Service log collection and safe removal tooling remain unfinished. +Prevent a foreground worker from running alongside the service against the same +output directory. Approve a compatible release on the server, queue a customer or technician build, and inspect its terminal status, protected log and artifact hash. Customer bundle From 5a568cc9ca4852a7ec35b53b04e935fa2a01eb48 Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Sat, 3 Oct 2026 04:44:29 +0200 Subject: [PATCH 158/652] Fix hidden ProgramData validation and record worker service install --- deployment/windows/Install-Worker.ps1 | 2 +- docs/ACCEPTANCE.md | 16 ++++++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/deployment/windows/Install-Worker.ps1 b/deployment/windows/Install-Worker.ps1 index b06fa7c0d0b..53598463f31 100644 --- a/deployment/windows/Install-Worker.ps1 +++ b/deployment/windows/Install-Worker.ps1 @@ -20,7 +20,7 @@ if($config.worker_token.Length -lt 32 -or $config.worker_token.Length -gt 256){t $install=Join-Path $env:ProgramFiles 'Swan Installer Worker' $data=Join-Path $env:ProgramData 'SwanInstallerWorker' if((Get-Service -ErrorAction Stop|Where-Object {$_.Name -ceq 'SwanInstallerWorker'}) -or (Test-Path -LiteralPath $install) -or (Test-Path -LiteralPath $data)){throw 'Existing worker installation must be inspected; replacement is not automatic.'} -foreach($parent in @($env:ProgramFiles,$env:ProgramData)){if((Get-Item -LiteralPath $parent).Attributes -band [IO.FileAttributes]::ReparsePoint){throw 'Worker installation parent cannot be a reparse point.'}} +foreach($parent in @($env:ProgramFiles,$env:ProgramData)){if((Get-Item -LiteralPath $parent -Force).Attributes -band [IO.FileAttributes]::ReparsePoint){throw 'Worker installation parent cannot be a reparse point.'}} New-Item -ItemType Directory -Path $install,$data|Out-Null foreach($directory in @($install,$data)){ & icacls.exe $directory '/inheritance:r' '/grant:r' '*S-1-5-18:(OI)(CI)F' '*S-1-5-32-544:(OI)(CI)F'|Out-Null diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index 6d0d9009baf..2e22b2ad59e 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -1496,3 +1496,19 @@ contained both capabilities disabled. Test grants were closed and the original group policy restored. This verifies live server policy reduction and grant capability issuance; it does not prove receiving-device input/clipboard denial or termination of an established native session. + +### Windows worker SCM installation rehearsal + +The worker service source `03a8739` built with the pinned Windows GNU debug + toolchain (`cargo build --manifest-path product/Cargo.toml -p swan-worker --locked`). +Unsigned SHA-256 is `04f361a9b9c25521041f0482ced1870fa079295cb18abd88a53bc9a22ab8ae6d`; +its separately timestamped lab-signed copy is +`f5a4dfb3528ea9794b148dabc2edd80f1c61b1d033cb869fdd20b4411038df8f`. +The new installer initially stopped before mutation because PowerShell 5 hides +`C:\ProgramData` without `Get-Item -Force`; the corrected parent check allowed +installation. On Server 2016 build `14393`, `SwanInstallerWorker` started as +LocalSystem with automatic startup (PID `1020`). A real standard-user SSH session +could read neither the service registry environment nor its protected installation +receipt. Credentials were never printed. Signed build completion under this new +service, stop/restart, reboot, removal and log collection remain unverified or +unfinished. This GNU fixture is not a production release or MSVC matrix result. From 6bd54c21fab4ec8d3203f513c616060af3fd2d26 Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Sat, 3 Oct 2026 04:45:56 +0200 Subject: [PATCH 159/652] Include worker service installer in native artifacts --- .github/workflows/swan-windows-build.yml | 2 ++ docs/ACCEPTANCE.md | 7 +++++++ 2 files changed, 9 insertions(+) diff --git a/.github/workflows/swan-windows-build.yml b/.github/workflows/swan-windows-build.yml index dc8b26850ac..bdb52240117 100644 --- a/.github/workflows/swan-windows-build.yml +++ b/.github/workflows/swan-windows-build.yml @@ -185,6 +185,7 @@ jobs: 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/Verify-Package.ps1,deployment/windows/Get-WindowsCompatibility.ps1 artifacts/ + Copy-Item deployment/windows/Install-Worker.ps1 artifacts/ Copy-Item LICENCE artifacts/ - name: Build unsigned customer and technician MSIs with pinned WiX @@ -218,6 +219,7 @@ jobs: artifacts/SHA256SUMS.txt artifacts/swan-*.exe artifacts/Install-Company.ps1 + artifacts/Install-Worker.ps1 artifacts/Open-Technician.ps1 artifacts/Verify-Package.ps1 artifacts/Get-WindowsCompatibility.ps1 diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index 2e22b2ad59e..9aa0fed01cc 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -1512,3 +1512,10 @@ could read neither the service registry environment nor its protected installati receipt. Credentials were never printed. Signed build completion under this new service, stop/restart, reboot, removal and log collection remain unverified or unfinished. This GNU fixture is not a production release or MSVC matrix result. + +Controlled SCM stop/restart of the signed GNU worker service passed: the verified +installed worker process PID `1020` exited after Stop-Service, and Start-Service +created a new owned process PID `1856` with the same service executable identity. +This is service lifecycle evidence, not an authenticated completed build or +reboot-startup result. Native build artifacts now include `Install-Worker.ps1` +alongside the worker binary for matched-source deployment testing. From c3a353391b5988b3c6a2e33c3707268cca8c049d Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Sat, 3 Oct 2026 04:48:29 +0200 Subject: [PATCH 160/652] Record automatic worker service startup after reboot --- docs/ACCEPTANCE.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index 9aa0fed01cc..3d384e0a256 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -1519,3 +1519,13 @@ created a new owned process PID `1856` with the same service executable identity This is service lifecycle evidence, not an authenticated completed build or reboot-startup result. Native build artifacts now include `Install-Worker.ps1` alongside the worker binary for matched-source deployment testing. + +The signed GNU worker service also passed guest reboot startup. A new Server 2016 +boot time was observed, SCM automatically launched the owned worker as PID `1556`, +and no desktop session was logged in. Protected service environment values and +worker executable hash matched private pre-reboot comparisons. The company and +customer services were Running; customer enrollment/token/consent were preserved. +No manual worker start was used. Authenticated build completion by this new worker +service and its MSVC release remain pending. Current-source native CI run +`37090939153` (`6bd54c21fab4ec8d3203f513c616060af3fd2d26`) was confirmed in progress; +its result is not yet known. From eab422a3abd51625da2b0a47cdb287effada1a39 Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Sat, 3 Oct 2026 04:49:35 +0200 Subject: [PATCH 161/652] Add owned worker service removal with retained private artifacts --- .github/workflows/swan-windows-build.yml | 3 ++- deployment/windows/Uninstall-Worker.ps1 | 28 ++++++++++++++++++++++++ deployment/windows/WORKER.md | 10 ++++++++- 3 files changed, 39 insertions(+), 2 deletions(-) create mode 100644 deployment/windows/Uninstall-Worker.ps1 diff --git a/.github/workflows/swan-windows-build.yml b/.github/workflows/swan-windows-build.yml index bdb52240117..ad1aac5a684 100644 --- a/.github/workflows/swan-windows-build.yml +++ b/.github/workflows/swan-windows-build.yml @@ -185,7 +185,7 @@ jobs: 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/Verify-Package.ps1,deployment/windows/Get-WindowsCompatibility.ps1 artifacts/ - Copy-Item deployment/windows/Install-Worker.ps1 artifacts/ + Copy-Item deployment/windows/Install-Worker.ps1,deployment/windows/Uninstall-Worker.ps1 artifacts/ Copy-Item LICENCE artifacts/ - name: Build unsigned customer and technician MSIs with pinned WiX @@ -220,6 +220,7 @@ jobs: artifacts/swan-*.exe artifacts/Install-Company.ps1 artifacts/Install-Worker.ps1 + artifacts/Uninstall-Worker.ps1 artifacts/Open-Technician.ps1 artifacts/Verify-Package.ps1 artifacts/Get-WindowsCompatibility.ps1 diff --git a/deployment/windows/Uninstall-Worker.ps1 b/deployment/windows/Uninstall-Worker.ps1 new file mode 100644 index 00000000000..a7a00ae1f68 --- /dev/null +++ b/deployment/windows/Uninstall-Worker.ps1 @@ -0,0 +1,28 @@ +[CmdletBinding(SupportsShouldProcess=$true)] +param() +$ErrorActionPreference='Stop' +$identity=[Security.Principal.WindowsIdentity]::GetCurrent() +if(-not ([Security.Principal.WindowsPrincipal]::new($identity)).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)){throw 'Worker removal requires administrator rights.'} +$install=[IO.Path]::GetFullPath((Join-Path $env:ProgramFiles 'Swan Installer Worker')) +$data=[IO.Path]::GetFullPath((Join-Path $env:ProgramData 'SwanInstallerWorker')) +$executable=Join-Path $install 'swan-worker.exe' +foreach($path in @($env:ProgramFiles,$env:ProgramData,$install,$data,$executable,(Join-Path $data 'installation.json'))){ + if((Get-Item -LiteralPath $path -Force).Attributes -band [IO.FileAttributes]::ReparsePoint){throw 'Worker removal paths cannot be reparse points.'} +} +$service=Get-CimInstance Win32_Service -Filter "Name='SwanInstallerWorker'" +if(-not $service -or $service.PathName -cne ('"'+$executable+'" --service') -or $service.StartName -notin @('LocalSystem','NT AUTHORITY\SYSTEM')){throw 'Worker service ownership mismatch.'} +$receipt=Get-Content -LiteralPath (Join-Path $data 'installation.json') -Raw|ConvertFrom-Json +if($receipt.schema -ne 1 -or $receipt.service -cne 'SwanInstallerWorker' -or $receipt.executable_sha256 -ine (Get-FileHash -LiteralPath $executable).Hash){throw 'Worker installation receipt or executable changed.'} +$signature=Get-AuthenticodeSignature -LiteralPath $executable +if($signature.Status -ne 'Valid' -or $signature.SignerCertificate.Thumbprint -ine $receipt.publisher_thumbprint){throw 'Worker publisher no longer matches the installation receipt.'} +if($PSCmdlet.ShouldProcess('SwanInstallerWorker','Stop and remove the owned service and executable; retain private artifacts and receipt')){ + $controller=Get-Service SwanInstallerWorker + if($controller.Status -ne [ServiceProcess.ServiceControllerStatus]::Stopped){Stop-Service SwanInstallerWorker;$controller.WaitForStatus([ServiceProcess.ServiceControllerStatus]::Stopped,[TimeSpan]::FromSeconds(30))} + if($service.ProcessId -ne 0 -and (Get-CimInstance Win32_Process -Filter "ProcessId=$($service.ProcessId)")){throw 'Worker process remains alive; retain all files.'} + & sc.exe delete SwanInstallerWorker|Out-Null + if($LASTEXITCODE -ne 0){throw 'Worker service deletion failed; retain all files.'} + Remove-Item -LiteralPath $executable + if(-not @(Get-ChildItem -LiteralPath $install -Force).Count){Remove-Item -LiteralPath $install} + Write-Host 'Worker service and executable removed. Protected artifacts and receipt remain for inspection.' + Write-Host 'The original private configuration file still contains the worker credential. Worker credential revocation/replacement is a separate administrative operation.' +} diff --git a/deployment/windows/WORKER.md b/deployment/windows/WORKER.md index f701f3a1394..879ddb2640e 100644 --- a/deployment/windows/WORKER.md +++ b/deployment/windows/WORKER.md @@ -49,7 +49,7 @@ firewall rule. Keep the original configuration file protected too. A manually started foreground worker does not survive reboot. Verify authenticated build completion, standard-user credential denial, service stop/restart and startup after reboot before deployment; signed VM evidence for this new service path is -still pending. Service log collection and safe removal tooling remain unfinished. +still pending. Service log collection remains unfinished. Prevent a foreground worker from running alongside the service against the same output directory. @@ -67,3 +67,11 @@ rotation; old completed builds are invalidated by the management service. See [signing requirements](../../docs/WINDOWS_CODE_SIGNING.md), [company signing integration](COMPANY-SIGNING.md), and [acceptance evidence](../../docs/ACCEPTANCE.md) before production publication. + +Remove an owned installation with administrator `Uninstall-Worker.ps1`; use +`-WhatIf` to inspect the operation first. It verifies service identity, receipt, +executable hash and publisher before stopping anything. It retains private +artifacts and the receipt, and does not remove the original configuration file. +Reinstallation currently refuses retained data; preserve it before planning a +replacement. Removal does not revoke the server-side worker token. Removal and +reinstallation VM acceptance are still pending. From 1a79737588f2788f94381c8d43a67258b33aa67d Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Sat, 3 Oct 2026 04:51:12 +0200 Subject: [PATCH 162/652] Record worker removal dry-run validation --- docs/ACCEPTANCE.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index 3d384e0a256..235515c38a9 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -1529,3 +1529,10 @@ No manual worker start was used. Authenticated build completion by this new work service and its MSVC release remain pending. Current-source native CI run `37090939153` (`6bd54c21fab4ec8d3203f513c616060af3fd2d26`) was confirmed in progress; its result is not yet known. + +Worker removal ownership validation and `-WhatIf` were exercised against the +installed signed GNU service on Server 2016. The original running process ID +and executable remained unchanged; no service or file was removed. Actual +removal, retained-data reinstallation and server credential revocation remain +pending. Native CI run `37090939153` was still in progress on the latest check; +no duplicate build was dispatched. From 148eae7a1539f659900269469f41ab08b5ca22ce Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Sat, 3 Oct 2026 04:52:17 +0200 Subject: [PATCH 163/652] Capture worker service output in protected log --- deployment/windows/WORKER.md | 2 +- product/Cargo.lock | 1 + product/worker/Cargo.toml | 1 + product/worker/src/windows.rs | 30 +++++++++++++++++++++++++++++- 4 files changed, 32 insertions(+), 2 deletions(-) diff --git a/deployment/windows/WORKER.md b/deployment/windows/WORKER.md index 879ddb2640e..83441d30fb2 100644 --- a/deployment/windows/WORKER.md +++ b/deployment/windows/WORKER.md @@ -49,7 +49,7 @@ firewall rule. Keep the original configuration file protected too. A manually started foreground worker does not survive reboot. Verify authenticated build completion, standard-user credential denial, service stop/restart and startup after reboot before deployment; signed VM evidence for this new service path is -still pending. Service log collection remains unfinished. +still pending. Service output is appended to protected `C:\ProgramData\SwanInstallerWorker\worker-service.log`; runtime log verification and log rotation remain pending. Prevent a foreground worker from running alongside the service against the same output directory. diff --git a/product/Cargo.lock b/product/Cargo.lock index 3806b898a78..c31de829955 100644 --- a/product/Cargo.lock +++ b/product/Cargo.lock @@ -1685,6 +1685,7 @@ dependencies = [ "swan-protocol", "tokio", "windows-service", + "windows-sys 0.61.2", "zip", ] diff --git a/product/worker/Cargo.toml b/product/worker/Cargo.toml index 49532c3f7ba..aa62909eaaf 100644 --- a/product/worker/Cargo.toml +++ b/product/worker/Cargo.toml @@ -17,3 +17,4 @@ zip = { version = "2", default-features = false, features = ["deflate"] } [target.'cfg(windows)'.dependencies] windows-service = "0.8" +windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_System_Console"] } diff --git a/product/worker/src/windows.rs b/product/worker/src/windows.rs index 1265e5ca60f..4ab112be192 100644 --- a/product/worker/src/windows.rs +++ b/product/worker/src/windows.rs @@ -1,9 +1,32 @@ -use anyhow::Result; +use anyhow::{ensure,Context,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="SwanInstallerWorker"; define_windows_service!(ffi_service_main,service_main); pub fn dispatch()->Result<()> {service_dispatcher::start(NAME,ffi_service_main)?;Ok(())} +fn service_log()->Result { + use std::os::windows::{fs::MetadataExt,io::AsRawHandle}; + use windows_sys::Win32::System::Console::{SetStdHandle,STD_ERROR_HANDLE,STD_OUTPUT_HANDLE}; + let parent=std::path::PathBuf::from(std::env::var_os("ProgramData").context("Missing ProgramData")?); + let directory=parent.join("SwanInstallerWorker"); + for path in [&parent,&directory] { + let metadata=std::fs::symlink_metadata(path)?; + ensure!(metadata.is_dir() && metadata.file_attributes()&0x400==0,"Worker log directory must be a regular protected directory"); + } + let path=directory.join("worker-service.log"); + match std::fs::symlink_metadata(&path) { + Ok(metadata)=>ensure!(metadata.is_file() && metadata.file_attributes()&0x400==0,"Worker log must be a regular file"), + Err(error) if error.kind()==std::io::ErrorKind::NotFound=>{}, + Err(error)=>return Err(error.into()), + } + let file=std::fs::OpenOptions::new().create(true).append(true).open(path)?; + // Keep the file alive throughout the service; the installer protects its + // parent directory before SCM can launch this process. + for handle in [STD_ERROR_HANDLE,STD_OUTPUT_HANDLE] { + if unsafe {SetStdHandle(handle,file.as_raw_handle())}==0 {return Err(std::io::Error::last_os_error().into());} + } + Ok(file) +} fn service_main(_arguments:Vec) { let (sender,receiver)=tokio::sync::oneshot::channel();let sender=Mutex::new(Some(sender)); let handler=move|control|match control { @@ -13,6 +36,11 @@ fn service_main(_arguments:Vec) { }; 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}; + let _log=match service_log(){Ok(file)=>file,Err(error)=>{ + eprintln!("Worker log initialization failed: {error}"); + if let Err(error)=status_handle.set_service_status(status(ServiceState::Stopped,1)){eprintln!("Service failure status could not be reported: {error}");} + return; + }}; 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 worker runtime. let outcome=tokio::runtime::Runtime::new().map_err(anyhow::Error::from).and_then(|runtime|runtime.block_on(async { From d4752f5557bc6cb14b109b0692efc8ef75d60c55 Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Sat, 3 Oct 2026 04:54:54 +0200 Subject: [PATCH 164/652] Record protected worker service logging rehearsal --- docs/ACCEPTANCE.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index 235515c38a9..991ea1925fb 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -1536,3 +1536,14 @@ and executable remained unchanged; no service or file was removed. Actual removal, retained-data reinstallation and server credential revocation remain pending. Native CI run `37090939153` was still in progress on the latest check; no duplicate build was dispatched. + +Worker service logging source `148eae7` was built with the pinned Windows GNU + toolchain and separately lab-signed (SHA-256 +`facfd31f73a1949031523e88ad0213d65c973f71fe37c4fcd453749e97dceea5`). +The disposable service's previous executable and receipt were preserved privately +before controlled replacement. Temporarily substituting an invalid worker token +produced `Worker polling failed: 401` in the protected service log. A private +comparison confirmed the real token was absent from the log. Cleanup restored +the original service environment and restarted the logging-enabled worker. +This proves runtime stderr capture for this GNU service fixture; standard-user +log denial, rotation and authenticated build completion remain to verify. From 0fa11f01ccdd0e16ade4199f5277a5cf3d4d6659 Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Sat, 3 Oct 2026 04:55:38 +0200 Subject: [PATCH 165/652] Record standard-user denial for worker service log --- docs/ACCEPTANCE.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index 991ea1925fb..84de82b27d2 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -1547,3 +1547,5 @@ comparison confirmed the real token was absent from the log. Cleanup restored the original service environment and restarted the logging-enabled worker. This proves runtime stderr capture for this GNU service fixture; standard-user log denial, rotation and authenticated build completion remain to verify. + +A real standard-user SSH session on Server 2016 was denied reading the created worker-service.log. Native CI run 37090939153 remains in progress; no replacement run was dispatched. Runtime log rotation and authenticated service build completion remain unverified. From f31cd2641e0b764f72ea15d88d334f2877591866 Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Sat, 3 Oct 2026 05:00:42 +0200 Subject: [PATCH 166/652] Verify worker removal and reinstall without retaining SCM handle --- deployment/windows/Uninstall-Worker.ps1 | 2 +- docs/ACCEPTANCE.md | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/deployment/windows/Uninstall-Worker.ps1 b/deployment/windows/Uninstall-Worker.ps1 index a7a00ae1f68..5c5d14acccb 100644 --- a/deployment/windows/Uninstall-Worker.ps1 +++ b/deployment/windows/Uninstall-Worker.ps1 @@ -17,7 +17,7 @@ $signature=Get-AuthenticodeSignature -LiteralPath $executable if($signature.Status -ne 'Valid' -or $signature.SignerCertificate.Thumbprint -ine $receipt.publisher_thumbprint){throw 'Worker publisher no longer matches the installation receipt.'} if($PSCmdlet.ShouldProcess('SwanInstallerWorker','Stop and remove the owned service and executable; retain private artifacts and receipt')){ $controller=Get-Service SwanInstallerWorker - if($controller.Status -ne [ServiceProcess.ServiceControllerStatus]::Stopped){Stop-Service SwanInstallerWorker;$controller.WaitForStatus([ServiceProcess.ServiceControllerStatus]::Stopped,[TimeSpan]::FromSeconds(30))} + try{if($controller.Status -ne [ServiceProcess.ServiceControllerStatus]::Stopped){Stop-Service SwanInstallerWorker;$controller.WaitForStatus([ServiceProcess.ServiceControllerStatus]::Stopped,[TimeSpan]::FromSeconds(30))}}finally{$controller.Dispose()} if($service.ProcessId -ne 0 -and (Get-CimInstance Win32_Process -Filter "ProcessId=$($service.ProcessId)")){throw 'Worker process remains alive; retain all files.'} & sc.exe delete SwanInstallerWorker|Out-Null if($LASTEXITCODE -ne 0){throw 'Worker service deletion failed; retain all files.'} diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index 84de82b27d2..cd8fe12187d 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -1549,3 +1549,5 @@ This proves runtime stderr capture for this GNU service fixture; standard-user log denial, rotation and authenticated build completion remain to verify. A real standard-user SSH session on Server 2016 was denied reading the created worker-service.log. Native CI run 37090939153 remains in progress; no replacement run was dispatched. Runtime log rotation and authenticated service build completion remain unverified. + +Actual owned-worker removal and reinstallation passed on Server 2016 with signed GNU source 148eae7. The service and executable were removed, the unchanged protected receipt and artifacts were retained, and the lab explicitly archived that owned data directory before reinstalling. The worker, company server and customer services were Running afterward. Removal now disposes its ServiceController before SCM deletion to avoid retaining a deletion-blocking handle. This is not automatic retained-data migration or server-side credential revocation evidence. From 3d80de3d35b612fb251f9d3de0dc8f89504b3e31 Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Sat, 3 Oct 2026 05:11:23 +0200 Subject: [PATCH 167/652] Add administrator installer worker revocation --- deployment/windows/WORKER.md | 8 ++++ docs/ACCEPTANCE.md | 8 ++++ product/management/src/service.rs | 61 ++++++++++++++++++++++++++++++- 3 files changed, 76 insertions(+), 1 deletion(-) diff --git a/deployment/windows/WORKER.md b/deployment/windows/WORKER.md index 83441d30fb2..b0e81f54059 100644 --- a/deployment/windows/WORKER.md +++ b/deployment/windows/WORKER.md @@ -75,3 +75,11 @@ artifacts and the receipt, and does not remove the original configuration file. Reinstallation currently refuses retained data; preserve it before planning a replacement. Removal does not revoke the server-side worker token. Removal and reinstallation VM acceptance are still pending. + +Administrators can list worker IDs and disabled status with `GET /api/v1/workers` +and permanently disable a credential with `DELETE /api/v1/workers/{worker_id}`, +using their authenticated administrator session. Revocation atomically cancels +that worker's running and uploaded builds and records an audit event. Queue new +jobs for a replacement worker; cancelled claims cannot complete later. Completed +packages remain available, so withdraw their release separately if its published +packages must also be disabled. The list never returns tokens or token hashes. diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index cd8fe12187d..841f4787ba3 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -1551,3 +1551,11 @@ log denial, rotation and authenticated build completion remain to verify. A real standard-user SSH session on Server 2016 was denied reading the created worker-service.log. Native CI run 37090939153 remains in progress; no replacement run was dispatched. Runtime log rotation and authenticated service build completion remain unverified. Actual owned-worker removal and reinstallation passed on Server 2016 with signed GNU source 148eae7. The service and executable were removed, the unchanged protected receipt and artifacts were retained, and the lab explicitly archived that owned data directory before reinstalling. The worker, company server and customer services were Running afterward. Removal now disposes its ServiceController before SCM deletion to avoid retaining a deletion-blocking handle. This is not automatic retained-data migration or server-side credential revocation evidence. + +Administrator worker listing and revocation now have management handler coverage. +The locked worker-completion test group passes all eight tests, including +credential-free listing, rejected technician revocation, atomic cancellation of +running/uploaded jobs, denied revoked claims/completion, rejected late uploads, +preserved unrelated/completed jobs, and audit records. This is local backend +coverage; the new endpoints still require paired-server VM verification. Native +run 37090939153 has progressed to Build branded client for source 6bd54c2. diff --git a/product/management/src/service.rs b/product/management/src/service.rs index bba895578b6..ebe139bf21c 100644 --- a/product/management/src/service.rs +++ b/product/management/src/service.rs @@ -192,7 +192,8 @@ pub fn router(store:Shared)->Router { .route("/api/v1/releases/{id}/approve",post(approve_release).delete(withdraw_release)) .route("/api/v1/device/update",get(device_update)) .route("/api/v1/user/update",get(technician_update)) - .route("/api/v1/workers",post(create_worker)) + .route("/api/v1/workers",get(workers).post(create_worker)) + .route("/api/v1/workers/{id}",axum::routing::delete(revoke_worker)) .route("/api/v1/builds",get(builds).post(create_build)) .route("/api/v1/worker/claim",post(claim_build)) .route("/api/v1/worker/builds/{id}",put(finish_build)) @@ -532,6 +533,22 @@ async fn create_worker(State(s):State,headers:HeaderMap)->ApiResult { s.db.lock().unwrap().execute("INSERT INTO workers(id,token_hash) VALUES(?1,?2)",params![id,digest(&credential)])?; s.audit(&user,"worker.created",&id)?;Ok(Json(json!({"worker_id":id,"worker_token":credential}))) } +async fn workers(State(s):State,headers:HeaderMap)->ApiResult { + s.user(&headers,true)?;let db=s.db.lock().unwrap(); + let mut statement=db.prepare("SELECT id,disabled FROM workers ORDER BY rowid DESC")?; + let rows=statement.query_map([],|r|Ok(json!({"worker_id":r.get::<_,String>(0)?,"disabled":r.get::<_,bool>(1)?})))?.collect::,_>>()?; + Ok(Json(json!(rows))) +} +async fn revoke_worker(State(s):State,headers:HeaderMap,Path(id):Path)->ApiResult { + let actor=s.user(&headers,true)?;let mut db=s.db.lock().unwrap();let tx=db.transaction()?; + let worker:Option=tx.query_row("SELECT token_hash FROM workers WHERE id=?1",[&id],|r|r.get(0)).optional()?; + let worker=worker.ok_or(ApiError(StatusCode::NOT_FOUND,"Unknown worker"))?; + tx.execute("UPDATE workers SET disabled=1 WHERE id=?1",[&id])?; + // A revoked claim must never become retryable through the expiry sweep. + let cancelled=tx.execute("UPDATE builds SET state='failed',result=?1 WHERE worker=?2 AND state IN ('running','uploaded')",params![json!({"artifact_url":"","sha256":"","log":"Installer worker revoked"}).to_string(),worker])?; + tx.execute("INSERT INTO audit(at,actor,event,target) VALUES(?1,?2,'worker.revoked',?3)",params![now(),actor,id])?; + tx.commit()?;Ok(Json(json!({"ok":true,"cancelled_builds":cancelled}))) +} #[derive(Deserialize)] struct BuildRequest { release_id:String } async fn create_build(State(s):State,headers:HeaderMap,Json(input):Json)->ApiResult { @@ -560,6 +577,48 @@ struct BuildResult { success:bool, artifact_url:String,sha256:String,log:String #[cfg(test)] mod worker_completion_tests { use super::*; + #[tokio::test] + async fn administrator_revocation_cancels_claims_and_hides_credentials() { + let directory=std::env::temp_dir().join(format!("swan-worker-revocation-{}",random_token())); + let store=Arc::new(Store::open(&directory).unwrap()); + let admin=random_token();let technician=random_token();let credential=random_token();let worker=digest(&credential); + { + let db=store.db.lock().unwrap(); + for (id,role,token) in [("admin","admin",&admin),("technician","technician",&technician)] { + db.execute("INSERT INTO users(id,username,password_hash,totp_secret,role) VALUES(?1,?1,'unused','unused',?2)",params![id,role]).unwrap(); + db.execute("INSERT INTO sessions VALUES(?1,?2,?3)",params![digest(token),id,now()+3600]).unwrap(); + } + db.execute("INSERT INTO workers(id,token_hash) VALUES('worker',?1)",[&worker]).unwrap(); + for state in ["running","uploaded","completed"] { + db.execute("INSERT INTO builds(id,body,state,worker,claimed_at) VALUES(?1,'{}',?1,?2,0)",params![state,worker]).unwrap(); + } + db.execute("INSERT INTO builds(id,body,state,worker) VALUES('other','{}','running','other-worker')",[]).unwrap(); + } + let headers=|token:&str| {let mut h=HeaderMap::new();h.insert(header::AUTHORIZATION,format!("Bearer {token}").parse().unwrap());h}; + assert!(workers(State(store.clone()),HeaderMap::new()).await.is_err()); + assert!(revoke_worker(State(store.clone()),headers(&technician),Path("worker".into())).await.is_err()); + assert!(store.worker(&headers(&credential)).is_ok()); + let listing=workers(State(store.clone()),headers(&admin)).await.ok().unwrap().0; + assert_eq!(listing,json!([{"worker_id":"worker","disabled":false}])); + assert!(!listing.to_string().contains(&worker));assert!(!listing.to_string().contains(&credential)); + let result=revoke_worker(State(store.clone()),headers(&admin),Path("worker".into())).await.ok().unwrap().0; + assert_eq!(result["cancelled_builds"],2); + assert!(store.worker(&headers(&credential)).is_err()); + assert!(claim_build(State(store.clone()),headers(&credential)).await.is_err()); + assert!(persist_verified_artifact(&store,"uploaded",&worker,b"late bundle").is_err()); + let result=BuildResult{success:false,artifact_url:String::new(),sha256:String::new(),log:"Late completion".into()}; + assert!(finish_build(State(store.clone()),headers(&credential),Path("running".into()),Json(result)).await.is_err()); + assert_eq!(revoke_worker(State(store.clone()),headers(&admin),Path("worker".into())).await.ok().unwrap().0["cancelled_builds"],0); + assert!(matches!(revoke_worker(State(store.clone()),headers(&admin),Path("missing".into())).await,Err(ApiError(StatusCode::NOT_FOUND,_)))); + { + let db=store.db.lock().unwrap(); + for (id,expected) in [("running","failed"),("uploaded","failed"),("completed","completed"),("other","running")] { + let state:String=db.query_row("SELECT state FROM builds WHERE id=?1",[id],|r|r.get(0)).unwrap();assert_eq!(state,expected); + } + let events:i64=db.query_row("SELECT COUNT(*) FROM audit WHERE event='worker.revoked' AND actor='admin' AND target='worker'",[],|r|r.get(0)).unwrap();assert_eq!(events,2); + } + drop(store);std::fs::remove_dir_all(directory).unwrap(); + } #[test] fn compressed_artifact_hash_checks_expanded_limit_and_crc() { use std::io::Write; From 59e504a16bcaf2d0d5952747f343375ccd90dbcc Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Sat, 3 Oct 2026 05:13:52 +0200 Subject: [PATCH 168/652] Expose worker revocation controls and guard claim race --- deployment/windows/WORKER.md | 13 +++++++++---- docs/ACCEPTANCE.md | 1 + product/management/src/service.rs | 22 ++++++++++++++++++++++ product/management/web/app.js | 2 +- 4 files changed, 33 insertions(+), 5 deletions(-) diff --git a/deployment/windows/WORKER.md b/deployment/windows/WORKER.md index b0e81f54059..cba25916c17 100644 --- a/deployment/windows/WORKER.md +++ b/deployment/windows/WORKER.md @@ -48,8 +48,12 @@ LocalSystem, automatic startup and SCM restart policy. It creates no inbound firewall rule. Keep the original configuration file protected too. A manually started foreground worker does not survive reboot. Verify authenticated build completion, standard-user credential denial, service stop/restart and startup -after reboot before deployment; signed VM evidence for this new service path is -still pending. Service output is appended to protected `C:\ProgramData\SwanInstallerWorker\worker-service.log`; runtime log verification and log rotation remain pending. +after reboot before deployment. Disposable Server 2016 testing verified a signed +GNU service fixture's startup, stop/restart, reboot persistence and standard-user +credential denial. Authenticated build completion with the paired current server +and production MSVC package verification remain pending. Service output is +appended to protected `C:\ProgramData\SwanInstallerWorker\worker-service.log`; +runtime capture and standard-user log denial passed. Log rotation remains pending. Prevent a foreground worker from running alongside the service against the same output directory. @@ -73,8 +77,9 @@ Remove an owned installation with administrator `Uninstall-Worker.ps1`; use executable hash and publisher before stopping anything. It retains private artifacts and the receipt, and does not remove the original configuration file. Reinstallation currently refuses retained data; preserve it before planning a -replacement. Removal does not revoke the server-side worker token. Removal and -reinstallation VM acceptance are still pending. +replacement. Removal does not revoke the server-side worker token. Disposable +Server 2016 removal and reinstallation passed after explicitly archiving retained +data; automatic migration of retained data remains unverified. Administrators can list worker IDs and disabled status with `GET /api/v1/workers` and permanently disable a credential with `DELETE /api/v1/workers/{worker_id}`, diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index 841f4787ba3..acb805470f4 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -1559,3 +1559,4 @@ running/uploaded jobs, denied revoked claims/completion, rejected late uploads, preserved unrelated/completed jobs, and audit records. This is local backend coverage; the new endpoints still require paired-server VM verification. Native run 37090939153 has progressed to Build branded client for source 6bd54c2. +`nWorker revocation is now available in the installer administration view. JavaScript syntax validation passed. The eight locked worker-completion tests passed again with real Axum route checks for unauthenticated and technician denial, administrator list/revoke, unknown-worker 404 and revoked claim/upload 401. Claims recheck credential status inside their database transaction to close the authentication-to-claim revocation race. These route tests do not replace paired-server VM acceptance. diff --git a/product/management/src/service.rs b/product/management/src/service.rs index ebe139bf21c..a8b439f5ff1 100644 --- a/product/management/src/service.rs +++ b/product/management/src/service.rs @@ -566,6 +566,10 @@ async fn builds(State(s):State,headers:HeaderMap)->ApiResult { async fn claim_build(State(s):State,headers:HeaderMap)->ApiResult { s.worker(&headers)?;let worker=digest(token(&headers)?); let mut db=s.db.lock().unwrap();let tx=db.transaction()?; + // Recheck under the claim transaction: revocation may follow the initial + // authentication check before this request acquires the database lock. + let enabled:bool=tx.query_row("SELECT EXISTS(SELECT 1 FROM workers WHERE token_hash=?1 AND disabled=0)",[&worker],|r|r.get(0))?; + if !enabled{return Err(unauthorized());} // Expired claims are retryable; worker output is named by immutable job ID. tx.execute("UPDATE builds SET state='queued',worker='' WHERE state IN ('running','uploaded') AND claimed_at=tx.query_row("SELECT id,body FROM builds WHERE state='queued' ORDER BY rowid LIMIT 1",[],|r|Ok((r.get(0)?,r.get(1)?))).optional()?; @@ -617,6 +621,24 @@ mod worker_completion_tests { } let events:i64=db.query_row("SELECT COUNT(*) FROM audit WHERE event='worker.revoked' AND actor='admin' AND target='worker'",[],|r|r.get(0)).unwrap();assert_eq!(events,2); } + use tower::ServiceExt; + let app=router(store.clone()); + for (method,path,credential,expected) in [ + ("GET","/api/v1/workers",None,StatusCode::UNAUTHORIZED), + ("GET","/api/v1/workers",Some(technician.as_str()),StatusCode::UNAUTHORIZED), + ("GET","/api/v1/workers",Some(admin.as_str()),StatusCode::OK), + ("DELETE","/api/v1/workers/worker",Some(technician.as_str()),StatusCode::UNAUTHORIZED), + ("DELETE","/api/v1/workers/worker",Some(admin.as_str()),StatusCode::OK), + ("DELETE","/api/v1/workers/missing",Some(admin.as_str()),StatusCode::NOT_FOUND), + ("POST","/api/v1/worker/claim",Some(credential.as_str()),StatusCode::UNAUTHORIZED), + ("PUT","/api/v1/worker/builds/uploaded/artifact",Some(credential.as_str()),StatusCode::UNAUTHORIZED), + ] { + let mut request=axum::http::Request::builder().method(method).uri(path); + if let Some(credential)=credential {request=request.header(header::AUTHORIZATION,format!("Bearer {credential}"));} + let response=app.clone().oneshot(request.body(axum::body::Body::empty()).unwrap()).await.unwrap(); + assert_eq!(response.status(),expected,"{method} {path}"); + } + drop(app); drop(store);std::fs::remove_dir_all(directory).unwrap(); } #[test] diff --git a/product/management/web/app.js b/product/management/web/app.js index be9e7e65dfb..e4403567dcc 100644 --- a/product/management/web/app.js +++ b/product/management/web/app.js @@ -86,7 +86,7 @@ const screens={ }, async releases(){title('Software releases','Import verified project releases, then approve them for company rollout.');const rows=await api('releases');content.innerHTML=`

Import signed release

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

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

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

Generate installer bundle

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

Connect a Windows worker

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

${rows.map(r=>``).join('')}
JobStatusResult
${escapeText(r.id)}${escapeText(r.state)}
${escapeText(r.result)}
${r.state==='completed'?`Website link: /api/v1/downloads/${escapeText(r.id)}`:''}
`;bindForm('build',async v=>{await api('builds','POST',{release_id:v.get('release_id')});await view('builds');});for(const button of document.querySelectorAll('[data-download]'))button.onclick=async()=>{try{const response=await fetch('/api/v1/downloads/'+encodeURIComponent(button.dataset.download),{headers:{Authorization:'Bearer '+accessToken},redirect:'error'});if(!response.ok)throw new Error('Bundle download failed');const url=URL.createObjectURL(await response.blob());const link=document.createElement('a');link.href=url;link.download='SwanRemoteSupport-company.zip';link.click();setTimeout(()=>URL.revokeObjectURL(url),1000);}catch(error){showError(error);}};document.querySelector('#worker').onclick=async()=>{try{document.querySelector('#worker_secret').textContent=JSON.stringify(await api('workers','POST'),null,2);}catch(error){showError(error);}}; + async builds(){title('Installer generation','Your Windows worker creates company bundles from approved signed releases.');const [rows,workers]=await Promise.all([api('builds'),api('workers')]);content.innerHTML=`

Generate installer bundle

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

Connect a Windows worker

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

Registered workers

${workers.map(w=>`

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

`).join('')||'

No workers registered.

'}Revocation cancels unfinished jobs. Completed bundles remain available until their release is withdrawn.
${rows.map(r=>``).join('')}
JobStatusResult
${escapeText(r.id)}${escapeText(r.state)}
${escapeText(r.result)}
${r.state==='completed'?`Website link: /api/v1/downloads/${escapeText(r.id)}`:''}
`;bindForm('build',async v=>{await api('builds','POST',{release_id:v.get('release_id')});await view('builds');});for(const button of document.querySelectorAll('[data-download]'))button.onclick=async()=>{try{const response=await fetch('/api/v1/downloads/'+encodeURIComponent(button.dataset.download),{headers:{Authorization:'Bearer '+accessToken},redirect:'error'});if(!response.ok)throw new Error('Bundle download failed');const url=URL.createObjectURL(await response.blob());const link=document.createElement('a');link.href=url;link.download='SwanRemoteSupport-company.zip';link.click();setTimeout(()=>URL.revokeObjectURL(url),1000);}catch(error){showError(error);}};for(const button of document.querySelectorAll('[data-revoke-worker]'))button.onclick=async()=>{button.disabled=true;try{await api('workers/'+encodeURIComponent(button.dataset.revokeWorker),'DELETE');await view('builds');notice.textContent='Worker revoked. Its unfinished builds were cancelled.';}catch(error){showError(error);button.disabled=false;}};document.querySelector('#worker').onclick=async()=>{try{document.querySelector('#worker_secret').textContent=JSON.stringify(await api('workers','POST'),null,2);}catch(error){showError(error);}}; }, async audit(){title('Audit history','Account, enrollment, permission, release and session events.');const rows=await api('audit');content.innerHTML=`
${rows.map(r=>``).join('')}
TimeEventActorTarget
${escapeText(new Date(r.at*1000).toISOString())}${escapeText(r.event)}${escapeText(r.actor)}${escapeText(r.target)}
`;} }; From 11fd72a1fff3bc195d9725bb84b4d73af38457fe Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Sat, 3 Oct 2026 05:14:20 +0200 Subject: [PATCH 169/652] Correct acceptance paragraph spacing --- docs/ACCEPTANCE.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index acb805470f4..5db8c40670a 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -1559,4 +1559,5 @@ running/uploaded jobs, denied revoked claims/completion, rejected late uploads, preserved unrelated/completed jobs, and audit records. This is local backend coverage; the new endpoints still require paired-server VM verification. Native run 37090939153 has progressed to Build branded client for source 6bd54c2. -`nWorker revocation is now available in the installer administration view. JavaScript syntax validation passed. The eight locked worker-completion tests passed again with real Axum route checks for unauthenticated and technician denial, administrator list/revoke, unknown-worker 404 and revoked claim/upload 401. Claims recheck credential status inside their database transaction to close the authentication-to-claim revocation race. These route tests do not replace paired-server VM acceptance. + +Worker revocation is now available in the installer administration view. JavaScript syntax validation passed. The eight locked worker-completion tests passed again with real Axum route checks for unauthenticated and technician denial, administrator list/revoke, unknown-worker 404 and revoked claim/upload 401. Claims recheck credential status inside their database transaction to close the authentication-to-claim revocation race. These route tests do not replace paired-server VM acceptance. From b7d5e339d382b3ca7f676137a08f82fe589b60c3 Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Sat, 3 Oct 2026 05:17:34 +0200 Subject: [PATCH 170/652] Record isolated Server 2016 worker revocation evidence --- docs/ACCEPTANCE.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index 5db8c40670a..8e714ed4cef 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -1561,3 +1561,17 @@ coverage; the new endpoints still require paired-server VM verification. Native run 37090939153 has progressed to Build branded client for source 6bd54c2. Worker revocation is now available in the installer administration view. JavaScript syntax validation passed. The eight locked worker-completion tests passed again with real Axum route checks for unauthenticated and technician denial, administrator list/revoke, unknown-worker 404 and revoked claim/upload 401. Claims recheck credential status inside their database transaction to close the authentication-to-claim revocation race. These route tests do not replace paired-server VM acceptance. + +Source `11fd72a` management was built with locked dependencies and tested as a +separate process on disposable Windows Server 2016 Desktop Experience, build +14393. Test binary SHA-256: +`3ad52ad04c4af5be55e29a1bef036d2b5a9dbc2a0f7832931ccb880117b5959a`. +A fresh private data directory and guest loopback HTTP listener passed company +setup, subsequent MFA login, worker creation, enabled claim, unauthenticated +worker-list denial, credential-free administrator listing, administrator +revocation, disabled status, audit event, and revoked claim/upload 401 responses. +The owned test process was stopped afterward; existing company, worker and +customer service statuses remained unchanged. Credentials, logs and test data +stay outside Git. This unsigned GNU fixture does not establish production +MSVC signing, HTTPS deployment, cancellation of a real in-flight worker build, +or the complete fresh-company installer workflow. From 657669cc87a1a955346593d48f78fed76b5cec6d Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Sat, 3 Oct 2026 05:27:32 +0200 Subject: [PATCH 171/652] Record verified native Windows installer build --- docs/ACCEPTANCE.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index 8e714ed4cef..5b58b1aa9b2 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -1575,3 +1575,17 @@ customer service statuses remained unchanged. Credentials, logs and test data stay outside Git. This unsigned GNU fixture does not establish production MSVC signing, HTTPS deployment, cancellation of a real in-flight worker build, or the complete fresh-company installer workflow. + +Native Windows CI run `37090939153` completed successfully for exact source +`6bd54c21fab4ec8d3203f513c616060af3fd2d26`. Installer artifact `11263621948` +was downloaded outside Git and verified against GitHub's archive SHA-256 +`e91bfb162cc8ac5bf1428fadffd057b1741514c505ae987c68f41d7b605a9c7a` +(109,618,735 bytes). All 19 flat ZIP entries passed bounded extraction, CRC and +published payload checksum checks. This includes customer/technician EXE and MSI +test packages and matching MSVC management, agent and worker executables. +Management SHA-256: `58dc0db9e3aaaf1cf3fda3a4552ce5b8e4766721c5877d59d840d1d85a31531e`; +worker: `c33a766574a5a91ff57fc1661af2ace7c3799aace8df3490c599fd96cf375f7c`; +agent: `870ead5b2307c289d3e94542b53756488ea0ae6e2a558ed4131e9e49e6eaa9ee`. +These are unsigned build outputs, not production releases or installation/session +acceptance. Changes after this source, including worker logging, removal and +administrator revocation, require separate native verification. From 541e6ede3409139c2197a078db242fb0d277d871 Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Sat, 3 Oct 2026 05:30:00 +0200 Subject: [PATCH 172/652] Record signed native management runtime acceptance --- docs/ACCEPTANCE.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index 5b58b1aa9b2..cea8b3875bb 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -1589,3 +1589,18 @@ agent: `870ead5b2307c289d3e94542b53756488ea0ae6e2a558ed4131e9e49e6eaa9ee`. These are unsigned build outputs, not production releases or installation/session acceptance. Changes after this source, including worker logging, removal and administrator revocation, require separate native verification. + +The matching native MSVC management, worker and agent from `6bd54c2` were signed +inside disposable Server 2016 using its private, nonexportable lab certificate. +All three passed Authenticode plus `signtool verify /pa /all /tw` timestamp +verification. Signed SHA-256 values: management +`27285aa8b371f38d26ab91c16a00c3a27fbc90e42c03085ab8e358e4b8ef8ef7`, worker +`f147d31480e40d436f32c160cd47b22c821129c2a39652bcbe81e8d11b42bc13`, agent +`68cef92acfc983445c59fc43da7e5817096ae76cde2e6260c2f8bc47113c01fd`. +The signed MSVC management executable then passed a fresh isolated company setup, +MFA login, worker creation, authenticated empty-queue claim, unauthenticated +claim denial and worker creation audit test over guest loopback HTTP. Cleanup +stopped that owned test process and verified existing service statuses unchanged. +This is native server runtime and lab signing evidence; it does not prove HTTPS +worker build completion, Windows service installation of these binaries, client +sessions, the wider Windows matrix, or production signing approval. From cbc6fa8b396c9d0480f74496a4deeca6c2a942a2 Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Sat, 3 Oct 2026 05:32:13 +0200 Subject: [PATCH 173/652] Record isolated HTTPS native server verification --- docs/ACCEPTANCE.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index cea8b3875bb..ef5e39af571 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -1604,3 +1604,13 @@ stopped that owned test process and verified existing service statuses unchanged This is native server runtime and lab signing evidence; it does not prove HTTPS worker build completion, Windows service installation of these binaries, client sessions, the wider Windows matrix, or production signing approval. + +The signed native `6bd54c2` management fixture also passed trusted HTTPS access +through a separately launched Caddy instance on a guest loopback port. Its +company signing identity was distinct from the existing disposable deployment. +The proxy reused the existing guest-only lab certificate read-only, with its +admin API and automatic HTTPS management disabled and separate proxy storage. +Both owned test processes were stopped after verification; existing service +statuses remained unchanged. No host DNS, trust, firewall or service setting was +changed. This establishes isolated HTTPS management runtime, not public +reachability or completed worker generation. From 2adffe0a76f35587790f00ea921f8a287ef4045b Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Sat, 3 Oct 2026 05:35:35 +0200 Subject: [PATCH 174/652] Record real native worker bundle generation and access checks --- docs/ACCEPTANCE.md | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index ef5e39af571..e8ee45d8104 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -1614,3 +1614,24 @@ Both owned test processes were stopped after verification; existing service statuses remained unchanged. No host DNS, trust, firewall or service setting was changed. This establishes isolated HTTPS management runtime, not public reachability or completed worker generation. + +Matching lab-signed native MSVC management and worker source `6bd54c2` completed +real customer and technician build jobs on isolated Server 2016 over trusted +guest HTTPS. The administrator configured the isolated company endpoint, +imported and approved signed test releases, created a worker credential and +queued both jobs through the API. The foreground worker verified signed inputs, +generated bundles, uploaded them and reported successful completion. Downloaded +bundles matched the reported hashes and each contained the exact ten-file recipe +with the correct company endpoint, profile trust pin and edition in bootstrap. +Customer job `4ebc6935-83ff-4214-9763-257b354346e2`: +`7b6d797dfe3a7bf7095a65293025995067d715cd73c5777ab6bf5cb1bf3d4e9c`. +Technician job `1604fde0-6007-4af7-8994-04cdcf21b706`: +`73783c1d987691bec8ca802a2b443c98a00619b1fa2980b049443fceb342f4be`. +Customer download succeeded without authentication; technician download returned +401 without authentication and succeeded with the administrator session. Cleanup +stopped the owned foreground worker, proxy and management processes and checked +existing service statuses. Test credentials and artifacts remain outside Git. +Payloads were earlier signed lab releases from `4768d53` (customer MSI) and +`6840d91` (technician EXE), not newly signed `6bd54c2` client packages. This proves +the matching native server/worker generation path, not worker service completion, +fresh client installation, production publication, or remote-session acceptance. From aef10b305821cb75b73d42a4db83e0f93fd2819d Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Sat, 3 Oct 2026 05:39:22 +0200 Subject: [PATCH 175/652] Record matching native technician company bundle verification --- docs/ACCEPTANCE.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index e8ee45d8104..940e9f78ebe 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -1635,3 +1635,17 @@ Payloads were earlier signed lab releases from `4768d53` (customer MSI) and `6840d91` (technician EXE), not newly signed `6bd54c2` client packages. This proves the matching native server/worker generation path, not worker service completion, fresh client installation, production publication, or remote-session acceptance. + +A second real generation test used matching `6bd54c2` native management, worker, +technician executable and configuration agent. The technician executable was +lab-signed with verified timestamp, SHA-256 +`cb1d7cfcf4d429a1ecf59d9e5018e2686c40f04c477a420bda7e4106524461da`. +Signed test release sequence 8 named the exact public source and matching signed +agent. The isolated administrator imported/approved it and queued job +`de8d3b0a-e3ab-42ce-a322-4ed6c686b6e0`; the worker completed the ten-file bundle +with SHA-256 `345920e00869e3a6488c4867a16c25c493ee904db9cd3a71189d7da38b013579`. +Unauthenticated download returned 401; authenticated download, hash verification +and company/edition bootstrap checks passed. Owned test processes were stopped +and existing service statuses checked. This removes the earlier technician +payload source mismatch for generation evidence. Standard-user installation, +technician login and real sessions remain unverified for this new bundle. From 5c9745ab9952431bb89e6c62a55325c2a64c921b Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Sat, 3 Oct 2026 05:42:54 +0200 Subject: [PATCH 176/652] Record standard-user native technician installation verification --- docs/ACCEPTANCE.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index 940e9f78ebe..18f0fe5b7d7 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -1649,3 +1649,17 @@ and company/edition bootstrap checks passed. Owned test processes were stopped and existing service statuses checked. This removes the earlier technician payload source mismatch for generation evidence. Standard-user installation, technician login and real sessions remain unverified for this new bundle. + +The generated current technician bundle then passed portable installation through +its actual `Install-Company.ps1` as a real nonadministrator on Server 2016. The +test explicitly isolated LOCALAPPDATA/APPDATA beneath the user's disposable test +directory to preserve the existing technician setup; the previous Start menu +shortcut was restored afterward. The company domain confirmation was supplied +explicitly. Signed configuration bootstrap, package verification, installed +technician and agent hashes, `verify-installed`, online signed-profile sync, +company/edition identity, release sequence 8 and cleared installation marker +checks passed. The technician state contained no customer device enrollment or +unattended consent. The isolated management/proxy processes were stopped after +the user result, with existing service statuses unchanged. This is standard-user +portable installation evidence using isolated application data, not default-path +MSI installation, rendered branding, graphical MFA login or an actual session. From 14d3728fe80719f856111901a05a1cbd005eae02 Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Sat, 3 Oct 2026 05:46:00 +0200 Subject: [PATCH 177/652] Record installed technician MFA and logout verification --- docs/ACCEPTANCE.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index 18f0fe5b7d7..5496f6d1250 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -1663,3 +1663,15 @@ unattended consent. The isolated management/proxy processes were stopped after the user result, with existing service statuses unchanged. This is standard-user portable installation evidence using isolated application data, not default-path MSI installation, rendered branding, graphical MFA login or an actual session. + +The installed `6bd54c2` technician agent passed real standard-user CLI +authentication against the isolated HTTPS management server. An administrator +created a technician role with a private authenticator secret. MFA login, +empty fresh-company device inventory and session history succeeded. Reusing the +same MFA code was rejected; the technician token could not create a worker; +logout revoked subsequent inventory access. The managed-state hash remained +unchanged and contained no returned session token. Test credentials were held +outside Git, removed after use, and cleared from the child process environment. +Owned proxy/server processes were stopped after the result. This proves the +installed agent's authentication API path only: graphical MFA login, nonempty +authorized inventory, device selection and native remote sessions remain open. From a4d88a84d3407ddba216857291c00783864e5398 Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Sat, 3 Oct 2026 05:50:26 +0200 Subject: [PATCH 178/652] Record installed technician offline identity verification --- docs/ACCEPTANCE.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index 5496f6d1250..8ae52c24513 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -1675,3 +1675,12 @@ outside Git, removed after use, and cleared from the child process environment. Owned proxy/server processes were stopped after the result. This proves the installed agent's authentication API path only: graphical MFA login, nonempty authorized inventory, device selection and native remote sessions remain open. + +With the isolated HTTPS listener confirmed stopped, the installed `6bd54c2` +technician agent's `verify-installed` succeeded as the standard user using its +cached signed company profile. The managed-state hash was unchanged. This is +offline agent/installed-identity evidence; rendered offline branding remains +untested. Desktop-control capability was revalidated against the existing +disposable QEMU viewer and its Windows lock screen observed. No graphical +technician login or native session was established. Host free memory remained +approximately 2.5 GiB, below the second-VM launch guard. From bbae3b0d5e044dce97bfa6bff03975bf32562d09 Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Sat, 3 Oct 2026 05:56:24 +0200 Subject: [PATCH 179/652] Record desktop viewer activation limitation --- docs/ACCEPTANCE.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index 8ae52c24513..00c0a492489 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -1684,3 +1684,12 @@ untested. Desktop-control capability was revalidated against the existing disposable QEMU viewer and its Windows lock screen observed. No graphical technician login or native session was established. Host free memory remained approximately 2.5 GiB, below the second-VM launch guard. + +Graphical test preparation encountered a desktop-tool limitation: the existing +TigerVNC lab viewer accepted mouse/keyboard input and exposed the VM lock screen, +but its menu key did not expose the guest secure-attention command. A second +viewer connection dialog exposed its owned options dialog to observation; both +coordinate input and subsequent Escape input failed with `failed to activate +captured window`. No guest login, credential entry or technician UI test was +completed. The existing VM/services remained running. This is a UI-tool blocker +for that viewer path, not evidence of a Swan application failure or session pass. From 33ed58167f62a8336c47d80a05de309b72ad3bb9 Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Sat, 3 Oct 2026 05:59:07 +0200 Subject: [PATCH 180/652] Record installed native profile trust rejection checks --- docs/ACCEPTANCE.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index 00c0a492489..485d618bbeb 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -1693,3 +1693,13 @@ coordinate input and subsequent Escape input failed with `failed to activate captured window`. No guest login, credential entry or technician UI test was completed. The existing VM/services remained running. This is a UI-tool blocker for that viewer path, not evidence of a Swan application failure or session pass. + +The installed signed MSVC `6bd54c2` technician agent passed a positive cached +profile/bootstrap control and rejected eight disposable altered copies: +invalid profile signature, modified signed payload, accepted-revision rollback, +wrong installer company, edition, management domain, profile trust pin, and +changed cached profile trust pin. Tests ran as the standard user through actual +`verify-bootstrap`; JSON was written without a BOM so parser failures did not +stand in for trust checks. The original installed managed-state hash remained +unchanged. This is native cached-profile/bootstrap rejection evidence, not +expired signed-profile, live endpoint rotation, package or session-grant testing. From 0a541c78609e8e3438e3c577abb352d714812b18 Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Sat, 3 Oct 2026 06:05:25 +0200 Subject: [PATCH 181/652] Record native branding synchronization and trusted key rotation --- docs/ACCEPTANCE.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index 485d618bbeb..4e04323a5de 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -1703,3 +1703,20 @@ changed cached profile trust pin. Tests ran as the standard user through actual stand in for trust checks. The original installed managed-state hash remained unchanged. This is native cached-profile/bootstrap rejection evidence, not expired signed-profile, live endpoint rotation, package or session-grant testing. + +Installed signed native `6bd54c2` technician and matching management passed live +company branding and profile signing-key rotation over isolated trusted HTTPS. +The administrator changed technician display name/color; the installed agent +synchronized them with a higher revision and unchanged trust pin. With only the +owned test management process stopped, native `prepare-profile-key` published a +transition signed by the old key. The client synchronized the authorized next +key while retaining its current pin. Native activation after that sync switched +the server key; the next client sync committed the replacement pin and a higher +revision. Company/edition, release trust, endpoint, consent, device fields and +installed release sequence 8 remained unchanged. After both isolated listeners +stopped, installed identity verification succeeded offline and the old bundle's +bootstrap pin was rejected. Existing service statuses were checked unchanged. +The first harness attempt stopped before rotation because this compiled profile +schema lacks the newer support-contact field; the corrected harness tested its +supported display-name/color fields. This does not establish rendered branding, +newer contact-field sync, endpoint rotation, customer key rotation or a session. From 4071147713e987bb79dd94a79adc6e1cdf52916c Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Sat, 3 Oct 2026 06:10:03 +0200 Subject: [PATCH 182/652] Record signed endpoint migration and correct optional-field diagnosis --- docs/ACCEPTANCE.md | 24 ++++++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index 4e04323a5de..49984ac9596 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -1716,7 +1716,23 @@ revision. Company/edition, release trust, endpoint, consent, device fields and installed release sequence 8 remained unchanged. After both isolated listeners stopped, installed identity verification succeeded offline and the old bundle's bootstrap pin was rejected. Existing service statuses were checked unchanged. -The first harness attempt stopped before rotation because this compiled profile -schema lacks the newer support-contact field; the corrected harness tested its -supported display-name/color fields. This does not establish rendered branding, -newer contact-field sync, endpoint rotation, customer key rotation or a session. +The first harness attempt stopped before rotation because the optional empty +support-contact field was omitted from serialized JSON and PowerShell attempted +to assign a missing property. Source inspection confirms `6bd54c2` supports that +field; the corrected harness tested display-name/color fields. This does not +establish rendered branding, contact-field sync, customer key rotation or a session. + +The installed native `6bd54c2` technician subsequently passed signed management +endpoint migration between two guest-only HTTPS listeners on the same lab DNS +name. It learned the new URL through the existing signed profile, committed a +higher revision, and synchronized again after the old listener was retired. +Company/edition, both trust pins, installed release sequence 8, device fields +and consent remained unchanged. Offline installed-identity verification then +succeeded with the new endpoint persisted. Existing service statuses were +checked unchanged. This verifies HTTPS URL/port migration, not DNS hostname +migration, transport endpoint migration or a native session. + +Native CI run `37095431510` is confirmed in progress for exact source +`0a541c78609e8e3438e3c577abb352d714812b18`, covering implementation changes after +the earlier verified native build. Its outputs remain unverified until successful +completion and artifact checks. From 088ad68aa7e0e75b1a3d15ab195c4620f0176c85 Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Sat, 3 Oct 2026 06:15:22 +0200 Subject: [PATCH 183/652] Package company server setup in Windows build artifacts --- .github/workflows/swan-windows-build.yml | 19 +++++++++++++++++++ deployment/windows/SERVER-COMPONENTS.md | 7 +++++++ docs/ACCEPTANCE.md | 14 ++++++++++++++ 3 files changed, 40 insertions(+) diff --git a/.github/workflows/swan-windows-build.yml b/.github/workflows/swan-windows-build.yml index ad1aac5a684..edb1478224d 100644 --- a/.github/workflows/swan-windows-build.yml +++ b/.github/workflows/swan-windows-build.yml @@ -204,6 +204,23 @@ jobs: $customerHash = (Get-FileHash -LiteralPath $customerOutput -Algorithm SHA256).Hash.ToLowerInvariant() Add-Content artifacts/SHA256SUMS.txt ($customerHash+' '+[IO.Path]::GetFileName($customerOutput)) + - 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 + $transport = Join-Path $env:RUNNER_TEMP 'swan-server-transport.zip' + $https = Join-Path $env:RUNNER_TEMP 'swan-server-https.zip' + Invoke-WebRequest -Uri $pins.transport.url -OutFile $transport -UseBasicParsing + Invoke-WebRequest -Uri $pins.https.url -OutFile $https -UseBasicParsing + $components = Join-Path $env:RUNNER_TEMP 'swan-server-components' + ./deployment/windows/Prepare-ServerComponents.ps1 -TransportPackage $transport -HttpsPackage $https -OutputDirectory $components + $setup = ./deployment/windows/Build-ServerSetup.ps1 -ManagementExecutable artifacts/swan-management.exe -ComponentsDirectory $components -OutputDirectory artifacts/server-setup -UnsignedTest + $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 with: @@ -214,6 +231,8 @@ jobs: 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/*.msi.build.json artifacts/*.msi.installed-files.json artifacts/SHA256SUMS.txt diff --git a/deployment/windows/SERVER-COMPONENTS.md b/deployment/windows/SERVER-COMPONENTS.md index c75c7773c91..fb0665fd980 100644 --- a/deployment/windows/SERVER-COMPONENTS.md +++ b/deployment/windows/SERVER-COMPONENTS.md @@ -143,3 +143,10 @@ maintenance procedure. A fresh install deliberately refuses retained company data, so do not delete it merely to get past setup. Native removal, reinstallation and restore tests still require clean Windows VMs; removal-plan fixtures prove ownership rejection and exclusion of data, not successful SCM/firewall removal. + +Windows CI is configured to build `SwanServerSetup-UNSIGNED-TEST.exe` with these pinned +transport/HTTPS archives and the matching management executable. Its artifact +includes `SwanServerSetup-SOURCE.json` and a checksum. The setup builder checks +embedded resource hashes and retains component licenses/source notices. These +outputs remain test packages: production signing and clean-machine setup +acceptance are required before publication. diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index 49984ac9596..3d1ecdecb14 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -1736,3 +1736,17 @@ Native CI run `37095431510` is confirmed in progress for exact source `0a541c78609e8e3438e3c577abb352d714812b18`, covering implementation changes after the earlier verified native build. Its outputs remain unverified until successful completion and artifact checks. + +The Windows workflow now includes pinned transport/HTTPS archive preparation, +unsigned company-server setup compilation, source-manifest publication and its +SHA-256 checksum alongside endpoint installers. YAML parsing and both upload +entries passed local validation. The setup builder was exercised under Windows +PowerShell 5 using verified `6bd54c2` native management and existing pinned +components: all eleven embedded resource hashes passed, and the unsigned +64,093,696-byte package SHA-256 is +`14c2c794de937a20b16dc52f37b6238968c3fcaa6c3378fa80b5287838a0cb93`. +Its source receipt records recipe revision `4071147` and the dirty working tree; +it is a local test package, not a corresponding-source production release. +The already-running `0a541c7` CI build predates this workflow addition; a subsequent +full run must verify the new server packaging step. No setup executable was run +on the host and no host service, firewall, DNS or trust setting was changed. From 6e548cbed1285e20907f1d64fa327502d2b33b11 Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Sat, 3 Oct 2026 06:18:00 +0200 Subject: [PATCH 184/652] Record native contact shortcut and logo synchronization --- docs/ACCEPTANCE.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index 3d1ecdecb14..3fac4588d49 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -1750,3 +1750,13 @@ it is a local test package, not a corresponding-source production release. The already-running `0a541c7` CI build predates this workflow addition; a subsequent full run must verify the new server packaging step. No setup executable was run on the host and no host service, firewall, DNS or trust setting was changed. + +Installed native `6bd54c2` technician then passed live signed synchronization of +support contacts, a validated HTTPS shortcut, logo SVG and consent text from the +isolated administrator profile API. The harness explicitly added omitted +optional JSON properties before publication. The client committed a higher +profile revision while preserving bootstrap ownership/trust, release sequence 8 +and consent. After the isolated management/proxy listeners stopped, offline +installed-identity verification accepted the signed cache without changing its +hash. Existing service statuses were checked unchanged. This is installed-agent +metadata synchronization evidence, not visual rendering or shortcut interaction. From b0ff587e4e98634dc2304f6d19006bc489241384 Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Sat, 3 Oct 2026 06:23:11 +0200 Subject: [PATCH 185/652] Align update release notes with verified Windows lab evidence --- docs/RELEASE_PAYLOAD.md | 26 ++++++++++++++++---------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/docs/RELEASE_PAYLOAD.md b/docs/RELEASE_PAYLOAD.md index 054f1b26071..bf4fcc3a8bc 100644 --- a/docs/RELEASE_PAYLOAD.md +++ b/docs/RELEASE_PAYLOAD.md @@ -32,7 +32,7 @@ executable identity. Initial installation, repair, update completion, installed metadata verification and recovery verify customer file hashes and reject unlisted DLLs or EXEs. Paths resolving outside the installed directory fail. The agent has its separate signed release hash and is checked during initial -installation; agent replacement during updates remains incomplete. +installation and replacement through the staged update helper. RustDesk's copied `RuntimeBroker_rustdesk.exe` is Windows-owned and cannot be listed in project metadata. Verification instead requires its bytes to match @@ -43,15 +43,17 @@ clean-machine testing. This is a development metadata change. Previously generated releases without the mandatory manifest are rejected; do not weaken validation to import them. -No production compatibility or signing approval is implied. Full real signed -payload installation, update replacement, rollback and native behavior remain -acceptance requirements. +No production compatibility or signing approval is implied. Private lab-signed +customer MSI installation and automatic replacement have passed on Server 2016. +Rollback, the remaining Windows compatibility matrix and native session behavior +remain acceptance requirements; see [ACCEPTANCE.md](ACCEPTANCE.md). Interrupted-update receipts also block new managed sessions after the updater process exits or releases its activity lock. Recovery rechecks Windows support and the installed agent hash and publisher as well as the endpoint payload. -An old or missing agent cannot be accepted as a completed release. Agent replacement is implemented through a staged signed helper; its real -Windows installation and full rollback behavior remain unverified. A release +An old or missing agent cannot be accepted as a completed release. Agent replacement +is implemented through a staged signed helper and has passed private lab-signed +customer updates on Server 2016. Full rollback behavior remains unverified. A release stays pending until its full installed identity is restored and verified. Automatic updates now stage both the installer and agent and verify their @@ -96,8 +98,11 @@ usable offline. Recovery rechecks the unchanged receipt and session exclusion before handoff. Permission and other staging I/O errors remain errors rather than being treated as missing files. Complete customer/MSI rollback and canonical-agent recovery remain incomplete. Graphical technician update coordination and this -staging recovery still require native end-to-end verification. Signed clean-machine tests -are required before claiming interruption or self-update acceptance. +staging recovery from missing or damaged files still require native end-to-end +verification. A Server 2016 lab test interrupted the helper before MSI execution; +the normal scheduled retry completed the selected signed customer MSI and agent +update, preserving identity and consent. This demonstrates forward recovery for +that interruption point, not power-loss recovery during MSI or technician updates. Recovery validation is shared by completion, retry and helper execution. Its regression tests reject wrong keys/editions, tampering, conflicting sequences @@ -107,8 +112,9 @@ persistence. Automatic retries use a durable five-minute attempt delay. Explicit checks. A failed helper requests restart of the matching customer task, provided its installed agent still has a valid signature; pending receipts continue to block sessions. Corrupt or missing canonical agents still require recovery from -a verified staged helper or explicit setup/repair. Actual scheduled-task and -power-loss behavior remain unverified. +a verified staged helper or explicit setup/repair. Scheduled forward retry and +post-upgrade reboot persistence have passed on Server 2016 with private lab trust. +Power-loss behavior remains unverified. New automatic updates require a previously recorded signed installation. Before writing their pending receipt, they verify the current installed release and From 06994df40fc554ef49dc7a36d308c52b9d8cddd4 Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Sat, 3 Oct 2026 06:25:43 +0200 Subject: [PATCH 186/652] Record packaged native server wizard preview verification --- docs/ACCEPTANCE.md | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index 3fac4588d49..1fb450a7308 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -1748,8 +1748,9 @@ components: all eleven embedded resource hashes passed, and the unsigned Its source receipt records recipe revision `4071147` and the dirty working tree; it is a local test package, not a corresponding-source production release. The already-running `0a541c7` CI build predates this workflow addition; a subsequent -full run must verify the new server packaging step. No setup executable was run -on the host and no host service, firewall, DNS or trust setting was changed. +full run must verify the new server packaging step. At that stage no setup +executable was run on the host; no host service, firewall, DNS or trust setting +was changed. Installed native `6bd54c2` technician then passed live signed synchronization of support contacts, a validated HTTPS shortcut, logo SVG and consent text from the @@ -1760,3 +1761,17 @@ and consent. After the isolated management/proxy listeners stopped, offline installed-identity verification accepted the signed cache without changing its hash. Existing service statuses were checked unchanged. This is installed-agent metadata synchronization evidence, not visual rendering or shortcut interaction. + +The same hash-verified unsigned server setup package subsequently passed its +explicit `--render-preview` path on the unelevated Windows 11 host. The packaged +launcher extracted its bundled native management/components, loaded its embedded +wizard and exited successfully. Visual inspection of the generated 836-by-779 +PNG confirmed Swan branding, the unsigned-test title, hostname and trust inputs, +bundled component paths and the preview-only status without clipped labels or +overlapping controls. The PNG SHA-256 is +`ca0e06e43bba6c841076015b667671895923cb252ba518f8c39ca42672883f53`. +The owned temporary payload directory was removed and no setup process remained. +Preview code disables installation and browser actions and does not show the +interactive form. This verifies packaged extraction and offscreen rendering, +not elevated installation, interactive setup, production signing or a supported +Windows compatibility result. No host service, firewall, DNS or trust was changed. From 29404217606a439647229ad34b36adb5008f43ac Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Sat, 3 Oct 2026 06:27:31 +0200 Subject: [PATCH 187/652] Verify native rejection of signed expired and future profiles --- .../windows/test-native-profile-time.py | 89 +++++++++++++++++++ docs/ACCEPTANCE.md | 13 +++ 2 files changed, 102 insertions(+) create mode 100644 deployment/windows/test-native-profile-time.py diff --git a/deployment/windows/test-native-profile-time.py b/deployment/windows/test-native-profile-time.py new file mode 100644 index 00000000000..7961c06fc04 --- /dev/null +++ b/deployment/windows/test-native-profile-time.py @@ -0,0 +1,89 @@ +"""Exercise native cached-profile time validation without network or installed state. + +Requires Python cryptography. The signing key is ephemeral and never serialized. +Use an independently hash-verified swan-agent executable and a fresh private output +directory. This harness does not install software or establish a remote session. +""" +import argparse +import base64 +import hashlib +import json +import os +from pathlib import Path +import subprocess +import time + +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("agent", type=Path) + parser.add_argument("expected_sha256") + parser.add_argument("output", type=Path) + args = parser.parse_args() + agent = args.agent.resolve(strict=True) + digest = hashlib.sha256(agent.read_bytes()).hexdigest() + if digest != args.expected_sha256.lower(): + raise SystemExit("Native agent hash mismatch") + output = args.output.resolve() + output.mkdir(parents=False, exist_ok=False) + key = Ed25519PrivateKey.generate() + public = base64.b64encode(key.public_key().public_bytes( + serialization.Encoding.Raw, serialization.PublicFormat.Raw)).decode() + bootstrap = dict(schema=1, edition="technician", company_id="time-validation-lab", + management_url="https://time-validation.example.invalid", + profile_public_key=public, release_public_key=public) + branding = dict(display_name="Time validation lab", primary_color="#007F82", + logo_svg="", support_url="", consent_text="Approve lab support.") + now = int(time.time()) + cases = [("valid", now - 60, now + 3600, True), + ("expired", now - 3600, now - 60, False), + ("future", now + 3600, now + 7200, False), + ("invalid_lifetime", now - 60, now - 120, False)] + results = [] + for name, issued, expires, accepted in cases: + directory = output / name + directory.mkdir() + profile = dict(schema=1, company_id=bootstrap["company_id"], revision=1, + issued_at=issued, expires_at=expires, + management_url=bootstrap["management_url"], + rendezvous="transport.example.invalid", relay="transport.example.invalid", + transport_public_key=public, customer=branding, technician=branding, + allow_unattended=False, updates_paused=True, rollout_percent=0, + maintenance_start_utc=0, maintenance_end_utc=23, + update_channel="test", next_profile_public_key=None) + payload = json.dumps(profile, separators=(",", ":")).encode() + signature = key.sign(payload) + key.public_key().verify(signature, payload) + state = dict(bootstrap=bootstrap, profile=dict( + payload=base64.b64encode(payload).decode(), + signature=base64.b64encode(signature).decode()), accepted_revision=1, + device_id=None, device_token=None, unattended_consent=False, + consent_revision=0, last_release_sequence=0) + state_path = directory / "managed-state.json" + state_path.write_text(json.dumps(state), encoding="utf-8") + bootstrap_path = directory / "bootstrap.json" + bootstrap_path.write_text(json.dumps(bootstrap), encoding="utf-8") + before = state_path.read_bytes() + env = os.environ.copy() + env["SWAN_STATE_DIR"] = str(directory) + completed = subprocess.run([str(agent), "verify-bootstrap", str(bootstrap_path)], + env=env, capture_output=True, text=True, timeout=30) + if (completed.returncode == 0) != accepted: + raise RuntimeError(f"Unexpected native validation outcome: {name}") + if not accepted and "Expired or future profile" not in completed.stderr: + raise RuntimeError(f"Rejection did not exercise profile time validation: {name}") + if state_path.read_bytes() != before: + raise RuntimeError(f"Native verification changed fixture state: {name}") + results.append(dict(case=name, accepted=accepted, state_unchanged=True, + signature_independently_verified=True)) + result = dict(agent_sha256=digest, cases=results, + scope="Native cached-profile validation; synthetic company; no network or installation") + (output / "result.json").write_text(json.dumps(result, indent=2), encoding="utf-8") + print(json.dumps(result)) + + +if __name__ == "__main__": + main() diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index 1fb450a7308..12e84e4dd76 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -1775,3 +1775,16 @@ Preview code disables installation and browser actions and does not show the interactive form. This verifies packaged extraction and offscreen rendering, not elevated installation, interactive setup, production signing or a supported Windows compatibility result. No host service, firewall, DNS or trust was changed. + +The hash-verified native `6bd54c2` configuration agent also passed +`deployment/windows/test-native-profile-time.py` on the Windows 11 host using +four isolated synthetic-company cached profiles. An ephemeral Ed25519 key signed +each profile and Python independently verified each signature before invoking +the actual native `verify-bootstrap` command. The valid control passed; expired, +future-issued and invalid-lifetime profiles were rejected with the profile-time +validation error, and all fixture state files remained byte-identical. Agent +SHA-256 was +`870ead5b2307c289d3e94542b53756488ea0ae6e2a558ed4131e9e49e6eaa9ee`. +The key was never serialized, the synthetic HTTPS address was never contacted, +and installed company state was not used. This proves native cached-profile time +rejection, not live expiry during a remote session or refresh of an expired cache. From 9c6bdbb9e2923d0b6dae50076a99670ee76013e6 Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Sat, 3 Oct 2026 06:36:59 +0200 Subject: [PATCH 188/652] Record successful native 0a541c7 Windows build --- docs/ACCEPTANCE.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index 12e84e4dd76..937ab49f85c 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -1732,10 +1732,12 @@ succeeded with the new endpoint persisted. Existing service statuses were checked unchanged. This verifies HTTPS URL/port migration, not DNS hostname migration, transport endpoint migration or a native session. -Native CI run `37095431510` is confirmed in progress for exact source +Native CI run `37095431510` completed successfully for exact source `0a541c78609e8e3438e3c577abb352d714812b18`, covering implementation changes after -the earlier verified native build. Its outputs remain unverified until successful -completion and artifact checks. +the earlier verified native build. All four jobs, including the branded Windows +x64 build, passed. Its outputs remain unverified until download, provenance and +artifact checks complete; build success alone does not establish native runtime +acceptance. The Windows workflow now includes pinned transport/HTTPS archive preparation, unsigned company-server setup compilation, source-manifest publication and its From fc577e9824faa14b877cab15d40ca8c9afb4f929 Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Sat, 3 Oct 2026 06:41:45 +0200 Subject: [PATCH 189/652] Record signed native prepared technician rollback state --- docs/ACCEPTANCE.md | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index 937ab49f85c..73625c1ab0b 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -1790,3 +1790,24 @@ SHA-256 was The key was never serialized, the synthetic HTTPS address was never contacted, and installed company state was not used. This proves native cached-profile time rejection, not live expiry during a remote session or refresh of an expired cache. + +Native `6bd54c2` portable technician recovery was exercised in a separate +standard-user-owned directory on Server 2016 using private lab-signed binaries. +The harness cloned the sequence-8 installation, prepared verified signed +rollback snapshots and a synthetic sequence-9 `rolling_back` receipt, then +damaged only its disposable canonical endpoint and agent. The signed staged +helper restored both exact binaries, removed the pending receipt and recorded +`failed-update.json` with phase `rolled_back`, failed sequence 9 and previous +sequence 8. An independent read-only verifier confirmed both restored hashes, +byte-identical company state and installed metadata against the source +installation, retained sequence 8, unchanged empty technician device fields and +disabled unattended consent. The native `verify-installed` command accepted the +restored installation and its pinned publisher. + +The original SSH harness has not returned a terminal result; its post-recovery +app restart and cleanup command remain unresolved. Therefore this is verified +post-recovery state for a prepared rollback, not a complete automatic-update +failure/rollback test, replay-rejection test, GUI acceptance, MSI recovery or +power-loss evidence. The synthetic receipt SHA-256 is +`f3e7059dfd47309c406b2bbe0e988ee697bbc8cd8d8ed92bd3ba8aab1b2ce126`; +fixture files and signing material remain outside Git. From 812d68c8cb3b672ff8f35fe02ec57ef6397592d3 Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Sat, 3 Oct 2026 06:43:26 +0200 Subject: [PATCH 190/652] Record native failed-release quarantine verification --- docs/ACCEPTANCE.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index 73625c1ab0b..a4de6c8cfd2 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -1811,3 +1811,14 @@ failure/rollback test, replay-rejection test, GUI acceptance, MSI recovery or power-loss evidence. The synthetic receipt SHA-256 is `f3e7059dfd47309c406b2bbe0e988ee697bbc8cd8d8ed92bd3ba8aab1b2ce126`; fixture files and signing material remain outside Git. + +The restored Server 2016 technician fixture subsequently passed native +`verify-package` quarantine checks. Correctly signed metadata for failed sequence +9 was rejected specifically as quarantined; correctly signed sequence-10 +metadata for the same trusted lab package passed signature, hash and publisher +verification as a positive control. Company state, installed metadata and the +quarantine receipt remained byte-identical before/after both commands. No +installer was executed and no sequence advanced. This proves native explicit +package verification enforces the recorded failed-release watermark; automatic +discovery, rollout of a later release and the original harness terminal result +remain unverified. From d95da93415778efbbf231c40cf03b4da98e89440 Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Sat, 3 Oct 2026 06:45:37 +0200 Subject: [PATCH 191/652] Record native rejection of damaged signed package bytes --- docs/ACCEPTANCE.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index a4de6c8cfd2..4588ef77cc4 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -1822,3 +1822,12 @@ installer was executed and no sequence advanced. This proves native explicit package verification enforces the recorded failed-release watermark; automatic discovery, rollout of a later release and the original harness terminal result remain unverified. + +Using the same sequence-10 metadata and restored native agent, a Server 2016 +negative package check flipped one byte at offset 4096 in a disposable copy of +the real lab-signed technician EXE. Native `verify-package` rejected the damaged +copy with a hash-validation error. The preceding intact-package positive control +had passed. The original package, company state, installed metadata and rollback +quarantine stayed unchanged, and no installer was executed. This verifies native +package-byte tamper rejection; it does not establish interrupted installation or +automatic-update acceptance. From b67afbeb45231636c1d9994935719baa90925492 Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Sat, 3 Oct 2026 06:52:31 +0200 Subject: [PATCH 192/652] Clarify implemented rollback paths and remaining native verification --- docs/RELEASE_PAYLOAD.md | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/docs/RELEASE_PAYLOAD.md b/docs/RELEASE_PAYLOAD.md index bf4fcc3a8bc..fa839d9aa10 100644 --- a/docs/RELEASE_PAYLOAD.md +++ b/docs/RELEASE_PAYLOAD.md @@ -159,9 +159,14 @@ metadata, wrong trust or edition, unknown phases/protocols and I/O errors fail closed. Quarantine persists through subsequent successful installations. No technician login token is passed to or restored by rollback. -Windows service/MSI registration rollback and full power-loss acceptance remain -unfinished. The portable restoration path is implemented but not yet demonstrated -with signed installed binaries on clean Windows. Component tests cover complete file +Windows service/MSI registration restoration is implemented through the narrowly +validated `Restore-ReleaseMsi.ps1` path, but signed native rollback and full +power-loss acceptance remain unverified. Prepared portable technician rollback +has restored lab-signed endpoint and agent binaries on Server 2016; native +installed-identity and quarantine checks passed independently. The original +SSH harness and app restart remain unresolved, and automatic failure selection +has not been demonstrated. See [ACCEPTANCE.md](ACCEPTANCE.md) for the scope. +Component tests cover complete file preservation, rejection of tampered/missing sources and unsafe paths, incomplete snapshot cleanup, immutable published snapshots, and previous metadata trust and sequence checks. Quarantine/receipt regressions cover replay suppression, eligibility @@ -182,7 +187,8 @@ does not authorize a different package. A missing, corrupt or unverifiable old artifact defers the update before installation; keep corresponding release files available on company-controlled storage. Retained packages are software artifacts, not copies of mutable enrollment or consent. MSI/service restoration still needs -implementation and signed native tests. +signed native end-to-end tests, including registration, service/task consistency +and interruptions during restoration. The agent and Windows worker launch Windows PowerShell from the OS-resolved system directory and restrict module discovery to its built-in modules. This From 56c6740b375503dc7741b28fed2192ffc21598a1 Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Sat, 3 Oct 2026 06:57:23 +0200 Subject: [PATCH 193/652] Verify native 0a541c7 artifacts and worker revocation --- docs/ACCEPTANCE.md | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index 4588ef77cc4..a3a3aaad8e0 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -1737,7 +1737,7 @@ Native CI run `37095431510` completed successfully for exact source the earlier verified native build. All four jobs, including the branded Windows x64 build, passed. Its outputs remain unverified until download, provenance and artifact checks complete; build success alone does not establish native runtime -acceptance. +acceptance. Subsequent verification and runtime evidence is recorded below. The Windows workflow now includes pinned transport/HTTPS archive preparation, unsigned company-server setup compilation, source-manifest publication and its @@ -1831,3 +1831,20 @@ had passed. The original package, company state, installed metadata and rollback quarantine stayed unchanged, and no installer was executed. This verifies native package-byte tamper rejection; it does not establish interrupted installation or automatic-update acceptance. + +Native run `37095431510` artifacts for exact source +`0a541c78609e8e3438e3c577abb352d714812b18` subsequently passed authenticated +GitHub provenance checks, the authoritative archive SHA-256 +`1e77ecd393173b0a10cababc01b39d0d28bc177c5fbe8a5406bfc1148b17229b`, +bounded flat-file extraction, ZIP CRC checks and published payload checksums. +Twenty files were verified outside Git. These remain unsigned build artifacts. + +The verified native MSVC management binary (SHA-256 +`9046b27c9e7b6afdb581ee84a2c4d8a3c1128b34a138daf354cbd3603c2ab3e6`) +then passed a fresh isolated Server 2016 company setup and MFA login, worker +creation and enabled claim, administrator-only credential-free worker listing, +revocation, subsequent claim/upload authentication denial, persisted disabled +status and revocation auditing. The harness used guest-loopback HTTP and stopped +only its owned management process; existing service statuses stayed unchanged. +This verifies compiled native revocation behavior, not HTTPS/public transport, +signed distribution, job cancellation during active upload or a real session. From 8e33b0998b76a7e7284251fa5461e334424ef056 Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Sat, 3 Oct 2026 06:59:20 +0200 Subject: [PATCH 194/652] Record lab signing of verified native 0a541c7 components --- docs/ACCEPTANCE.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index a3a3aaad8e0..17a70f00ad3 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -1848,3 +1848,16 @@ status and revocation auditing. The harness used guest-loopback HTTP and stopped only its owned management process; existing service statuses stayed unchanged. This verifies compiled native revocation behavior, not HTTPS/public transport, signed distribution, job cancellation during active upload or a real session. + +The verified `0a541c7` native management, agent and worker inputs were then signed +inside the Server 2016 guest using the existing non-exportable private lab +certificate and pinned company-signing recipe. Every original input hash was +rechecked, Authenticode status and certificate identity passed, and +`signtool verify /pa /all /tw` confirmed RFC3161 timestamps without warnings. +Signed hashes are management +`bf52a32557e96990616de28380c386b4eae7ece0b523f9677685e8f6c359c8bc`, +agent `1226def2eee9ade11d4fabd5b49321de5de4744973999fd517c85645086f0ca0` +and worker `6610f507d02ab4f02dc1080142f113f0c2aab7ec0b10564573216dcb861cad51`. +The key was not exported; host trust and installed service binaries were not +changed. These are lab fixtures, not production-signed releases or evidence that +the new worker has completed a service-mode build. From 86f47c27c96497b0ec9db59b51dd758925fb38ae Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Sat, 3 Oct 2026 07:02:10 +0200 Subject: [PATCH 195/652] Verify current native server and worker bundle generation --- docs/ACCEPTANCE.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index 17a70f00ad3..73943e53862 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -1861,3 +1861,19 @@ and worker `6610f507d02ab4f02dc1080142f113f0c2aab7ec0b10564573216dcb861cad51`. The key was not exported; host trust and installed service binaries were not changed. These are lab fixtures, not production-signed releases or evidence that the new worker has completed a service-mode build. + +Matching lab-signed `0a541c7` native management and foreground worker then passed +real customer and technician bundle generation over the isolated guest HTTPS +endpoint. The administrator imported/approved earlier signed lab payloads from +`4768d53` and `6840d91` and queued both jobs. Customer job +`2d2fd057-850f-4d56-9340-8d51644d3cf1` completed with bundle SHA-256 +`fd374569c890dc48aedcabd37d44f1b70f64e5d3b197e84e5d9b5db71b71522e`; +technician job `48303891-6097-4dbc-b09a-3e2b6cedbab1` completed with +`b7bbd3a197c2aba4c18e61645d9497bddc711f5009070667f478cf44d2cee648`. +Both downloaded hashes, ten-entry bundle recipes and company HTTPS URL/profile +pin/edition bootstraps matched. Customer download was public; technician download +required authentication and the unauthenticated request returned 401. Owned test +processes were stopped and existing service statuses were preserved. No bundle +was installed. This verifies current native server/worker generation using earlier +payloads, not newest-client installation, worker service-mode completion, public +Internet connectivity or production signing. From 3e9d0deac1875e87d7253799876153f454ebc379 Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Sat, 3 Oct 2026 07:05:29 +0200 Subject: [PATCH 196/652] Verify matching current native technician distribution bundle --- docs/ACCEPTANCE.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index 73943e53862..26b909b356c 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -1877,3 +1877,16 @@ processes were stopped and existing service statuses were preserved. No bundle was installed. This verifies current native server/worker generation using earlier payloads, not newest-client installation, worker service-mode completion, public Internet connectivity or production signing. + +Matching `0a541c7` technician distribution then passed using the newly lab-signed +technician EXE and matching agent, signed release metadata sequence 11, and the +same native management/worker pair. Technician EXE SHA-256 is +`fe29807746ec9f406031f10f5872083b13e1374a7fa2f35b0d077a8221fb8722`; +its signature, pinned lab certificate and RFC3161 timestamp were verified in the +guest. Job `456c3b0a-dfa4-48ad-8cf7-1ae24024abef` produced bundle +`64d7b7b3119ec794b08d1cee8e205f2ed81b687062b1490c19883a8caedec019`. +Authenticated download, hash, ten-entry recipe and HTTPS/profile-pin/edition +bootstrap checks passed; unauthenticated download returned 401. This package +contains matching current native payloads, but was not installed in this test. +Production signing, service-mode completion and rendered technician/session +behavior remain open. From e473adb681c44d8d2f88fa6665cdbec8786c7031 Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Sat, 3 Oct 2026 07:07:40 +0200 Subject: [PATCH 197/652] Verify newest technician bundle standard-user installation --- docs/ACCEPTANCE.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index 26b909b356c..e9c8fc2cd77 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -1890,3 +1890,15 @@ bootstrap checks passed; unauthenticated download returned 401. This package contains matching current native payloads, but was not installed in this test. Production signing, service-mode completion and rendered technician/session behavior remain open. + +The matching `0a541c7` technician bundle subsequently passed its actual +`Install-Company.ps1` setup under the non-administrator `swanlabtech` account on +Server 2016. Separate fresh application-data directories preserved the earlier +installation. Installed EXE and agent hashes matched the approved signed inputs, +native installed-identity verification and HTTPS signed configuration sync passed, +sequence 11 was recorded and the pending-install marker cleared. No customer +device identity, enrollment token or unattended consent was created. The existing +Start-menu shortcut was restored, owned management/proxy processes stopped, and +existing service statuses remained unchanged. This is real portable technician +installation evidence; rendered login, authorized inventory, native sessions, +MSI installation and the remaining Windows matrix are still unverified. From 92c443241314317021342cf5d8a6634b0e14bf74 Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Sat, 3 Oct 2026 07:09:06 +0200 Subject: [PATCH 198/652] Verify current installed technician authentication and role checks --- docs/ACCEPTANCE.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index e9c8fc2cd77..86f5aa78826 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -1902,3 +1902,13 @@ Start-menu shortcut was restored, owned management/proxy processes stopped, and existing service statuses remained unchanged. This is real portable technician installation evidence; rendered login, authorized inventory, native sessions, MSI installation and the remaining Windows matrix are still unverified. + +Installed `0a541c7` technician CLI authentication then passed under the same +standard user against the matching native HTTPS management fixture. A new local +technician account completed MFA login; inventory/history requests succeeded +with empty results, replay of the consumed MFA code was denied, and worker +administration privileges were denied. Logout invalidated subsequent inventory +access. Managed identity stayed unchanged and no login token was stored in managed +state. Temporary account credentials were removed by the harness. This verifies +installed-agent authentication and role enforcement, not rendered login, populated +authorized inventory or a native remote session. From 28f8a9b211252dedb2c7061c12aba743efef58f1 Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Sat, 3 Oct 2026 07:12:37 +0200 Subject: [PATCH 199/652] Verify current technician offline identity and record UI prerequisite --- docs/ACCEPTANCE.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index 86f5aa78826..bbc6d80263e 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -1912,3 +1912,16 @@ access. Managed identity stayed unchanged and no login token was stored in manag state. Temporary account credentials were removed by the harness. This verifies installed-agent authentication and role enforcement, not rendered login, populated authorized inventory or a native remote session. + +The installed `0a541c7` technician also passed standard-user offline +`verify-installed`: a loopback TCP probe confirmed the isolated company endpoint +refused connections, the native agent accepted its cached profile and signed +installed release/publisher, and managed state stayed byte-identical. The first +harness attempt used an administrator-only CIM query and failed before native +verification; replacing it with the unprivileged connection-refused probe +preserved the offline precondition. This does not authorize offline remote access. + +The VM viewer became targetable again after refreshing and activating its +returned window. Its screenshot showed the disposable Server 2016 desktop is +locked. Graphical tests await manual unlock under the computer-use skill; no +authentication dialog or security setting was automated. From 79c75ade3145e6c4ee00bba7227516c7bd55398a Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Sat, 3 Oct 2026 07:15:13 +0200 Subject: [PATCH 200/652] Verify damaged technician executable repair preserves identity --- docs/ACCEPTANCE.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index bbc6d80263e..b045c8b9a98 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -1925,3 +1925,12 @@ The VM viewer became targetable again after refreshing and activating its returned window. Its screenshot showed the disposable Server 2016 desktop is locked. Graphical tests await manual unlock under the computer-use skill; no authentication dialog or security setting was automated. + +Standard-user portable repair passed for the installed `0a541c7` technician on +Server 2016. After preserving a hash-verified backup, the harness replaced only +the disposable installed EXE with four invalid bytes and ran the actual bundle +`Install-Company.ps1 -Repair`. The signed executable was restored to its exact +expected hash, native installed-identity verification passed, managed state stayed +byte-identical at sequence 11, and the pending-install marker cleared. The prior +Start-menu shortcut was restored. This proves damaged portable EXE repair, not +MSI repair, agent-loss repair or remote-session behavior. From ef4720a6557ce7349ab5d91d3f189da605151e34 Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Sat, 3 Oct 2026 07:17:33 +0200 Subject: [PATCH 201/652] Verify portable bundle repairs a corrupt configuration agent --- docs/ACCEPTANCE.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index b045c8b9a98..7695cd6f76d 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -1934,3 +1934,13 @@ expected hash, native installed-identity verification passed, managed state stay byte-identical at sequence 11, and the pending-install marker cleared. The prior Start-menu shortcut was restored. This proves damaged portable EXE repair, not MSI repair, agent-loss repair or remote-session behavior. + +A separate standard-user test then damaged only the installed `0a541c7` +configuration agent with four invalid bytes and ran the same trusted portable +bundle repair. The bundle's staged signed agent completed repair, native +installed-identity verification passed, managed state stayed byte-identical at +sequence 11 and the pending-install marker cleared. Protected fixture backups +and the previous Start-menu shortcut were retained/restored. This establishes +explicit portable-bundle repair of a corrupt agent, not automatic recovery from +a missing canonical agent or MSI/service repair. Portable technician distribution +still lacks a dedicated uninstall entry point; this remains distribution work. From e9cda62c06e483ea0af0cf3a834f76f5af59c78b Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Sat, 3 Oct 2026 07:22:00 +0200 Subject: [PATCH 202/652] Add verified portable technician uninstall entry point --- .github/workflows/swan-windows-build.yml | 3 +- deployment/windows/Install-Company.ps1 | 9 ++++ deployment/windows/Uninstall-Technician.ps1 | 48 +++++++++++++++++++++ docs/RELEASE_PAYLOAD.md | 9 ++++ product/management/src/service.rs | 10 +++-- product/protocol/src/lib.rs | 3 +- 6 files changed, 77 insertions(+), 5 deletions(-) create mode 100644 deployment/windows/Uninstall-Technician.ps1 diff --git a/.github/workflows/swan-windows-build.yml b/.github/workflows/swan-windows-build.yml index edb1478224d..aa34c5f53ac 100644 --- a/.github/workflows/swan-windows-build.yml +++ b/.github/workflows/swan-windows-build.yml @@ -184,7 +184,7 @@ jobs: run: | 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/Verify-Package.ps1,deployment/windows/Get-WindowsCompatibility.ps1 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 artifacts/ Copy-Item LICENCE artifacts/ @@ -241,6 +241,7 @@ jobs: artifacts/Install-Worker.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 diff --git a/deployment/windows/Install-Company.ps1 b/deployment/windows/Install-Company.ps1 index 87f4ad128ac..1fd23d9fefc 100644 --- a/deployment/windows/Install-Company.ps1 +++ b/deployment/windows/Install-Company.ps1 @@ -136,5 +136,14 @@ if ($bootstrap.edition -eq 'customer') { $shortcut.WorkingDirectory = $editionDirectory $shortcut.IconLocation = $portable $shortcut.Save() + if ($release.format -eq 'exe') { + Copy-Item -LiteralPath (Join-Path $root 'Uninstall-Technician.ps1') -Destination (Join-Path $editionDirectory 'Uninstall-Technician.ps1') -Force + $uninstallShortcut=$shell.CreateShortcut((Join-Path ([Environment]::GetFolderPath('Programs')) 'Uninstall Swan Remote Support Technician.lnk')) + $uninstallShortcut.TargetPath=Join-Path $env:SystemRoot 'System32/WindowsPowerShell/v1.0/powershell.exe' + $uninstallShortcut.Arguments='-NoLogo -NoProfile -ExecutionPolicy Bypass -File "'+(Join-Path $editionDirectory 'Uninstall-Technician.ps1')+'"' + $uninstallShortcut.WorkingDirectory=$editionDirectory + $uninstallShortcut.IconLocation=$portable + $uninstallShortcut.Save() + } Write-Host 'Technician application installed. Use the Start menu shortcut to sign in and request support sessions.' } diff --git a/deployment/windows/Uninstall-Technician.ps1 b/deployment/windows/Uninstall-Technician.ps1 new file mode 100644 index 00000000000..3ee9dc4f3ef --- /dev/null +++ b/deployment/windows/Uninstall-Technician.ps1 @@ -0,0 +1,48 @@ +[CmdletBinding(SupportsShouldProcess)] +param([switch]$Confirmed) +$ErrorActionPreference='Stop' +$directory=[IO.Path]::GetFullPath((Join-Path $env:LOCALAPPDATA 'SwanRemoteSupport-Technician')) +if([IO.Path]::GetFullPath($PSScriptRoot) -ine $directory){throw 'Run the installed technician uninstaller.'} +foreach($path in @($env:LOCALAPPDATA,$directory)){ + $item=Get-Item -LiteralPath $path -Force + if(-not $item.PSIsContainer -or ($item.Attributes -band [IO.FileAttributes]::ReparsePoint)){throw 'Technician installation directory is unsafe.'} +} +$metadata=Get-Content -LiteralPath (Join-Path $directory 'installed-release.json') -Raw|ConvertFrom-Json +$release=[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($metadata.payload))|ConvertFrom-Json +if($release.edition -cne 'technician' -or $release.format -cne 'exe'){throw 'Use Windows installed-app removal for MSI installations.'} +$agent=Join-Path $directory 'swan-agent.exe' +if((Get-AuthenticodeSignature -LiteralPath $agent).Status -ne 'Valid'){throw 'Repair the configuration agent before uninstalling.'} +$env:SWAN_STATE_DIR=$directory +& $agent verify-installed +if($LASTEXITCODE -ne 0){throw 'Repair the installed identity before uninstalling.'} +if(-not $Confirmed -and -not $WhatIfPreference){ + Add-Type -AssemblyName System.Windows.Forms + if([Windows.Forms.MessageBox]::Show('Remove the technician application? Close it first. Company configuration is retained for reinstall.','Remove technician application','YesNo','Question') -ne 'Yes'){return} +} +if(-not $PSCmdlet.ShouldProcess($directory,'Remove portable technician application and retain company state')){return} +$lock=[IO.File]::Open((Join-Path $directory 'activity.lock'),[IO.FileMode]::OpenOrCreate,[IO.FileAccess]::ReadWrite,[IO.FileShare]::ReadWrite) +$locked=$false +try{ + $lock.Lock(0,1);$locked=$true + & $agent cancel-update-recovery + if($LASTEXITCODE -ne 0){throw 'Update recovery cancellation failed; no application files were removed.'} + # Fixed regular files only: never recurse into company state, releases or + # extracted runtimes shared with another installation. + foreach($name in @('SwanRemoteSupport-Technician.exe','Open-Technician.ps1','swan-agent.exe')){ + $path=Join-Path $directory $name + if(Test-Path -LiteralPath $path){ + $item=Get-Item -LiteralPath $path -Force + if($item.PSIsContainer -or ($item.Attributes -band [IO.FileAttributes]::ReparsePoint)){throw 'Unexpected application file; removal remains pending.'} + Remove-Item -LiteralPath $path -Force + } + } + foreach($name in @('Swan Remote Support Technician.lnk','Uninstall Swan Remote Support Technician.lnk')){ + $path=Join-Path ([Environment]::GetFolderPath('Programs')) $name + if(Test-Path -LiteralPath $path){ + $shell=New-Object -ComObject WScript.Shell + $shortcut=$shell.CreateShortcut($path) + if($shortcut.Arguments.Contains('"'+(Join-Path $directory 'Open-Technician.ps1')+'"') -or $shortcut.Arguments.Contains('"'+(Join-Path $directory 'Uninstall-Technician.ps1')+'"')){Remove-Item -LiteralPath $path} + } + } + Write-Output 'Technician application removed. Company state is retained and automatic recovery is cancelled.' +}finally{if($locked){$lock.Unlock(0,1)};$lock.Dispose()} diff --git a/docs/RELEASE_PAYLOAD.md b/docs/RELEASE_PAYLOAD.md index fa839d9aa10..f2f7cffa91b 100644 --- a/docs/RELEASE_PAYLOAD.md +++ b/docs/RELEASE_PAYLOAD.md @@ -1,5 +1,14 @@ # Signed installed payload identity +Portable technician bundles now include `Uninstall-Technician.ps1` and install a +dedicated Start-menu removal shortcut. The uninstaller verifies installed identity, +acquires session exclusion, cancels automatic recovery and removes fixed application +files while retaining company state for reinstall. MSI installations use Windows +installed-app removal. The bundle recipe now contains eleven entries; rebuild +management and workers together and regenerate packages. Compiled exact-recipe +tests reject altered uninstall scripts. Native uninstall/reinstall acceptance is +still required before publication. + Release metadata now requires `installed_files`, an array of objects containing relative `path` and SHA-256 `sha256`. Customer manifests cover the final Flutter payload: the installed Swan executable, native libraries and application assets. diff --git a/product/management/src/service.rs b/product/management/src/service.rs index a8b439f5ff1..670b2f5a193 100644 --- a/product/management/src/service.rs +++ b/product/management/src/service.rs @@ -691,11 +691,14 @@ mod worker_completion_tests { ("release.json".into(),serde_json::to_vec_pretty(&signed_release).unwrap()), ]; for (name,bytes) in COMPANY_BUNDLE_TEXT_FILES {entries.push((name.into(),bundle_text(bytes).unwrap()));} - assert_eq!(entries.len(),10); + let recipe_count=5+COMPANY_BUNDLE_TEXT_FILES.len(); + assert_eq!(entries.len(),recipe_count); let verify=|bytes:&[u8]|validate_worker_bundle(bytes,&job,&release,&company.verifying_key(),&trust).is_ok(); let valid=archive(&entries);assert!(verify(&valid),"Complete {edition:?}/{format} bundle was rejected"); let helper=entries.iter().position(|(name,_)|name=="Get-MsiInstallMode.ps1").unwrap(); let mut altered=entries.clone();altered[helper].1=b"altered script".to_vec();assert!(!verify(&archive(&altered))); + let uninstall=entries.iter().position(|(name,_)|name=="Uninstall-Technician.ps1").unwrap(); + let mut altered=entries.clone();altered[uninstall].1=b"altered uninstall script".to_vec();assert!(!verify(&archive(&altered))); let mut missing=entries.clone();missing.remove(helper);assert!(!verify(&archive(&missing))); let mut extra=entries.clone();extra.push(("extra.dll".into(),b"unexpected".to_vec()));assert!(!verify(&archive(&extra))); let mut renamed=entries.clone();renamed[helper].0="Get-MsiInstallM0de.ps1".into();assert!(!verify(&archive(&renamed))); @@ -724,9 +727,10 @@ mod worker_completion_tests { let mut duplicate=entries.clone();duplicate.push((String::from_utf8(alias.to_vec()).unwrap(),entries[helper].1.clone())); let mut duplicate=archive(&duplicate); for offset in 0..=duplicate.len()-alias.len() {if &duplicate[offset..offset+alias.len()]==alias {duplicate[offset..offset+alias.len()].copy_from_slice(canonical);}} - assert_eq!(zip::ZipArchive::new(std::io::Cursor::new(&duplicate)).unwrap().len(),10,"Regression must exercise hidden physical duplicates"); + assert_eq!(zip::ZipArchive::new(std::io::Cursor::new(&duplicate)).unwrap().len(),recipe_count,"Regression must exercise hidden physical duplicates"); assert!(!verify(&duplicate)); - let footer=duplicate.len()-22;duplicate[footer+8..footer+12].copy_from_slice(&[10,0,10,0]); + let footer=duplicate.len()-22;let count=(recipe_count as u16).to_le_bytes(); + duplicate[footer+8..footer+10].copy_from_slice(&count);duplicate[footer+10..footer+12].copy_from_slice(&count); assert!(!verify(&duplicate),"Forged entry counts cannot hide duplicate records"); let mut expired=profile.clone();expired.expires_at=now();let expired=SignedEnvelope::sign(&expired,&company).unwrap(); let mut expired_entries=entries.clone();expired_entries[3].1=serde_json::to_vec_pretty(&expired).unwrap(); diff --git a/product/protocol/src/lib.rs b/product/protocol/src/lib.rs index 9819c73eee8..2b1dd5a79f3 100644 --- a/product/protocol/src/lib.rs +++ b/product/protocol/src/lib.rs @@ -10,10 +10,11 @@ pub const SCHEMA: u32 = 1; pub const PRODUCT: &str = "Swan Remote Support"; pub const WIRE_PREFIX: &[u8] = b"SWAN1:"; // Worker output and server upload validation must use the same trusted recipe. -pub const COMPANY_BUNDLE_TEXT_FILES:[(&str,&[u8]);5]=[ +pub const COMPANY_BUNDLE_TEXT_FILES:[(&str,&[u8]);6]=[ ("Install-Company.ps1",include_bytes!("../../../deployment/windows/Install-Company.ps1")), ("Get-MsiInstallMode.ps1",include_bytes!("../../../deployment/windows/Get-MsiInstallMode.ps1")), ("Open-Technician.ps1",include_bytes!("../../../deployment/windows/Open-Technician.ps1")), + ("Uninstall-Technician.ps1",include_bytes!("../../../deployment/windows/Uninstall-Technician.ps1")), ("Verify-Package.ps1",include_bytes!("../../../deployment/windows/Verify-Package.ps1")), ("LICENSE.txt",include_bytes!("../../../LICENCE")), ]; From b4eec2a2c73143d96b6705500a287c41a483fde9 Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Sat, 3 Oct 2026 07:29:46 +0200 Subject: [PATCH 203/652] Record native portable technician uninstall and reinstall evidence --- docs/ACCEPTANCE.md | 14 ++++++++++++-- docs/RELEASE_PAYLOAD.md | 6 ++++-- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index 7695cd6f76d..27961dda708 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -1942,5 +1942,15 @@ installed-identity verification passed, managed state stayed byte-identical at sequence 11 and the pending-install marker cleared. Protected fixture backups and the previous Start-menu shortcut were retained/restored. This establishes explicit portable-bundle repair of a corrupt agent, not automatic recovery from -a missing canonical agent or MSI/service repair. Portable technician distribution -still lacks a dedicated uninstall entry point; this remains distribution work. +a missing canonical agent or MSI/service repair. + +The portable technician uninstaller added in `e9cda62` passed as a standard user +on the disposable Server 2016 machine using the existing signed `0a541c7` +installation. `-WhatIf` left files and recovery state unchanged. Confirmed removal +cancelled update recovery and removed the fixed application files while retaining +company state. Reinstall through the trusted existing bundle restored the payload, +passed native installed-identity verification, cleared the uninstall marker and +preserved managed state byte-for-byte. This verifies the new source script with +an older ten-entry bundle; generation and installation of the new eleven-entry +recipe, graphical uninstall confirmation, MSI removal and complete cleanup remain +unverified. The matching native build for source `e9cda62` has been dispatched. diff --git a/docs/RELEASE_PAYLOAD.md b/docs/RELEASE_PAYLOAD.md index f2f7cffa91b..9a5974f1b98 100644 --- a/docs/RELEASE_PAYLOAD.md +++ b/docs/RELEASE_PAYLOAD.md @@ -6,8 +6,10 @@ acquires session exclusion, cancels automatic recovery and removes fixed applica files while retaining company state for reinstall. MSI installations use Windows installed-app removal. The bundle recipe now contains eleven entries; rebuild management and workers together and regenerate packages. Compiled exact-recipe -tests reject altered uninstall scripts. Native uninstall/reinstall acceptance is -still required before publication. +tests reject altered uninstall scripts. The new source uninstaller passed a +standard-user Server 2016 dry run, removal and trusted-bundle reinstall using the +signed `0a541c7` payload: company state remained byte-identical and recovery was +cancelled. A freshly generated eleven-entry bundle remains a publication gate. Release metadata now requires `installed_files`, an array of objects containing relative `path` and SHA-256 `sha256`. Customer manifests cover the final Flutter From 1f6153eeeeb7f8fdcabb10c388cd53bb3a83b106 Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Sat, 3 Oct 2026 07:35:08 +0200 Subject: [PATCH 204/652] Document native Windows encrypted management restore rehearsal --- docs/ACCEPTANCE.md | 12 ++++++++++++ docs/SERVER_BACKUP.md | 9 +++++++++ 2 files changed, 21 insertions(+) diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index 27961dda708..6b4e7027e55 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -1954,3 +1954,15 @@ preserved managed state byte-for-byte. This verifies the new source script with an older ten-entry bundle; generation and installation of the new eleven-entry recipe, graphical uninstall confirmation, MSI removal and complete cleanup remain unverified. The matching native build for source `e9cda62` has been dispatched. + +The lab-signed native `0a541c7` management server passed an encrypted backup and +restore rehearsal in separate protected Server 2016 directories. Signing/setup +file hashes and the complete signed profile remained identical. Original and +replacement guest-loopback servers returned identical account, device, worker +and approved-release state; MFA login and replay denial passed independently. +Backup overwrite and restore into a nonempty directory were denied. Wrong-password +and byte-tampered archives failed authentication before creating target files. +Only owned foreground test processes were stopped; existing services retained +their prior status. The first harness expected the wrong authentication error text +and stopped; the corrected fresh-directory rehearsal passed. This is management-only +native recovery evidence, not transport/TLS restoration or migration rollback. diff --git a/docs/SERVER_BACKUP.md b/docs/SERVER_BACKUP.md index dc533bbffc6..8181bdea1bb 100644 --- a/docs/SERVER_BACKUP.md +++ b/docs/SERVER_BACKUP.md @@ -130,3 +130,12 @@ retains its pending device despite revocation in the replacement. Private key contents never leave the volumes. This supersedes the earlier local transport restore limitation above; remote desktop sessions, public ACME renewal, post-backup reconciliation and migration rollback remain unverified. + +The compiled, lab-signed `0a541c7` Windows management executable also passed an +isolated Server 2016 management-only export/restore rehearsal. The restored +server retained identical signed profile bytes, signing/setup files, accounts, +devices, worker revocation state and approved releases. Administrator MFA worked +on both servers and replay was denied. Existing-backup overwrite, nonempty restore, +wrong passwords and a changed ciphertext byte were rejected; authentication failures +created no target files. This rehearsal used guest-loopback HTTP for API comparisons +and did not exercise transport, TLS restoration or database migration rollback. From b0cb74045487003b2ba179f210ebfcc700e7b537 Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Sat, 3 Oct 2026 07:40:34 +0200 Subject: [PATCH 205/652] Preserve generated installer files in optional encrypted deployment backups --- docs/ACCEPTANCE.md | 9 ++++++++ docs/SERVER_BACKUP.md | 12 +++++++++- product/management/src/backup.rs | 39 +++++++++++++++++++++++++++++++- product/management/src/main.rs | 4 ++-- 4 files changed, 60 insertions(+), 4 deletions(-) diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index 6b4e7027e55..8599051691e 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -1966,3 +1966,12 @@ Only owned foreground test processes were stopped; existing services retained their prior status. The first harness expected the wrong authentication error text and stopped; the corrected fresh-directory rehearsal passed. This is management-only native recovery evidence, not transport/TLS restoration or migration rollback. + +Deployment backup now has an explicit `--installer-artifacts-directory` option. +It includes canonical build-UUID ZIP files in the authenticated archive and +restores them under the replacement data directory, retaining files needed by +published installer links. Four compiled backup tests passed, including byte-exact +installer-file recovery, rejection of unfinished uploads, tampered archives, +unsafe restore paths and prior transport/TLS behavior. This component evidence +does not demonstrate native installer-download availability after deployment +restore; that check requires a rebuilt server with this option. diff --git a/docs/SERVER_BACKUP.md b/docs/SERVER_BACKUP.md index 8181bdea1bb..414fcf4ecd8 100644 --- a/docs/SERVER_BACKUP.md +++ b/docs/SERVER_BACKUP.md @@ -25,6 +25,15 @@ swan-management backup company.swan-backup --transport-directory TRANSPORT_DATA Alternatively, replace the final option with `--tls-directory HTTPS_STORAGE_DIRECTORY`. Both options can be included when the deployment uses separate storage and identity files. +Add `--installer-artifacts-directory COMPANY_DATA/artifacts` to preserve generated +customer and technician bundles and their existing download links. Pause builds +and uploads, and keep management configuration changes stopped during this export. +Only regular files named with canonical build UUIDs and `.zip` are accepted; +unfinished `.partial` uploads cause export to fail. Installer files share the +512 MiB plaintext and 4096-file limits with the rest of the archive. If those +limits are exceeded, keep the installer directory in a separate protected backup +and restore it with the matching management database. Management-only export +omits these files, so its restored build records alone cannot serve old downloads. Directory exports reject symbolic links, Windows reparse points, special files, unsafe paths, and more than 4096 total files. The 512 MiB total plaintext limit applies. When the company data directory contains `Caddyfile`, export also encrypts that @@ -57,7 +66,7 @@ swan-management restore company.swan-backup Set `SWAN_DATA_DIR` to that empty replacement directory before running restore. The restorer authenticates and validates the archive before writing contents. It permits fixed management filenames and validated relative paths under -`tls-storage/`, rejects duplicate or Windows-colliding names, file/directory +`tls-storage/` and canonical build ZIPs under `artifacts/`, rejects duplicate or Windows-colliding names, file/directory conflicts and incomplete key pairs, and protects restored files with private Unix permissions or Windows ACLs for the invoking account, SYSTEM and Administrators. @@ -73,6 +82,7 @@ protected directory; restore never overwrites external service files: | `tls-identity` | Company HTTPS identity location, or replace through certificate reissuance | | `tls-storage/` | Complete HTTPS service data directory; copy while that service is stopped | | `Caddyfile` | HTTPS recipe in the replacement company data directory; validate paths and hostnames before starting | +| `artifacts/` | Generated installers in the replacement company data directory; verify stored hashes and both public/customer and protected/technician download behavior | Keep transport services stopped while placing their restored data. Preserve private ownership and permissions appropriate to the service accounts. Use the diff --git a/product/management/src/backup.rs b/product/management/src/backup.rs index 1ec6c35d244..4ca7a4cef4a 100644 --- a/product/management/src/backup.rs +++ b/product/management/src/backup.rs @@ -43,6 +43,13 @@ fn collect_tls(root:&Path,directory:&Path,files:&mut Vec<(String,std::path::Path Ok(()) } pub fn export_deployment(data:&Path,destination:&Path,password:&str,transport:Option<&Path>,configuration:Option<&Path>,tls_identity:Option<&Path>,tls_directory:Option<&Path>)->Result<()> { + export_deployment_with_artifacts(data,destination,password,transport,configuration,tls_identity,tls_directory,None) +} +fn artifact_entry(name:&str)->bool { + name.strip_prefix("artifacts/").and_then(|name|name.strip_suffix(".zip")) + .is_some_and(|id|uuid::Uuid::parse_str(id).is_ok_and(|parsed|parsed.to_string()==id)) +} +pub fn export_deployment_with_artifacts(data:&Path,destination:&Path,password:&str,transport:Option<&Path>,configuration:Option<&Path>,tls_identity:Option<&Path>,tls_directory:Option<&Path>,artifacts:Option<&Path>)->Result<()> { ensure!(!destination.exists(),"Refusing to overwrite an existing backup"); let db=rusqlite::Connection::open_with_flags(data.join("management.sqlite3"),rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY)?; let snapshot=data.join(format!("backup-{}.sqlite3",swan_protocol::random_token())); @@ -64,6 +71,19 @@ pub fn export_deployment(data:&Path,destination:&Path,password:&str,transport:Op if let Some(path)=configuration {files.push(("deployment.env".into(),path.to_path_buf()));} if let Some(path)=tls_identity {files.push(("tls-identity".into(),path.to_path_buf()));} if let Some(directory)=tls_directory {collect_tls(directory,directory,&mut files)?;} + if let Some(directory)=artifacts { + let metadata=std::fs::symlink_metadata(directory)?; + ensure!(metadata.is_dir() && !metadata.file_type().is_symlink(),"Installer artifacts must be a regular directory"); + #[cfg(windows)] {use std::os::windows::fs::MetadataExt;ensure!(metadata.file_attributes()&0x400==0,"Installer artifacts must not contain reparse points");} + for entry in std::fs::read_dir(directory)? { + let path=entry?.path();let metadata=std::fs::symlink_metadata(&path)?; + ensure!(metadata.is_file() && !metadata.file_type().is_symlink(),"Installer artifacts must contain regular files only"); + #[cfg(windows)] {use std::os::windows::fs::MetadataExt;ensure!(metadata.file_attributes()&0x400==0,"Installer artifacts must not contain reparse points");} + let name=format!("artifacts/{}",path.file_name().and_then(|name|name.to_str()).context("Installer artifact name must be UTF-8")?); + ensure!(artifact_entry(&name),"Installer artifact must use its canonical build UUID and ZIP extension; finish uploads before export"); + ensure!(files.len()<4096,"Too many backup files");files.push((name,path)); + } + } let proxy=data.join("Caddyfile"); match std::fs::symlink_metadata(&proxy) { Ok(metadata)=>{ @@ -104,7 +124,7 @@ pub fn restore(data:&Path,source:&Path,password:&str)->Result<()> { ensure!((3..=4096).contains(&archive.len()),"Unexpected backup contents"); let allowed=["management.sqlite3","profile-key.hex","setup-token.txt","transport-id_ed25519","transport-id_ed25519.pub","transport-db.sqlite3","deployment.env","tls-identity","Caddyfile"]; let mut names=std::collections::HashSet::new(); - for index in 0..archive.len(){let entry=archive.by_index(index)?;ensure!((allowed.contains(&entry.name()) || tls_entry(entry.name())) && !entry.is_dir() && names.insert(entry.name().to_string()),"Unknown or duplicate backup entry");} + for index in 0..archive.len(){let entry=archive.by_index(index)?;ensure!((allowed.contains(&entry.name()) || tls_entry(entry.name()) || artifact_entry(entry.name())) && !entry.is_dir() && names.insert(entry.name().to_string()),"Unknown or duplicate backup entry");} let mut portable_names=std::collections::HashSet::new(); for name in &names {ensure!(portable_names.insert(name.to_ascii_lowercase()),"Backup filenames collide on Windows");} for name in &portable_names { @@ -143,6 +163,23 @@ pub fn restore(data:&Path,source:&Path,password:&str)->Result<()> { mod tests { use super::*; #[test] + fn installer_artifacts_roundtrip_and_reject_unfinished_or_unsafe_entries() { + let root=std::env::temp_dir().join(format!("swan-artifact-backup-{}",swan_protocol::random_token())); + let data=root.join("data");let artifacts=data.join("artifacts");std::fs::create_dir_all(&artifacts).unwrap(); + let db=rusqlite::Connection::open(data.join("management.sqlite3")).unwrap();db.execute_batch("CREATE TABLE builds(id TEXT);INSERT INTO builds VALUES('retained');").unwrap();drop(db); + std::fs::write(data.join("profile-key.hex"),b"key").unwrap();std::fs::write(data.join("setup-token.txt"),b"token").unwrap(); + let id=uuid::Uuid::new_v4().to_string();let name=format!("{id}.zip");std::fs::write(artifacts.join(&name),b"retained installer bytes").unwrap(); + let archive=root.join("complete.backup"); + export_deployment_with_artifacts(&data,&archive,"installer backup test password",None,None,None,None,Some(&artifacts)).unwrap(); + let restored=root.join("restored");restore(&restored,&archive,"installer backup test password").unwrap(); + assert_eq!(std::fs::read(restored.join("artifacts").join(&name)).unwrap(),b"retained installer bytes"); + for name in ["artifacts/../outside.zip","artifacts/not-a-build.zip","artifacts/00000000-0000-0000-0000-000000000000.zip/child","artifacts/00000000-0000-0000-0000-000000000000.partial"] {assert!(!artifact_entry(name));} + std::fs::write(artifacts.join(format!("{id}.partial")),b"unfinished upload").unwrap(); + let rejected=root.join("unfinished.backup"); + assert!(export_deployment_with_artifacts(&data,&rejected,"installer backup test password",None,None,None,None,Some(&artifacts)).is_err());assert!(!rejected.exists()); + std::fs::remove_dir_all(root).unwrap(); + } + #[test] fn complete_backup_preserves_transport_configuration_and_tls_and_rejects_tampering() { let root=std::env::temp_dir().join(format!("swan-full-backup-{}",swan_protocol::random_token())); let data=root.join("data");let transport=root.join("transport");std::fs::create_dir_all(&data).unwrap();std::fs::create_dir(&transport).unwrap(); diff --git a/product/management/src/main.rs b/product/management/src/main.rs index cd1cf9210a2..9d0e240d8bc 100644 --- a/product/management/src/main.rs +++ b/product/management/src/main.rs @@ -50,11 +50,11 @@ async fn run(stop:tokio::sync::oneshot::Receiver<()>)->Result<()> { let mut inputs=std::collections::HashMap::new(); anyhow::ensure!(extra.len()%2==0,"Backup options require explicit paths"); for pair in extra.chunks_exact(2){ - anyhow::ensure!(operation=="backup" && matches!(pair[0].as_str(),"--transport-directory"|"--deployment-env"|"--tls-identity"|"--tls-directory"),"Unknown backup option"); + anyhow::ensure!(operation=="backup" && matches!(pair[0].as_str(),"--transport-directory"|"--deployment-env"|"--tls-identity"|"--tls-directory"|"--installer-artifacts-directory"),"Unknown backup option"); anyhow::ensure!(inputs.insert(pair[0].clone(),PathBuf::from(&pair[1])).is_none(),"Duplicate backup option"); } if operation=="backup" { - backup::export_deployment(&data,&path,&passphrase,inputs.get("--transport-directory").map(|p|p.as_path()),inputs.get("--deployment-env").map(|p|p.as_path()),inputs.get("--tls-identity").map(|p|p.as_path()),inputs.get("--tls-directory").map(|p|p.as_path())) + backup::export_deployment_with_artifacts(&data,&path,&passphrase,inputs.get("--transport-directory").map(|p|p.as_path()),inputs.get("--deployment-env").map(|p|p.as_path()),inputs.get("--tls-identity").map(|p|p.as_path()),inputs.get("--tls-directory").map(|p|p.as_path()),inputs.get("--installer-artifacts-directory").map(|p|p.as_path())) }else{backup::restore(&data,&path,&passphrase)} }).await??; println!("Encrypted {} completed.",args[1]);return Ok(()); From 8ec66d2f67042c802a9215c33b2112d68b289e02 Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Sat, 3 Oct 2026 07:45:10 +0200 Subject: [PATCH 206/652] Verify native encrypted installer restore and download authorization --- docs/ACCEPTANCE.md | 18 ++++++++++++++++++ docs/SERVER_BACKUP.md | 9 +++++++++ 2 files changed, 27 insertions(+) diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index 8599051691e..ffa3092df93 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -1975,3 +1975,21 @@ installer-file recovery, rejection of unfinished uploads, tampered archives, unsafe restore paths and prior transport/TLS behavior. This component evidence does not demonstrate native installer-download availability after deployment restore; that check requires a rebuilt server with this option. + +That installer-download check subsequently passed with native Windows source +`b0cb74045487003b2ba179f210ebfcc700e7b537`, built with locked dependencies using +Rust 1.90.0 GNU. Unsigned management SHA-256 was +`fbaaa1c0611734b42e8362c323fe7bdab241f294e6b89ddf5b690bd92705048a`; +the lab-signed fixture was +`645d17ab2dac1fad4789ee629cc23353d6e1af2ccd219bd959bdb7fc8c1953a6` +and passed Authenticode and required RFC3161 timestamp verification. Encrypted +export/restore of the isolated company's database and generated installer files +produced a replacement Server 2016 server that served the customer bundle publicly, +denied unauthenticated downloads of both technician bundles with 401, and served +both after MFA authentication. All three downloaded hashes matched the retained +build records. Trust, signed profile, accounts, devices, workers, releases and +MFA/replay behavior passed the prior comparison and negative checks. Existing +services retained their status. This supersedes the component-only installer +restore limitation above, but remains guest-loopback HTTP and lab signing evidence; +native transport/TLS recovery, migration rollback and production publication remain +unverified. diff --git a/docs/SERVER_BACKUP.md b/docs/SERVER_BACKUP.md index 414fcf4ecd8..f40316577c5 100644 --- a/docs/SERVER_BACKUP.md +++ b/docs/SERVER_BACKUP.md @@ -149,3 +149,12 @@ on both servers and replay was denied. Existing-backup overwrite, nonempty resto wrong passwords and a changed ciphertext byte were rejected; authentication failures created no target files. This rehearsal used guest-loopback HTTP for API comparisons and did not exercise transport, TLS restoration or database migration rollback. + +The updated native Windows management build at `b0cb740` then passed the same +rehearsal with `--installer-artifacts-directory`. Its replacement Server 2016 +server served one customer bundle publicly and two technician bundles only after +authentication; unauthenticated technician requests returned 401. All downloaded +files matched their pre-backup recorded SHA-256 hashes. Trust, accounts, worker +state, releases and MFA/replay protections remained intact. This uses a locally +built GNU executable with the disposable lab publisher, and guest-loopback HTTP; +it does not establish production signing, public connectivity or live transport recovery. From 2aed1206a3a32f78a6726d80e6f1bdfc53554b28 Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Sat, 3 Oct 2026 08:23:52 +0200 Subject: [PATCH 207/652] Record native MSI bundle and CI server setup verification limits --- docs/ACCEPTANCE.md | 43 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index ffa3092df93..2242326115e 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -1993,3 +1993,46 @@ services retained their status. This supersedes the component-only installer restore limitation above, but remains guest-loopback HTTP and lab signing evidence; native transport/TLS recovery, migration rollback and production publication remain unverified. + +Native eleven-entry technician MSI generation passed with the lab-signed GNU +management build `b0cb740` and worker `8ec66d2`. The worker's signed SHA-256 is +`ce86aebf44178f93a981b4c296c3eecebb01cd8da923e75e7799884c01051117`; +the signed MSI fixture is +`bef611e1904bb0b6ecb183da38aa502cf52a68c66589f208408bba17824cddea`. +Both passed publisher and required RFC3161 timestamp verification. The MSI uses +recipe `8ec66d2`, version 1.5.7 and the retained signed `0a541c7` technician/agent +payloads; this is a mixed-source lab fixture, not a production source release. +Build `7373f93d-e7d3-40e8-b1e6-9e31dda99081` completed over trusted guest HTTPS; +the generated bundle SHA-256 is +`5c4b2f105e4afe6f238aa659d5a505c4e617aa4c808ec2d9e219dd305ff26957`. +Its eleven entries, company bootstrap and downloaded hash passed, and public +technician download returned 401. Installation was not performed. + +A new disposable standard account was prepared for MSI lifecycle testing, with +its password confined to the protected guest fixture. Initial harness failures +used a reserved PowerShell variable, assumed a nonexistent public-key location, +and generated a password that failed Windows complexity checks. The last failure +partially created the specifically marked account; its unset password was repaired +without changing another account. WinRM alternate-user process creation was denied; +a scheduled probe never ran, and the alternate-user SSH probe subsequently exited +with `0xC0000142` before either success or error markers were written. Its profile +directory exists, but no successful unprivileged process or MSI lifecycle result +was demonstrated. The probe task remains registered and needs owned cleanup. +These are test-environment failures, not proof of technician MSI installation, +repair, upgrade or uninstall. + +CI run `37097192689` for `9c6bdbb9e2923d0b6dae50076a99670ee76013e6` +completed successfully. Artifact `11265830031` was downloaded with authenticated +provenance and archive SHA-256 +`a01d6eab9a21b7c9e4b4ebd07ed51aa77fbdc744c48dcb9b9789972a6a9f1699`; +all 22 flat entries passed bounded ZIP/CRC extraction and published payload checksums. +The included server setup SHA-256 is +`63846db80c82c29ad0ac3f0f5984d9deb6cba9ea708255c2f12b62192dca891d`. +Reflection-only inspection verified its exact twelve-resource recipe, all eleven +payload hashes, matching embedded/external source receipt and matching standalone +management binary. Six fixed setup source inputs matched the public commit, allowing +normal Git checkout line endings; transport/HTTPS component hashes matched their +pins. The receipt declares a dirty CI working tree, so these checks do not establish +whole-tree source cleanliness, production signing or actual server installation. +The later `e9cda62` native CI run `37099948400` also completed successfully; its +artifact verification is in progress. From 11faae30b0dac275c7d8c795ca3c3c134ded36c9 Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Sat, 3 Oct 2026 08:26:47 +0200 Subject: [PATCH 208/652] Record owned cleanup of failed standard-user MSI probe --- docs/ACCEPTANCE.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index 2242326115e..5d6cb924b87 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -2017,7 +2017,8 @@ without changing another account. WinRM alternate-user process creation was deni a scheduled probe never ran, and the alternate-user SSH probe subsequently exited with `0xC0000142` before either success or error markers were written. Its profile directory exists, but no successful unprivileged process or MSI lifecycle result -was demonstrated. The probe task remains registered and needs owned cleanup. +was demonstrated. The specifically owned probe task was subsequently verified +idle/never-run and removed; the disposable account and profile fixtures remain. These are test-environment failures, not proof of technician MSI installation, repair, upgrade or uninstall. From ff0589005bf1a45e587546f3b7a1084a6a6df718 Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Sat, 3 Oct 2026 09:35:39 +0200 Subject: [PATCH 209/652] Record native MSI retry failure and isolated SSH cleanup --- docs/ACCEPTANCE.md | 31 ++++++++++++++++++++++++++++--- 1 file changed, 28 insertions(+), 3 deletions(-) diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index 5d6cb924b87..39b780f81a5 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -2006,7 +2006,7 @@ Build `7373f93d-e7d3-40e8-b1e6-9e31dda99081` completed over trusted guest HTTPS; the generated bundle SHA-256 is `5c4b2f105e4afe6f238aa659d5a505c4e617aa4c808ec2d9e219dd305ff26957`. Its eleven entries, company bootstrap and downloaded hash passed, and public -technician download returned 401. Installation was not performed. +technician download returned 401. The later installation attempts below failed. A new disposable standard account was prepared for MSI lifecycle testing, with its password confined to the protected guest fixture. Initial harness failures @@ -2035,5 +2035,30 @@ management binary. Six fixed setup source inputs matched the public commit, allo normal Git checkout line endings; transport/HTTPS component hashes matched their pins. The receipt declares a dirty CI working tree, so these checks do not establish whole-tree source cleanliness, production signing or actual server installation. -The later `e9cda62` native CI run `37099948400` also completed successfully; its -artifact verification is in progress. +The later `e9cda62` native CI run `37099948400` also completed successfully. Its +archive SHA-256 was +`f80696b2705ab43e550a5e5eb8c5dc6802e35ecc72ef1ac50b8abad6f7fb46b0`; +all 23 flat entries passed bounded ZIP/CRC extraction and published checksums. + +A separately owned, loopback-forwarded SSH listener subsequently demonstrated +execution as the new standard Windows user with its actual LocalAppData profile +and no administrator membership. The original SSH listener was retained. The +generated eleven-entry bundle reached native technician MSI installation, which +returned `1601`. Its failed-setup marker was retained. Starting the guest Windows +Installer service without changing its Manual startup mode and explicitly retrying +the failed setup returned `1601` again. The isolated management/proxy processes +were stopped after both failed attempts, and existing company service statuses +were preserved. This supersedes the unsuccessful standard-user process probes +above, but does not demonstrate MSI installation, repair or uninstall. Earlier +noninteractive MSI attempts also returned `1601`; an interactive desktop test is +still required before attributing this failure to the product or environment. +After those attempts, ownership checks confirmed no established sessions on the +temporary SSH listener. Its task, guest firewall rule and QEMU host forward were +removed. The original SSH banner and SSH/WinRM forwards were verified, existing +company service statuses were preserved, and private account/profile/failure +evidence was retained. No host firewall or production service was changed. + +Native Windows CI run `37103083258` for +`11faae30b0dac275c7d8c795ca3c3c134ded36c9` completed successfully in all four jobs. +Artifact download and verification are in progress. CI success does not establish +clean-machine installation, native remote sessions or Windows matrix acceptance. From a69f56672ba19f02343ef9e317baa24e5f6c9744 Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Sat, 3 Oct 2026 09:41:37 +0200 Subject: [PATCH 210/652] Identify legacy Tailscale guides as preserved baseline instructions --- README.md | 2 +- docs/SERVER_CONTINUATION_PROMPT.md | 2 ++ docs/UNSIGNED_SPARE_LAPTOP_TEST.md | 2 ++ 3 files changed, 5 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 9e69b266eca..1c8ecc9d2af 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ > [!IMPORTANT] > **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):** 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. diff --git a/docs/SERVER_CONTINUATION_PROMPT.md b/docs/SERVER_CONTINUATION_PROMPT.md index 5228c4e18e6..cefa504b93d 100644 --- a/docs/SERVER_CONTINUATION_PROMPT.md +++ b/docs/SERVER_CONTINUATION_PROMPT.md @@ -1,5 +1,7 @@ # Server continuation prompt +> Historical continuation prompt for the single-company RustDesk/Tailscale baseline. Use it only when maintaining that preserved deployment. Current company-hosted product work follows the [product guide](../product/README.md) and [acceptance report](ACCEPTANCE.md). + Paste the following into Codex when working on the RustDesk/Tailscale server. It is intentionally evidence-first because live Tailnet, firewall, and key changes can interrupt support access. ```text diff --git a/docs/UNSIGNED_SPARE_LAPTOP_TEST.md b/docs/UNSIGNED_SPARE_LAPTOP_TEST.md index 1ed4693f671..e22490c89dd 100644 --- a/docs/UNSIGNED_SPARE_LAPTOP_TEST.md +++ b/docs/UNSIGNED_SPARE_LAPTOP_TEST.md @@ -1,5 +1,7 @@ # Unsigned two-laptop test +> Historical test procedure for `swan-single-company-baseline-1.4.9`. Its Tailscale and password steps apply only to the preserved single-company deployment. For the configurable company-hosted product, use the [product guide](../product/README.md) and [acceptance report](ACCEPTANCE.md). + This procedure is only for an isolated spare laptop owned or controlled by Swan Computing. The current 1.4.9 build is unsigned. Do not use it on a real customer computer, do not place customer data on the spare laptop, and do not weaken Microsoft Defender, SmartScreen, Windows Firewall, or another security control. ## Before starting From 78e86b02a6991adc1feee9dc587c51904ed0648a Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Sat, 3 Oct 2026 09:50:57 +0200 Subject: [PATCH 211/652] Verify native cached profile and bootstrap trust boundaries --- .../windows/test-native-profile-time.py | 68 +++++++++++++++---- docs/ACCEPTANCE.md | 28 ++++++++ 2 files changed, 82 insertions(+), 14 deletions(-) diff --git a/deployment/windows/test-native-profile-time.py b/deployment/windows/test-native-profile-time.py index 7961c06fc04..0923579b655 100644 --- a/deployment/windows/test-native-profile-time.py +++ b/deployment/windows/test-native-profile-time.py @@ -1,4 +1,4 @@ -"""Exercise native cached-profile time validation without network or installed state. +"""Exercise native cached-profile trust and time validation without network or installed state. Requires Python cryptography. The signing key is ephemeral and never serialized. Use an independently hash-verified swan-agent executable and a fresh private output @@ -15,6 +15,7 @@ from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey +from cryptography.exceptions import InvalidSignature def main(): @@ -38,12 +39,24 @@ def main(): branding = dict(display_name="Time validation lab", primary_color="#007F82", logo_svg="", support_url="", consent_text="Approve lab support.") now = int(time.time()) - cases = [("valid", now - 60, now + 3600, True), - ("expired", now - 3600, now - 60, False), - ("future", now + 3600, now + 7200, False), - ("invalid_lifetime", now - 60, now - 120, False)] + time_error = "Expired or future profile" + cases = [("valid", now - 60, now + 3600, None), + ("expired", now - 3600, now - 60, time_error), + ("future", now + 3600, now + 7200, time_error), + ("invalid_lifetime", now - 60, now - 120, time_error)] + cases += [(name, now - 60, now + 3600, error) for name, error in [ + ("tampered_branding", "Invalid signature"), + ("wrong_signing_key", "Invalid signature"), + ("wrong_company", "Wrong company or schema"), + ("replayed_revision", "Replayed profile"), + ("bootstrap_company", "Installer belongs to another company or edition"), + ("bootstrap_edition", "Installer belongs to another company or edition"), + ("bootstrap_trust_key", "Installer cannot replace configured trust keys"), + ("bootstrap_endpoint", "Installer endpoint differs from trusted company configuration"), + ]] results = [] - for name, issued, expires, accepted in cases: + for name, issued, expires, expected_error in cases: + accepted = expected_error is None directory = output / name directory.mkdir() profile = dict(schema=1, company_id=bootstrap["company_id"], revision=1, @@ -54,31 +67,58 @@ def main(): allow_unattended=False, updates_paused=True, rollout_percent=0, maintenance_start_utc=0, maintenance_end_utc=23, update_channel="test", next_profile_public_key=None) + if name == "wrong_company": + profile["company_id"] = "another-company" payload = json.dumps(profile, separators=(",", ":")).encode() - signature = key.sign(payload) - key.public_key().verify(signature, payload) + signer = Ed25519PrivateKey.generate() if name == "wrong_signing_key" else key + signature = signer.sign(payload) + signer.public_key().verify(signature, payload) + if name == "tampered_branding": + profile["technician"] = dict(branding, display_name="Tampered company") + payload = json.dumps(profile, separators=(",", ":")).encode() + signature_matches_pin = True + try: + key.public_key().verify(signature, payload) + except InvalidSignature: + signature_matches_pin = False + if signature_matches_pin != (name not in ("tampered_branding", "wrong_signing_key")): + raise RuntimeError(f"Unexpected fixture signature: {name}") state = dict(bootstrap=bootstrap, profile=dict( payload=base64.b64encode(payload).decode(), - signature=base64.b64encode(signature).decode()), accepted_revision=1, + signature=base64.b64encode(signature).decode()), + accepted_revision=2 if name == "replayed_revision" else 1, device_id=None, device_token=None, unattended_consent=False, consent_revision=0, last_release_sequence=0) state_path = directory / "managed-state.json" state_path.write_text(json.dumps(state), encoding="utf-8") bootstrap_path = directory / "bootstrap.json" - bootstrap_path.write_text(json.dumps(bootstrap), encoding="utf-8") + installer_bootstrap = dict(bootstrap) + if name == "bootstrap_company": + installer_bootstrap["company_id"] = "another-company" + elif name == "bootstrap_edition": + installer_bootstrap["edition"] = "customer" + elif name == "bootstrap_trust_key": + installer_bootstrap["profile_public_key"] = base64.b64encode( + Ed25519PrivateKey.generate().public_key().public_bytes( + serialization.Encoding.Raw, serialization.PublicFormat.Raw)).decode() + elif name == "bootstrap_endpoint": + installer_bootstrap["management_url"] = "https://another-company.example.invalid" + bootstrap_path.write_text(json.dumps(installer_bootstrap), encoding="utf-8") before = state_path.read_bytes() + bootstrap_before = bootstrap_path.read_bytes() env = os.environ.copy() env["SWAN_STATE_DIR"] = str(directory) completed = subprocess.run([str(agent), "verify-bootstrap", str(bootstrap_path)], env=env, capture_output=True, text=True, timeout=30) if (completed.returncode == 0) != accepted: raise RuntimeError(f"Unexpected native validation outcome: {name}") - if not accepted and "Expired or future profile" not in completed.stderr: - raise RuntimeError(f"Rejection did not exercise profile time validation: {name}") - if state_path.read_bytes() != before: + if not accepted and expected_error not in completed.stderr: + raise RuntimeError(f"Rejection did not exercise expected trust validation: {name}") + if state_path.read_bytes() != before or bootstrap_path.read_bytes() != bootstrap_before: raise RuntimeError(f"Native verification changed fixture state: {name}") results.append(dict(case=name, accepted=accepted, state_unchanged=True, - signature_independently_verified=True)) + signature_matches_pinned_key=signature_matches_pin, + expected_rejection_verified=not accepted)) result = dict(agent_sha256=digest, cases=results, scope="Native cached-profile validation; synthetic company; no network or installation") (output / "result.json").write_text(json.dumps(result, indent=2), encoding="utf-8") diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index 39b780f81a5..be849019dd0 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -2052,6 +2052,9 @@ were preserved. This supersedes the unsuccessful standard-user process probes above, but does not demonstrate MSI installation, repair or uninstall. Earlier noninteractive MSI attempts also returned `1601`; an interactive desktop test is still required before attributing this failure to the product or environment. +Read-only inspection after both attempts confirmed sequence 0, a retained +failed-setup marker and no installed technician executable or configuration agent. +The event-log summary did not establish a specific installer-service root cause. After those attempts, ownership checks confirmed no established sessions on the temporary SSH listener. Its task, guest firewall rule and QEMU host forward were removed. The original SSH banner and SSH/WinRM forwards were verified, existing @@ -2062,3 +2065,28 @@ Native Windows CI run `37103083258` for `11faae30b0dac275c7d8c795ca3c3c134ded36c9` completed successfully in all four jobs. Artifact download and verification are in progress. CI success does not establish clean-machine installation, native remote sessions or Windows matrix acceptance. + +That download subsequently completed. Archive `5cb668d823167a5516020ec395289eff067cc10d29f5e4ba1a940e789e44d7b5` +(134,259,819 bytes) passed authenticated provenance, bounded ZIP/CRC extraction +and published checksums for all 23 entries. The server setup hash is +`fd604e329d444d201c59c4feb41fbd477cb63eb17488285b7e8627e1fb39f886`. +Reflection-only inspection verified its exact twelve-resource recipe, all eleven +payload hashes, embedded/external source receipt equality and matching standalone +management executable. Six fixed source inputs matched public commit `11faae3`, +allowing Git checkout line endings, and component pins matched. The receipt still +reports a dirty CI working tree; whole-tree cleanliness, production signing and +actual setup installation remain unproven. + +The same archive's native MSVC agent +`cd2bdbc5c14cca1b9b24c16fe22f590cca96eb92cf45465085b3880dcd109631` +passed the expanded offline profile harness on the development Windows host. +Harness SHA-256 was +`2844f6148f7d2304010f6a74a0fa03871538fa10a2e8b9d0612576c5cce68fd7`. +All twelve cases passed: a valid cached profile was accepted; expired, future, +invalid-lifetime, tampered-branding, wrong-signing-key, wrong-company and older +revision profiles were rejected; company, edition, trust-key and endpoint +substitution in installer bootstrap were rejected. Each negative case matched +its specific expected error, and both state and bootstrap bytes remained unchanged. +Keys were ephemeral and not serialized. This is native CLI trust verification +against synthetic local fixtures, without network, installation, rendered branding +or remote-session evidence. From 442fb0cf4d1ff3cc3f6b358e6def7d65ab342b29 Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Sat, 3 Oct 2026 10:19:30 +0200 Subject: [PATCH 212/652] Record current MSVC installer restore and HTTPS worker verification --- docs/ACCEPTANCE.md | 43 +++++++++++++++++++++++++++++++++++++++++++ docs/SERVER_BACKUP.md | 9 +++++++++ 2 files changed, 52 insertions(+) diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index be849019dd0..6aa12c55134 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -2090,3 +2090,46 @@ its specific expected error, and both state and bootstrap bytes remained unchang Keys were ephemeral and not serialized. This is native CLI trust verification against synthetic local fixtures, without network, installation, rendered branding or remote-session evidence. + +The current MSVC management executable from `11faae3` subsequently passed the +native encrypted installer-backup rehearsal on Server 2016 build 14393. Its +unsigned hash is `de623e216e4ef2b81785c18615fc23e83e4716c894225ed620a860e2aea9f817`; +the disposable lab-signed hash is +`194c837babd6a07f51b37743fb11400e37cfa6a41e48c16e2701032f2d921f3d`. +Authenticode and required RFC3161 timestamp verification passed. The restored +server served one customer bundle publicly and three technician bundles after +authentication, including the eleven-entry MSI bundle. Every download hash +matched its retained build record, and unauthenticated technician requests +returned 401. Trust files, signed profile, users/devices/workers/releases, +administrator MFA and replay checks passed; backup overwrite, nonempty restore, +wrong passwords and ciphertext modification were rejected. Both isolated +processes stopped and existing company service statuses were preserved. This +extends the GNU result to the current MSVC management runtime, over guest-loopback +HTTP; transport/TLS restore, migration rollback and production signing remain +separate unverified requirements. + +The matched-source MSVC server and worker from `11faae3` then completed a real +technician MSI bundle job over trusted guest HTTPS. The worker's unsigned hash is +`73ce8e09c6473f9c32c7cd8c22d5eb0c75e68c448e1829a462df42e3843d5bd9`; +its timestamp-verified lab-signed hash is +`fa6bb94be7a44a4e2148b428af3fe1a0ca93045d0961aefd54e8c2188bb92c0c`. +Job `3444e8fd-4e4a-4624-887c-1e1b370afbd3` produced bundle +`478f2d3af2838f26245ed0723b5a009fd80ae53b58bd57418a8e2bf76e6832a9`. +All eleven entries, company/edition/endpoint bootstrap and downloaded hash passed; +unauthenticated technician download returned 401. The owned worker credential +was revoked and listed disabled, the administrator session logged out, all three +isolated processes stopped, and existing company service statuses were preserved. +This uses retained lab MSI recipe `8ec66d2`, signed endpoint/agent inputs `0a541c7` +and a newly signed test release sequence 13; it is mixed-source packaging evidence, +not installation or production source-release acceptance. + +Two preceding attempts failed and remain separate evidence. The first isolated +Caddy startup crashed with `fatal: morestack on g0` before login/job creation; a +version probe of the same pinned binary succeeded. The second started HTTPS but +its worker could not fetch the older signed release's artifacts: the retained +port-443 lab endpoint had no listener. No existing endpoint was restarted. The +successful run instead used newly signed metadata pointing to the isolated +port-24442 endpoint, which served only the two named lab inputs alongside its +management proxy. Company policy was not implicitly changed, unrelated active +jobs were checked before enqueue, and component pins were unchanged. The startup +crash's cause and wider Windows server stability remain unresolved. diff --git a/docs/SERVER_BACKUP.md b/docs/SERVER_BACKUP.md index f40316577c5..4ad0e3bb889 100644 --- a/docs/SERVER_BACKUP.md +++ b/docs/SERVER_BACKUP.md @@ -158,3 +158,12 @@ files matched their pre-backup recorded SHA-256 hashes. Trust, accounts, worker state, releases and MFA/replay protections remained intact. This uses a locally built GNU executable with the disposable lab publisher, and guest-loopback HTTP; it does not establish production signing, public connectivity or live transport recovery. + +The current MSVC build from `11faae3` also passed that Server 2016 rehearsal with +four retained bundles: one public customer download and three authenticated +technician downloads, including the newer eleven-entry MSI bundle. All hashes, +trust/configuration/authorization comparisons and negative restore/MFA checks +passed. Its lab-signed executable and both isolated foreground processes were +verified, with existing service statuses preserved. This remains guest-loopback +management evidence; native transport/TLS restore, migration rollback and +production publication require their own verification. From 87a1b7f85881d31d7f43d61c5fe7bfe0ffd1048a Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Sat, 3 Oct 2026 10:33:37 +0200 Subject: [PATCH 213/652] Enable Windows recovery after reported server and worker failures --- deployment/windows/Install-Server.ps1 | 3 +++ deployment/windows/Install-Worker.ps1 | 3 +++ docs/ACCEPTANCE.md | 21 +++++++++++++++++++++ 3 files changed, 27 insertions(+) diff --git a/deployment/windows/Install-Server.ps1 b/deployment/windows/Install-Server.ps1 index 8b929f82952..cb258b8ef59 100644 --- a/deployment/windows/Install-Server.ps1 +++ b/deployment/windows/Install-Server.ps1 @@ -70,6 +70,9 @@ try { New-ItemProperty -Path $serviceRegistry -Name Environment -PropertyType MultiString -Value $serviceEnvironment -Force | Out-Null & sc.exe failure SwanCompanyServer reset= 86400 actions= restart/60000/restart/60000/restart/300000 | Out-Null if ($LASTEXITCODE -ne 0) { throw 'Cannot configure company service recovery.' } + # Component supervision reports Stopped with a nonzero exit code after cleanup. + & sc.exe failureflag SwanCompanyServer 1 | Out-Null + if ($LASTEXITCODE -ne 0) { throw 'Cannot enable recovery for reported company service failures.' } if ($fullStack) { New-NetFirewallRule -Name 'SwanCompanyServer-TCP' -Group 'SwanCompanyServer' -DisplayName 'Swan company HTTPS and transport' -Direction Inbound -Action Allow -Protocol TCP -LocalPort 80,443,21115,21116,21117 | Out-Null $createdRules += 'SwanCompanyServer-TCP' diff --git a/deployment/windows/Install-Worker.ps1 b/deployment/windows/Install-Worker.ps1 index 53598463f31..da39628e38d 100644 --- a/deployment/windows/Install-Worker.ps1 +++ b/deployment/windows/Install-Worker.ps1 @@ -50,6 +50,9 @@ try{ New-ItemProperty -LiteralPath $registry -Name Environment -PropertyType MultiString -Value $environment|Out-Null & sc.exe failure SwanInstallerWorker reset= 86400 actions= restart/60000/restart/60000/restart/300000|Out-Null if($LASTEXITCODE -ne 0){throw 'Cannot configure worker recovery.'} + # The worker reports Stopped with a nonzero exit code for fatal runtime errors. + & sc.exe failureflag SwanInstallerWorker 1|Out-Null + if($LASTEXITCODE -ne 0){throw 'Cannot enable recovery for reported worker failures.'} Start-Service SwanInstallerWorker (Get-Service SwanInstallerWorker).WaitForStatus([ServiceProcess.ServiceControllerStatus]::Running,[TimeSpan]::FromSeconds(30)) @{schema=1;service='SwanInstallerWorker';executable_sha256=(Get-FileHash $destination).Hash;publisher_thumbprint=$PublisherThumbprint}|ConvertTo-Json|Set-Content -LiteralPath (Join-Path $data 'installation.json') diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index 6aa12c55134..c7bc6e90a28 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -2133,3 +2133,24 @@ port-24442 endpoint, which served only the two named lab inputs alongside its management proxy. Company policy was not implicitly changed, unrelated active jobs were checked before enqueue, and component pins were unchanged. The startup crash's cause and wider Windows server stability remain unresolved. + +Five further validations of the same pinned Caddy executable and isolated +configuration passed on Server 2016, each returning exit code 0 and `Valid +configuration`. An initial harness used `Start-Process` process objects whose exit +codes were unavailable; those observations were not counted as passes. Direct +native exit-code capture produced the verified results. No listeners were opened +and existing company service statuses were preserved. This does not resolve the +earlier startup crash or prove long-running service stability. + +Review of service failure handling found that management and worker report +`Stopped` with nonzero Win32 exit codes, while their installers configured recovery +actions without enabling recovery on those reported failures. Both installers now +set `sc.exe failureflag SERVICE 1` and fail setup if that configuration fails. +Microsoft documents the required flag in +[SERVICE_FAILURE_ACTIONS_FLAG](https://learn.microsoft.com/en-us/windows/win32/api/winsvc/ns-winsvc-service_failure_actions_flag). +A separate unsigned, protected Server 2016 fixture demonstrated actual SCM restart +after a non-crash error stop, plus no restart after a successful requested stop. +The fixture service was disabled/stopped/deleted during cleanup and existing product +service statuses were preserved. Installer parsing and diff checks passed. This +proves the native recovery mechanism, not installation or reboot of the changed +product packages; no existing product service recovery settings were changed. From 1f1695667e54f0e8cf0437bed2f320236856deaf Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Sat, 3 Oct 2026 10:39:50 +0200 Subject: [PATCH 214/652] Record rebuilt recovery installer verification --- docs/ACCEPTANCE.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index c7bc6e90a28..2e9fa40e41a 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -2154,3 +2154,14 @@ The fixture service was disabled/stopped/deleted during cleanup and existing pro service statuses were preserved. Installer parsing and diff checks passed. This proves the native recovery mechanism, not installation or reboot of the changed product packages; no existing product service recovery settings were changed. + +A fresh unsigned server setup package was subsequently compiled from clean +installer checkout `87a1b7f85881d31d7f43d61c5fe7bfe0ffd1048a`. All eleven embedded +payload hashes passed the builder's verification; reflection confirmed twelve +resources including the source receipt and the updated service recovery flag. +Package SHA-256: +`a5ef288f8747c44e7a3e44b9dfea027bfc207aebd428a53054186afa91107b13`. +The management executable remains the unchanged CI build from `11faae3`, hash +`de623e216e4ef2b81785c18615fc23e83e4716c894225ed620a860e2aea9f817`. +The clean installer receipt does not establish full runtime reproducibility. +The package was inspected without launching its wizard or installing services. From c7d118c4b655d401c14fd95672ae2aff3b5ed679 Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Sat, 3 Oct 2026 10:46:44 +0200 Subject: [PATCH 215/652] Bundle exact public Windows build source inputs --- .github/workflows/swan-windows-build.yml | 12 +++ deployment/windows/archive-build-source.py | 107 +++++++++++++++++++++ deployment/windows/test_build_source.py | 91 ++++++++++++++++++ docs/SOURCE_AND_LICENSE.md | 13 +++ 4 files changed, 223 insertions(+) create mode 100644 deployment/windows/archive-build-source.py create mode 100644 deployment/windows/test_build_source.py diff --git a/.github/workflows/swan-windows-build.yml b/.github/workflows/swan-windows-build.yml index aa34c5f53ac..5deeaab2c57 100644 --- a/.github/workflows/swan-windows-build.yml +++ b/.github/workflows/swan-windows-build.yml @@ -125,6 +125,15 @@ 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: Build branded client shell: pwsh run: | @@ -220,6 +229,8 @@ jobs: 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') + $sourceHash = (Get-FileHash -LiteralPath artifacts/SwanRemoteSupport-build-source.zip -Algorithm SHA256).Hash.ToLowerInvariant() + Add-Content artifacts/SHA256SUMS.txt ($sourceHash+' SwanRemoteSupport-build-source.zip') - name: Upload unsigned installer and checksum uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 @@ -233,6 +244,7 @@ jobs: artifacts/SwanRemoteSupport-${{ env.VERSION }}-customer-x64-unsigned.msi artifacts/SwanServerSetup-UNSIGNED-TEST.exe artifacts/SwanServerSetup-SOURCE.json + artifacts/SwanRemoteSupport-build-source.zip artifacts/*.msi.build.json artifacts/*.msi.installed-files.json artifacts/SHA256SUMS.txt diff --git a/deployment/windows/archive-build-source.py b/deployment/windows/archive-build-source.py new file mode 100644 index 00000000000..52df2f7390e --- /dev/null +++ b/deployment/windows/archive-build-source.py @@ -0,0 +1,107 @@ +"""Archive tracked build inputs and explicitly named generated bridge sources. + +Never sweep the working directory: ignored credentials, lab data, and binaries +are excluded. Run after bridge generation and before application compilation. +The included workflow describes subsequent deterministic packaging transforms. +""" +import argparse +import hashlib +import json +from pathlib import Path, PurePosixPath +import subprocess +import zipfile + +BRIDGE_FILES = ( + "src/bridge_generated.rs", "src/bridge_generated.io.rs", + "flutter/lib/generated_bridge.dart", "flutter/lib/generated_bridge.freezed.dart", + "flutter/macos/Runner/bridge_generated.h", "flutter/ios/Runner/bridge_generated.h", +) + + +def git(root, *args): + return subprocess.check_output(["git", "-C", str(root), *args]) + + +def safe_path(value): + path = PurePosixPath(value) + if path.is_absolute() or ".." in path.parts or ".git" in path.parts or "\\" in value or ":" in value: + raise ValueError("Unsafe source path") + return path + + +def collect(root): + files, repositories = {}, {} + + def repository(directory, prefix="", expected=None): + revision = git(directory, "rev-parse", "HEAD").decode().strip() + if expected is not None and revision != expected: + raise ValueError("Submodule does not match pinned source") + repositories[prefix or "."] = revision + records = git(directory, "ls-files", "--stage", "-z").split(b"\0") + for record in filter(None, records): + header, name = record.split(b"\t", 1) + mode, object_id, stage = header.decode().split() + if stage != "0": + raise ValueError("Unmerged source index") + relative = name.decode("utf-8") + safe_path(relative) + archive_name = prefix + relative + path = directory / relative + if path.is_symlink() or not path.resolve().is_relative_to(root): + raise ValueError("Source link or path escaped checkout") + if mode == "160000": + if not path.is_dir(): + raise ValueError("Uninitialized source submodule") + repository(path, archive_name + "/", object_id) + elif mode in ("100644", "100755"): + if not path.is_file(): + raise ValueError("Tracked source missing") + files[archive_name] = (path, mode) + else: + raise ValueError("Unsupported source file mode") + + repository(root) + for name in BRIDGE_FILES: + path = root / name + if not path.is_file() or path.is_symlink() or not path.resolve().is_relative_to(root): + raise ValueError("Generated bridge source missing or unsafe") + files[name] = (path, "100644") + return files, repositories + + +def archive(root, output): + root = root.resolve(strict=True) + files, repositories = collect(root) + # Explicit exclusive creation prevents silently replacing release evidence. + manifest = {"schema": 1, "snapshot": "before-application-compilation", + "repositories": repositories, "generated_bridge_files": list(BRIDGE_FILES), + "files": {}} + with output.open("xb") as destination, zipfile.ZipFile(destination, "w", compression=zipfile.ZIP_DEFLATED) as package: + for name, (path, mode) in sorted(files.items()): + data = path.read_bytes() + manifest["files"][name] = {"sha256": hashlib.sha256(data).hexdigest(), "mode": mode} + info = zipfile.ZipInfo("source/" + name, (1980, 1, 1, 0, 0, 0)) + info.create_system = 3 + info.external_attr = int(mode, 8) << 16 + info.compress_type = zipfile.ZIP_DEFLATED + package.writestr(info, data) + receipt = zipfile.ZipInfo("BUILD-SOURCE.json", (1980, 1, 1, 0, 0, 0)) + receipt.compress_type = zipfile.ZIP_DEFLATED + receipt.create_system = 3 + receipt.external_attr = 0o100644 << 16 + package.writestr(receipt, json.dumps(manifest, sort_keys=True, indent=2)) + with zipfile.ZipFile(output) as package: + if package.testzip() is not None: + raise ValueError("Source archive CRC failure") + for name, entry in manifest["files"].items(): + if hashlib.sha256(package.read("source/" + name)).hexdigest() != entry["sha256"]: + raise ValueError("Source archive hash mismatch") + return hashlib.sha256(output.read_bytes()).hexdigest() + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("root", type=Path) + parser.add_argument("output", type=Path) + args = parser.parse_args() + print(archive(args.root, args.output)) diff --git a/deployment/windows/test_build_source.py b/deployment/windows/test_build_source.py new file mode 100644 index 00000000000..054985039d3 --- /dev/null +++ b/deployment/windows/test_build_source.py @@ -0,0 +1,91 @@ +import importlib.util +import json +from pathlib import Path +import subprocess +import tempfile +import unittest +import zipfile + +spec = importlib.util.spec_from_file_location("build_source", Path(__file__).with_name("archive-build-source.py")) +source = importlib.util.module_from_spec(spec) +spec.loader.exec_module(source) + + +class BuildSourceTests(unittest.TestCase): + def fixture(self, root): + subprocess.run(["git", "init", str(root)], check=True, capture_output=True) + (root / "tracked.rs").write_text("original", encoding="utf-8") + (root / ".gitignore").write_text("private.env\n", encoding="utf-8") + subprocess.run(["git", "-C", str(root), "add", "."], check=True, capture_output=True) + subprocess.run(["git", "-C", str(root), "-c", "user.name=Fixture", "-c", "user.email=fixture@example.invalid", + "commit", "-m", "fixture"], check=True, capture_output=True) + for name in source.BRIDGE_FILES: + path = root / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("generated bridge source", encoding="utf-8") + + def test_actual_sources_only_and_repeatable_archive(self): + with tempfile.TemporaryDirectory() as folder: + root = Path(folder) / "checkout" + self.fixture(root) + (root / "tracked.rs").write_text("modified build input", encoding="utf-8") + (root / "private.env").write_text("secret must never enter archive", encoding="utf-8") + (root / "untracked.log").write_text("private log", encoding="utf-8") + first, second = Path(folder) / "first.zip", Path(folder) / "second.zip" + self.assertEqual(source.archive(root, first), source.archive(root, second)) + with zipfile.ZipFile(first) as package: + self.assertEqual(package.read("source/tracked.rs"), b"modified build input") + self.assertNotIn("source/private.env", package.namelist()) + self.assertNotIn("source/untracked.log", package.namelist()) + receipt = json.loads(package.read("BUILD-SOURCE.json")) + self.assertEqual(len(receipt["generated_bridge_files"]), 6) + self.assertEqual(set(receipt["files"]), {"tracked.rs", ".gitignore", *source.BRIDGE_FILES}) + with self.assertRaises(FileExistsError): + source.archive(root, first) + + def test_missing_generated_input_fails_before_output(self): + with tempfile.TemporaryDirectory() as folder: + root = Path(folder) / "checkout" + self.fixture(root) + (root / source.BRIDGE_FILES[0]).unlink() + output = Path(folder) / "output.zip" + with self.assertRaisesRegex(ValueError, "Generated bridge source missing"): + source.archive(root, output) + self.assertFalse(output.exists()) + + def test_submodule_pin_mismatch_is_rejected(self): + with tempfile.TemporaryDirectory() as folder: + root = Path(folder) / "checkout" + self.fixture(root) + child = root / "dependency" + self.fixture(child) + subprocess.run(["git", "-C", str(root), "add", "dependency"], check=True, capture_output=True) + matching = Path(folder) / "matching.zip" + source.archive(root, matching) + with zipfile.ZipFile(matching) as package: + self.assertEqual(package.read("source/dependency/tracked.rs"), b"original") + receipt = json.loads(package.read("BUILD-SOURCE.json")) + self.assertIn("dependency/", receipt["repositories"]) + (child / "tracked.rs").write_text("new child revision", encoding="utf-8") + subprocess.run(["git", "-C", str(child), "add", "tracked.rs"], check=True, capture_output=True) + subprocess.run(["git", "-C", str(child), "-c", "user.name=Fixture", "-c", "user.email=fixture@example.invalid", + "commit", "-m", "changed"], check=True, capture_output=True) + with self.assertRaisesRegex(ValueError, "Submodule does not match"): + source.archive(root, Path(folder) / "output.zip") + + def test_index_symlink_cannot_publish_its_target(self): + with tempfile.TemporaryDirectory() as folder: + root = Path(folder) / "checkout" + self.fixture(root) + blob = subprocess.check_output(["git", "-C", str(root), "hash-object", "-w", "--stdin"], input=b"private.env").decode().strip() + subprocess.run(["git", "-C", str(root), "update-index", "--add", "--cacheinfo", + "120000," + blob + ",linked.env"], check=True, capture_output=True) + (root / "linked.env").write_text("sensitive target", encoding="utf-8") + output = Path(folder) / "output.zip" + with self.assertRaisesRegex(ValueError, "Unsupported source file mode"): + source.archive(root, output) + self.assertFalse(output.exists()) + + +if __name__ == "__main__": + unittest.main() diff --git a/docs/SOURCE_AND_LICENSE.md b/docs/SOURCE_AND_LICENSE.md index 45da867217f..166a0a2e920 100644 --- a/docs/SOURCE_AND_LICENSE.md +++ b/docs/SOURCE_AND_LICENSE.md @@ -11,3 +11,16 @@ Project-signed binaries retain the certificate publisher's embedded identity. Co The original single-company Tailscale deployment remains available at `swan-single-company-baseline-1.4.9`; its historical build/deployment documents apply to that baseline only. Before production publication, build from the reviewed public source, verify artifacts and their signatures/hashes, complete the acceptance checks and publish exact corresponding source. Preserve the repository's `LICENCE` and upstream notices. + +The Windows workflow also produces `SwanRemoteSupport-build-source.zip` before +application compilation. It contains the actual tracked source bytes, initialized +pinned submodule source, and the six explicitly named generated bridge files. +`BUILD-SOURCE.json` records repository revisions and every included file hash; +the public workflow in the archive describes subsequent packaging transformations, +including removal of the portable launcher's DPI manifest entry. Ignored local +settings, untracked logs, credentials and build binaries are not swept into this +archive. Missing bridge inputs, source links, unmerged indexes and submodule pin +mismatches fail the build. The artifact checksum list includes the archive hash. +Publish this archive alongside the exact source tag, build instructions and +third-party component source references. Its presence does not prove binary +reproducibility or replace release acceptance and signing checks. From aa850e13e519a48357ad84679839c604f7d8b58a Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Sat, 3 Oct 2026 10:52:51 +0200 Subject: [PATCH 216/652] Record exact source archive verification with CI bridge inputs --- docs/ACCEPTANCE.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index 2e9fa40e41a..a2ed29444af 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -2165,3 +2165,16 @@ The management executable remains the unchanged CI build from `11faae3`, hash `de623e216e4ef2b81785c18615fc23e83e4716c894225ed620a860e2aea9f817`. The clean installer receipt does not establish full runtime reproducibility. The package was inspected without launching its wizard or installing services. + +The new Windows corresponding-source archive was exercised against clean checkout +`c7d118c4b655d401c14fd95672ae2aff3b5ed679` and the six generated bridge inputs from +its running native CI build. GitHub artifact provenance and SHA-256 verified those +inputs before writing the previously absent ignored generated source files. +The archive included 1,107 source files and pinned `hbb_common` revision +`7e1c392c62d39c364127307cd408421dd5f8cfb0`. Every archived file hash and ZIP CRC +passed; two independently written archives had identical SHA-256 +`74a7de90d0b3bc17782203b53605c917be3811dced1eefbc5ad1d2be64a4163c`. +Four focused fixture checks separately verified private/untracked file exclusion, +generated input completeness, matching/mismatched submodule pins and link denial. +This proves the source archive operation with real build inputs, not complete +native compilation, binary reproducibility, signing or release acceptance. From 800ed6e36287ea6db9ac6a91c10195a9c1c32a68 Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Sat, 3 Oct 2026 11:02:09 +0200 Subject: [PATCH 217/652] Exercise technician MSI lifecycle on disposable Windows CI --- .github/workflows/swan-windows-build.yml | 13 +++++ .../windows/test_technician_msi_lifecycle.ps1 | 49 +++++++++++++++++++ 2 files changed, 62 insertions(+) create mode 100644 deployment/windows/test_technician_msi_lifecycle.ps1 diff --git a/.github/workflows/swan-windows-build.yml b/.github/workflows/swan-windows-build.yml index 5deeaab2c57..efa23c2ecef 100644 --- a/.github/workflows/swan-windows-build.yml +++ b/.github/workflows/swan-windows-build.yml @@ -213,6 +213,19 @@ jobs: $customerHash = (Get-FileHash -LiteralPath $customerOutput -Algorithm SHA256).Hash.ToLowerInvariant() Add-Content artifacts/SHA256SUMS.txt ($customerHash+' '+[IO.Path]::GetFileName($customerOutput)) + - 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: 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-*/** + - name: Build unsigned company server setup with pinned components shell: powershell run: | diff --git a/deployment/windows/test_technician_msi_lifecycle.ps1 b/deployment/windows/test_technician_msi_lifecycle.ps1 new file mode 100644 index 00000000000..809ed7e1872 --- /dev/null +++ b/deployment/windows/test_technician_msi_lifecycle.ps1 @@ -0,0 +1,49 @@ +[CmdletBinding()] +param([Parameter(Mandatory)][string]$Package,[Parameter(Mandatory)][string]$TechnicianExecutable,[Parameter(Mandatory)][string]$AgentExecutable) +$ErrorActionPreference='Stop' +if($env:GITHUB_ACTIONS -ne 'true' -or $env:RUNNER_OS -ne 'Windows' -or -not $env:RUNNER_TEMP){throw 'This destructive repair fixture is restricted to disposable Windows CI runners.'} +$packagePath=(Resolve-Path -LiteralPath $Package).Path +if($packagePath -notmatch '-unsigned\.msi$'){throw 'Select an explicitly unsigned test MSI.'} +$expected=@{ + 'SwanRemoteSupport-Technician.exe'=(Get-FileHash -LiteralPath $TechnicianExecutable -Algorithm SHA256).Hash + 'swan-agent.exe'=(Get-FileHash -LiteralPath $AgentExecutable -Algorithm SHA256).Hash +} +$registry='HKCU:\Software\SwanRemoteSupport\Technician' +if(Test-Path -LiteralPath $registry){throw 'Existing technician registration must be preserved.'} +$root=Join-Path ([IO.Path]::GetFullPath($env:RUNNER_TEMP)) ('swan-msi-lifecycle-'+[guid]::NewGuid().ToString('N')) +$installation=Join-Path ([Environment]::GetFolderPath('LocalApplicationData')) 'SwanRemoteSupport-Technician' +if(Test-Path -LiteralPath $installation){throw 'Existing technician installation must be preserved.'} +New-Item -ItemType Directory -Path $root | Out-Null +function Invoke-Msi([string[]]$Arguments,[string]$LogName){ + $log=Join-Path $root $LogName + $all=@($Arguments)+@('/qn','/norestart','/l*v',$log) + if(@($all|Where-Object {$_ -match '["\r\n]'}).Count){throw 'Unsupported MSI argument'} + $start=[Diagnostics.ProcessStartInfo]::new() + $start.FileName=Join-Path $env:SystemRoot 'System32\msiexec.exe' + $start.UseShellExecute=$false + $start.Arguments=($all|ForEach-Object {'"'+$_+'"'}) -join ' ' + $process=[Diagnostics.Process]::Start($start) + try{ + if(-not $process.WaitForExit(120000)){throw 'MSI still running; inspect its process and retained log before retrying.'} + if($process.ExitCode -ne 0){throw ('MSI failed with '+$process.ExitCode+'; retained log '+$log)} + }finally{$process.Dispose()} +} +function Verify-Payload { + foreach($name in $expected.Keys){ + if((Get-FileHash -LiteralPath (Join-Path $installation $name) -Algorithm SHA256).Hash -ne $expected[$name]){throw 'Installed MSI payload hash mismatch.'} + } + if(-not(Test-Path -LiteralPath $registry)){throw 'Technician MSI registration missing.'} +} +Invoke-Msi @('/i',$packagePath) 'install.log' +Verify-Payload +$repairTarget=[IO.Path]::GetFullPath((Join-Path $installation 'SwanRemoteSupport-Technician.exe')) +if(-not $repairTarget.StartsWith([IO.Path]::GetFullPath($installation)+'\',[StringComparison]::OrdinalIgnoreCase)){throw 'Repair target escaped owned installation.'} +Remove-Item -LiteralPath $repairTarget +Invoke-Msi @('/fa',$packagePath) 'repair.log' +Verify-Payload +Invoke-Msi @('/x',$packagePath) 'uninstall.log' +foreach($name in $expected.Keys){if(Test-Path -LiteralPath (Join-Path $installation $name)){throw 'MSI payload remains after uninstall.'}} +if(Test-Path -LiteralPath $registry){throw 'MSI registration remains after uninstall.'} +$version=Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion' +[ordered]@{passed=$true;windows=$version.ProductName;build=$version.CurrentBuildNumber;installation_type=$version.InstallationType;unsigned_test=$true;install=$true;repair=$true;uninstall=$true;scope='MSI payload lifecycle under the CI account; no enrollment, standard-user GUI, signature acceptance or remote session'}|ConvertTo-Json|Set-Content -LiteralPath (Join-Path $root 'result.json') +Write-Output ('Technician MSI lifecycle passed; evidence: '+$root) From 7e05faf6b12d82422dcbcbfb52ad4b0d21b2f516 Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Sat, 3 Oct 2026 11:07:12 +0200 Subject: [PATCH 218/652] Run pinned technician MSI verification independently of full builds --- .github/workflows/company-product.yml | 51 +++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/.github/workflows/company-product.yml b/.github/workflows/company-product.yml index 2a8b4ed9e6f..8a380b214bb 100644 --- a/.github/workflows/company-product.yml +++ b/.github/workflows/company-product.yml @@ -1,17 +1,24 @@ 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 push: paths: ['product/**', 'deployment/**', '.dockerignore', '.github/workflows/company-product.yml'] pull_request: paths: ['product/**', 'deployment/**', '.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: verify: + if: github.event_name != 'workflow_dispatch' || !inputs.technician_msi_only strategy: matrix: os: [ubuntu-24.04, windows-2022] @@ -38,6 +45,7 @@ jobs: 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.' } linux-deployment: + if: github.event_name != 'workflow_dispatch' || !inputs.technician_msi_only runs-on: ubuntu-24.04 steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 @@ -49,3 +57,46 @@ jobs: 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 + technician-msi-lifecycle: + if: github.event_name == 'workflow_dispatch' && inputs.technician_msi_only + runs-on: 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: 37103083258}); + if (run.data.conclusion !== 'success' || run.data.head_sha !== '11faae30b0dac275c7d8c795ca3c3c134ded36c9') { + 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: 37103083258 + 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] + } + } + 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 + if-no-files-found: ignore + path: ${{ runner.temp }}/swan-msi-lifecycle-*/** From f51ea5a6c930d79cbe76b14319d8787d23cd5404 Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Sat, 3 Oct 2026 11:09:49 +0200 Subject: [PATCH 219/652] Pin verified agent hash for the older MSI fixture --- .github/workflows/company-product.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/company-product.yml b/.github/workflows/company-product.yml index 8a380b214bb..073e3921841 100644 --- a/.github/workflows/company-product.yml +++ b/.github/workflows/company-product.yml @@ -87,6 +87,9 @@ jobs: $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'] = 'cd2bdbc5c14cca1b9b24c16fe22f590cca96eb92cf45465085b3880dcd109631' 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.' } } From b69abc525749bdff373ffe8d8607faf7473f4a3b Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Sat, 3 Oct 2026 11:15:43 +0200 Subject: [PATCH 220/652] Bind MSI lifecycle command arrays explicitly --- deployment/windows/test_technician_msi_lifecycle.ps1 | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/deployment/windows/test_technician_msi_lifecycle.ps1 b/deployment/windows/test_technician_msi_lifecycle.ps1 index 809ed7e1872..48e5574bc25 100644 --- a/deployment/windows/test_technician_msi_lifecycle.ps1 +++ b/deployment/windows/test_technician_msi_lifecycle.ps1 @@ -15,6 +15,7 @@ $installation=Join-Path ([Environment]::GetFolderPath('LocalApplicationData')) ' if(Test-Path -LiteralPath $installation){throw 'Existing technician installation must be preserved.'} New-Item -ItemType Directory -Path $root | Out-Null function Invoke-Msi([string[]]$Arguments,[string]$LogName){ + if($Arguments.Count -ne 2 -or $Arguments[0] -notin @('/i','/fa','/x') -or $Arguments[1] -cne $packagePath -or $LogName -notmatch '^(install|repair|uninstall)\.log$'){throw 'Invalid MSI lifecycle command binding.'} $log=Join-Path $root $LogName $all=@($Arguments)+@('/qn','/norestart','/l*v',$log) if(@($all|Where-Object {$_ -match '["\r\n]'}).Count){throw 'Unsupported MSI argument'} @@ -34,14 +35,14 @@ function Verify-Payload { } if(-not(Test-Path -LiteralPath $registry)){throw 'Technician MSI registration missing.'} } -Invoke-Msi @('/i',$packagePath) 'install.log' +Invoke-Msi -Arguments @('/i',$packagePath) -LogName 'install.log' Verify-Payload $repairTarget=[IO.Path]::GetFullPath((Join-Path $installation 'SwanRemoteSupport-Technician.exe')) if(-not $repairTarget.StartsWith([IO.Path]::GetFullPath($installation)+'\',[StringComparison]::OrdinalIgnoreCase)){throw 'Repair target escaped owned installation.'} Remove-Item -LiteralPath $repairTarget -Invoke-Msi @('/fa',$packagePath) 'repair.log' +Invoke-Msi -Arguments @('/fa',$packagePath) -LogName 'repair.log' Verify-Payload -Invoke-Msi @('/x',$packagePath) 'uninstall.log' +Invoke-Msi -Arguments @('/x',$packagePath) -LogName 'uninstall.log' foreach($name in $expected.Keys){if(Test-Path -LiteralPath (Join-Path $installation $name)){throw 'MSI payload remains after uninstall.'}} if(Test-Path -LiteralPath $registry){throw 'MSI registration remains after uninstall.'} $version=Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion' From 76e89b6fffc9acd602679b8049a7520b6739ba08 Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Sat, 3 Oct 2026 11:20:20 +0200 Subject: [PATCH 221/652] Preserve MSI invocation evidence and quote only file arguments --- deployment/windows/test_technician_msi_lifecycle.ps1 | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/deployment/windows/test_technician_msi_lifecycle.ps1 b/deployment/windows/test_technician_msi_lifecycle.ps1 index 48e5574bc25..ee10b44ed0d 100644 --- a/deployment/windows/test_technician_msi_lifecycle.ps1 +++ b/deployment/windows/test_technician_msi_lifecycle.ps1 @@ -22,7 +22,8 @@ function Invoke-Msi([string[]]$Arguments,[string]$LogName){ $start=[Diagnostics.ProcessStartInfo]::new() $start.FileName=Join-Path $env:SystemRoot 'System32\msiexec.exe' $start.UseShellExecute=$false - $start.Arguments=($all|ForEach-Object {'"'+$_+'"'}) -join ' ' + $start.Arguments=($all|ForEach-Object {if($_.StartsWith('/')){$_}else{'"'+$_+'"'}}) -join ' ' + [ordered]@{executable=$start.FileName;arguments=$start.Arguments;installer_service=(Get-Service msiserver).Status.ToString();started_utc=[DateTime]::UtcNow.ToString('o')}|ConvertTo-Json|Set-Content -LiteralPath (Join-Path $root ($LogName+'.invocation.json')) $process=[Diagnostics.Process]::Start($start) try{ if(-not $process.WaitForExit(120000)){throw 'MSI still running; inspect its process and retained log before retrying.'} From 70b3d4c9a86aaec7d19faeeb75ee445cfc276767 Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Sat, 3 Oct 2026 11:22:19 +0200 Subject: [PATCH 222/652] Record verified Server 2022 technician MSI lifecycle --- docs/ACCEPTANCE.md | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index a2ed29444af..5bf43a84e64 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -2178,3 +2178,27 @@ Four focused fixture checks separately verified private/untracked file exclusion generated input completeness, matching/mismatched submodule pins and link denial. This proves the source archive operation with real build inputs, not complete native compilation, binary reproducibility, signing or release acceptance. + +### Technician MSI lifecycle on a disposable Windows CI runner + +Independent run `37112721733` completed successfully using fixture source +`76e89b6fffc9acd602679b8049a7520b6739ba08` and the pinned unsigned technician +MSI/application/agent from successful native build `11faae3`. Build provenance, +installer checksums and the independently verified agent hash were checked before +execution. The downloaded evidence artifact passed its GitHub SHA-256 and ZIP CRC. +Its result identifies Windows Server 2022 Datacenter, build 20348, installation +type `Server`. Native MSI install, forced repair after removal of the owned +technician executable, and uninstall all returned 0. Installed/repaired executable +and agent hashes matched the inputs; technician registry registration existed +after installation/repair and was absent after uninstall. Uninstall also removed +both payload executables, and the actual update-cancellation custom action passed. + +Earlier CI fixture attempts stopped at a missing agent checksum or timed out +without installer logs. Named array binding and quoting only file arguments +corrected the command invocation; the final run retained invocation records and +all three native MSI logs. Those failed fixtures are not installation passes. +No host installation or existing lab-service change occurred. This is unsigned +MSI payload lifecycle evidence under the CI account, not standard-user company +setup, enrollment, signature acceptance, a graphical session, or the full Server +2022 compatibility matrix. The separate Server 2016 standard-user error 1601 +remains unresolved. From 2aeefd1c853300ea63939c7f13d3ca00799d03af Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Sat, 3 Oct 2026 12:06:21 +0200 Subject: [PATCH 223/652] Record published native artifact and source integrity --- docs/ACCEPTANCE.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index 5bf43a84e64..2ee6923252e 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -2202,3 +2202,18 @@ MSI payload lifecycle evidence under the CI account, not standard-user company setup, enrollment, signature acceptance, a graphical session, or the full Server 2022 compatibility matrix. The separate Server 2016 standard-user error 1601 remains unresolved. + +### Published native build and corresponding source integrity + +Native Windows run `37110881152`, source +`c7d118c4b655d401c14fd95672ae2aff3b5ed679`, passed all four jobs. The downloaded +24-file artifact matched GitHub's authenticated SHA-256 +`c08f556d0ba316754886559e03dae33980f66cc832503e00b14c25b553336cc4`; +ZIP CRC, bounded extraction and published checksums passed. Its corresponding +source archive independently matched 1,101 public Git files, including the pinned +submodule, and six verified generated bridge files. All source manifest hashes +passed. The server setup's exact resource recipe, eleven payload hashes, embedded +source receipt, six packaging source inputs and component pins also passed. +The receipt truthfully records a dirty build working tree. This verifies supplied +build inputs and artifact integrity; it does not establish bit-for-bit binary +reproducibility, production signing or the outstanding Windows acceptance tests. From 908b447f7117c08f63315a8fbdff924b1edd4cd0 Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Sat, 3 Oct 2026 12:07:59 +0200 Subject: [PATCH 224/652] Allow isolated technician MSI testing on Server 2025 --- .github/workflows/company-product.yml | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/.github/workflows/company-product.yml b/.github/workflows/company-product.yml index 073e3921841..2266913d74b 100644 --- a/.github/workflows/company-product.yml +++ b/.github/workflows/company-product.yml @@ -6,6 +6,11 @@ on: 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/**', '.dockerignore', '.github/workflows/company-product.yml'] pull_request: @@ -59,7 +64,7 @@ jobs: run: python3 deployment/linux/test_container_stack.py swan-management:test technician-msi-lifecycle: if: github.event_name == 'workflow_dispatch' && inputs.technician_msi_only - runs-on: windows-2022 + runs-on: ${{ inputs.technician_msi_os || 'windows-2022' }} steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - name: Verify the pinned completed unsigned build @@ -100,6 +105,6 @@ jobs: if: always() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: - name: technician-msi-lifecycle + name: technician-msi-lifecycle-${{ inputs.technician_msi_os || 'windows-2022' }} if-no-files-found: ignore path: ${{ runner.temp }}/swan-msi-lifecycle-*/** From 172732d9243f3fbd60627feb1edfa98a58496e02 Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Sat, 3 Oct 2026 12:10:04 +0200 Subject: [PATCH 225/652] Record verified Server 2025 technician MSI lifecycle --- docs/ACCEPTANCE.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index 2ee6923252e..5cbb040f3dd 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -2217,3 +2217,18 @@ source receipt, six packaging source inputs and component pins also passed. The receipt truthfully records a dirty build working tree. This verifies supplied build inputs and artifact integrity; it does not establish bit-for-bit binary reproducibility, production signing or the outstanding Windows acceptance tests. + +### Technician MSI lifecycle on Server 2025 + +Run `37115402384`, fixture source `908b447f7117c08f63315a8fbdff924b1edd4cd0`, +passed on a disposable Windows Server 2025 Datacenter runner, build 26100, +installation type `Server`. It consumed the same pinned unsigned build `11faae3` +as the Server 2022 lifecycle fixture. Actual installation, forced repair after +deleting the owned executable, and uninstall each returned zero. Payload hashes +and registry presence/removal checks passed. The retained invocation records, +result and native MSI logs were downloaded only after checking the successful +run/source; the evidence artifact matched its authenticated GitHub digest and ZIP +CRC. This covers MSI payload lifecycle under the CI account, not company setup, +standard-user installation, graphical sessions, signing or full Server 2025 +acceptance. The local Server 2025 VM remains unbooted because available host +memory is below its configured safety reserve; existing services were preserved. From 8ad9b17c75405da0d0363635f7ba19a901c29c3a Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Sat, 3 Oct 2026 12:10:39 +0200 Subject: [PATCH 226/652] Test the verified current native installer inputs --- .github/workflows/company-product.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/company-product.yml b/.github/workflows/company-product.yml index 2266913d74b..3a5c88060c4 100644 --- a/.github/workflows/company-product.yml +++ b/.github/workflows/company-product.yml @@ -71,15 +71,15 @@ jobs: 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: 37103083258}); - if (run.data.conclusion !== 'success' || run.data.head_sha !== '11faae30b0dac275c7d8c795ca3c3c134ded36c9') { + 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: 37103083258 + run-id: 37110881152 github-token: ${{ github.token }} path: artifacts - name: Verify payload hashes before MSI execution @@ -94,7 +94,7 @@ jobs: } # This older build's checksum list covers installers only. Pin its # agent hash from the independently verified GitHub artifact archive. - $expected['swan-agent.exe'] = 'cd2bdbc5c14cca1b9b24c16fe22f590cca96eb92cf45465085b3880dcd109631' + $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.' } } From 0cb873660fbae9bae2ae486b7069bde852700064 Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Sat, 3 Oct 2026 12:12:24 +0200 Subject: [PATCH 227/652] Record current native MSI lifecycle on Server 2025 --- docs/ACCEPTANCE.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index 5cbb040f3dd..0da4f23f598 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -2232,3 +2232,10 @@ CRC. This covers MSI payload lifecycle under the CI account, not company setup, standard-user installation, graphical sessions, signing or full Server 2025 acceptance. The local Server 2025 VM remains unbooted because available host memory is below its configured safety reserve; existing services were preserved. + +The fixture was then advanced to independently verified native build `c7d118c` +(run `37110881152`), including its current agent hash. Run `37115549339`, fixture +`8ad9b17c75405da0d0363635f7ba19a901c29c3a`, passed the same actual MSI install, +repair and uninstall checks on Server 2025 Datacenter build 26100. Its retained +result and logs passed authenticated artifact digest and CRC verification. This +provides current-package lifecycle evidence with the same limited scope above. From 7fe9d4cc41f6bdd6ecaec569459ae7ca809c9bbc Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Sat, 3 Oct 2026 13:00:32 +0200 Subject: [PATCH 228/652] Allow technician cached startup after native sync errors --- .github/workflows/company-product.yml | 6 +++ deployment/windows/Open-Technician.ps1 | 15 +++++- .../windows/test_technician_launcher.ps1 | 46 +++++++++++++++++++ docs/ACCEPTANCE.md | 21 +++++++++ 4 files changed, 86 insertions(+), 2 deletions(-) create mode 100644 deployment/windows/test_technician_launcher.ps1 diff --git a/.github/workflows/company-product.yml b/.github/workflows/company-product.yml index 3a5c88060c4..f25b9974dcd 100644 --- a/.github/workflows/company-product.yml +++ b/.github/workflows/company-product.yml @@ -43,6 +43,12 @@ jobs: ./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: Render the Windows setup wizard without installation if: runner.os == 'Windows' shell: pwsh diff --git a/deployment/windows/Open-Technician.ps1 b/deployment/windows/Open-Technician.ps1 index 0b20fbfbb95..4a5f862c59a 100644 --- a/deployment/windows/Open-Technician.ps1 +++ b/deployment/windows/Open-Technician.ps1 @@ -4,8 +4,19 @@ $ErrorActionPreference = 'Stop' $env:SWAN_STATE_DIR = Join-Path $env:LOCALAPPDATA 'SwanRemoteSupport-Technician' $agent = Join-Path $env:SWAN_STATE_DIR 'swan-agent.exe' if ((Get-AuthenticodeSignature -LiteralPath $agent).Status -ne 'Valid') { throw 'Technician agent requires a valid signature.' } -& $agent sync -if ($LASTEXITCODE -ne 0) { Write-Warning 'Company server unavailable. Cached branding can be displayed; authorization still requires valid company policy.' } +# Windows PowerShell turns redirected native stderr into terminating errors under +# Stop. Observe the actual process exit so an offline sync can use cached branding. +$syncStart = [Diagnostics.ProcessStartInfo]::new() +$syncStart.FileName = $agent +$syncStart.Arguments = 'sync' +$syncStart.UseShellExecute = $false +$syncStart.CreateNoWindow = $true +$syncProcess = [Diagnostics.Process]::Start($syncStart) +try { + $syncProcess.WaitForExit() + $syncExit = $syncProcess.ExitCode +} finally { $syncProcess.Dispose() } +if ($syncExit -ne 0) { Write-Warning 'Company server unavailable. Cached branding can be displayed; authorization still requires valid company policy.' } $portable = Join-Path $env:SWAN_STATE_DIR 'SwanRemoteSupport-Technician.exe' if (-not (Test-Path -LiteralPath $portable)) { throw 'Technician application is not installed. Run the company installation bundle.' diff --git a/deployment/windows/test_technician_launcher.ps1 b/deployment/windows/test_technician_launcher.ps1 new file mode 100644 index 00000000000..eff931e28db --- /dev/null +++ b/deployment/windows/test_technician_launcher.ps1 @@ -0,0 +1,46 @@ +$ErrorActionPreference='Stop' +$root=Join-Path ([IO.Path]::GetTempPath()) ('swan-launcher-test-'+[guid]::NewGuid().ToString('N')) +$installation=Join-Path $root 'SwanRemoteSupport-Technician' +New-Item -ItemType Directory -Path $installation|Out-Null +$oldLocal=$env:LOCALAPPDATA;$oldState=$env:SWAN_STATE_DIR +$oldTrace=$env:SWAN_TEST_TRACE;$oldDeny=$env:SWAN_TEST_DENY +try { + $code=@' +using System; +using System.IO; +public class LauncherFixture { + public static int Main(string[] args) { + var mode=args.Length==0?"application":args[0]; + File.AppendAllText(Environment.GetEnvironmentVariable("SWAN_TEST_TRACE"),mode+"\n"); + if(mode=="sync") { Console.Error.WriteLine("fixture network unavailable"); return 7; } + if(mode=="verify-installed" && Environment.GetEnvironmentVariable("SWAN_TEST_DENY")=="1") { return 9; } + return 0; + } +} +'@ + $agent=Join-Path $installation 'swan-agent.exe' + Add-Type -TypeDefinition $code -OutputAssembly $agent -OutputType ConsoleApplication + Copy-Item -LiteralPath $agent -Destination (Join-Path $installation 'SwanRemoteSupport-Technician.exe') + $env:LOCALAPPDATA=$root + $env:SWAN_TEST_TRACE=Join-Path $root 'trace.txt' + # This isolated fixture tests launcher control flow, not Authenticode trust. + function Get-AuthenticodeSignature { param([string]$LiteralPath) [pscustomobject]@{Status='Valid'} } + $launcher=Join-Path $PSScriptRoot 'Open-Technician.ps1' + $env:SWAN_TEST_DENY='0' + & $launcher + if((Get-Content $env:SWAN_TEST_TRACE -Raw) -cne "sync`nverify-installed`napplication`n"){throw 'Offline launcher did not verify before starting application'} + [IO.File]::WriteAllText($env:SWAN_TEST_TRACE,'') + $env:SWAN_TEST_DENY='1' + $rejected=$false + try { & $launcher } catch { if($_.Exception.Message -cne 'Technician installed identity or pinned publisher verification failed.'){throw};$rejected=$true } + if(-not $rejected -or (Get-Content $env:SWAN_TEST_TRACE -Raw) -cne "sync`nverify-installed`n"){throw 'Invalid installed package was launched'} + Write-Output 'Offline launch and installed-verification rejection passed (fixture scope).' +} finally { + Remove-Item Function:\Get-AuthenticodeSignature -ErrorAction SilentlyContinue + $env:LOCALAPPDATA=$oldLocal;$env:SWAN_STATE_DIR=$oldState + $env:SWAN_TEST_TRACE=$oldTrace;$env:SWAN_TEST_DENY=$oldDeny + # Delete only the unique fixture directory after checking its absolute parent. + $resolved=[IO.Path]::GetFullPath($root) + if([IO.Path]::GetDirectoryName($resolved) -ine [IO.Path]::GetFullPath([IO.Path]::GetTempPath()).TrimEnd('\') -or [IO.Path]::GetFileName($resolved) -notmatch '^swan-launcher-test-[a-f0-9]{32}$'){throw 'Fixture cleanup path changed'} + Remove-Item -LiteralPath $resolved -Recurse -Force +} diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index 0da4f23f598..f54037c9133 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -2239,3 +2239,24 @@ The fixture was then advanced to independently verified native build `c7d118c` repair and uninstall checks on Server 2025 Datacenter build 26100. Its retained result and logs passed authenticated artifact digest and CRC verification. This provides current-package lifecycle evidence with the same limited scope above. + +### Standard-user technician offline launcher failure and correction + +After authorized manual-equivalent lab sign-in, an isolated interactive task +ran the installed launcher as `SwanLabTech`, session 1, administrator false. +With management unreachable, Windows PowerShell 5 converted the agent's sync +stderr into terminating `NativeCommandError`. The launcher stopped before its +documented cached-branding warning and installed-package verification. Native +process invocation now observes the actual sync exit code independently of +PowerShell's stderr handling. A Windows PowerShell fixture demonstrates offline +startup ordering and denies application launch when installed verification fails; +its mocked signature gate is control-flow evidence only. + +The existing signed lab agent/application independently passed real signature, +hash and pinned-publisher verification. With only the launcher development fix +applied, the same owned task reached the offline warning, passed real installed +verification and started the technician executable under the standard account. +Its original launcher and failed diagnostic logs remain preserved privately. +The executable subsequently exited without a visible technician window. This +fixes the observed launcher failure; graphical startup and native sessions remain +unverified and require further investigation. No host production service changed. From 01d24ab97cef1c7d9d29da252c55ea33a747c18f Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Sat, 3 Oct 2026 13:08:01 +0200 Subject: [PATCH 229/652] Keep valid standard handles for technician portable startup --- deployment/windows/Open-Technician.ps1 | 22 +++++++++++++++++-- .../windows/test_technician_launcher.ps1 | 22 ++++++++++++++++++- docs/ACCEPTANCE.md | 18 +++++++++++++++ 3 files changed, 59 insertions(+), 3 deletions(-) diff --git a/deployment/windows/Open-Technician.ps1 b/deployment/windows/Open-Technician.ps1 index 4a5f862c59a..c8cf2d9d44d 100644 --- a/deployment/windows/Open-Technician.ps1 +++ b/deployment/windows/Open-Technician.ps1 @@ -25,5 +25,23 @@ if (-not (Test-Path -LiteralPath $portable)) { if ($LASTEXITCODE -ne 0) { throw 'Technician installed identity or pinned publisher verification failed.' } # Login, inventory and ticket creation take place in the graphical application. # No password, MFA code, token or grant is passed in process arguments/environment. -& $portable -if ($LASTEXITCODE -ne 0) { throw 'Technician application could not start.' } +# The portable GUI wrapper can abort with invalid inherited console handles in +# a hidden Windows PowerShell session. Supply real pipes and drain both streams. +# Keep the readers alive until the spawned application's inherited pipes close. +$portableStart = [Diagnostics.ProcessStartInfo]::new() +$portableStart.FileName = $portable +$portableStart.WorkingDirectory = $env:SWAN_STATE_DIR +$portableStart.UseShellExecute = $false +$portableStart.RedirectStandardInput = $true +$portableStart.RedirectStandardOutput = $true +$portableStart.RedirectStandardError = $true +$portableProcess = [Diagnostics.Process]::Start($portableStart) +try { + $portableProcess.StandardInput.Close() + $output = $portableProcess.StandardOutput.ReadToEndAsync() + $errors = $portableProcess.StandardError.ReadToEndAsync() + $portableProcess.WaitForExit() + if ($portableProcess.ExitCode -ne 0) { throw 'Technician application could not start.' } + $null = $output.GetAwaiter().GetResult() + $null = $errors.GetAwaiter().GetResult() +} finally { $portableProcess.Dispose() } diff --git a/deployment/windows/test_technician_launcher.ps1 b/deployment/windows/test_technician_launcher.ps1 index eff931e28db..93502665ae0 100644 --- a/deployment/windows/test_technician_launcher.ps1 +++ b/deployment/windows/test_technician_launcher.ps1 @@ -4,6 +4,7 @@ $installation=Join-Path $root 'SwanRemoteSupport-Technician' New-Item -ItemType Directory -Path $installation|Out-Null $oldLocal=$env:LOCALAPPDATA;$oldState=$env:SWAN_STATE_DIR $oldTrace=$env:SWAN_TEST_TRACE;$oldDeny=$env:SWAN_TEST_DENY +$oldChild=$env:SWAN_TEST_CHILD try { $code=@' using System; @@ -12,8 +13,21 @@ public class LauncherFixture { public static int Main(string[] args) { var mode=args.Length==0?"application":args[0]; File.AppendAllText(Environment.GetEnvironmentVariable("SWAN_TEST_TRACE"),mode+"\n"); + if(mode=="child") { + Console.Out.Write(new string('o',131072)); + Console.Error.Write(new string('e',131072)); + System.Threading.Thread.Sleep(200); + File.AppendAllText(Environment.GetEnvironmentVariable("SWAN_TEST_TRACE"),"child-completed\n"); + return 0; + } if(mode=="sync") { Console.Error.WriteLine("fixture network unavailable"); return 7; } if(mode=="verify-installed" && Environment.GetEnvironmentVariable("SWAN_TEST_DENY")=="1") { return 9; } + if(mode=="application" && Environment.GetEnvironmentVariable("SWAN_TEST_CHILD")=="1") { + System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo { + FileName=System.Reflection.Assembly.GetExecutingAssembly().Location, + Arguments="child",UseShellExecute=false + }).Dispose(); + } return 0; } } @@ -27,6 +41,7 @@ public class LauncherFixture { function Get-AuthenticodeSignature { param([string]$LiteralPath) [pscustomobject]@{Status='Valid'} } $launcher=Join-Path $PSScriptRoot 'Open-Technician.ps1' $env:SWAN_TEST_DENY='0' + $env:SWAN_TEST_CHILD='0' & $launcher if((Get-Content $env:SWAN_TEST_TRACE -Raw) -cne "sync`nverify-installed`napplication`n"){throw 'Offline launcher did not verify before starting application'} [IO.File]::WriteAllText($env:SWAN_TEST_TRACE,'') @@ -34,11 +49,16 @@ public class LauncherFixture { $rejected=$false try { & $launcher } catch { if($_.Exception.Message -cne 'Technician installed identity or pinned publisher verification failed.'){throw};$rejected=$true } if(-not $rejected -or (Get-Content $env:SWAN_TEST_TRACE -Raw) -cne "sync`nverify-installed`n"){throw 'Invalid installed package was launched'} - Write-Output 'Offline launch and installed-verification rejection passed (fixture scope).' + [IO.File]::WriteAllText($env:SWAN_TEST_TRACE,'') + $env:SWAN_TEST_DENY='0';$env:SWAN_TEST_CHILD='1' + & $launcher + if((Get-Content $env:SWAN_TEST_TRACE -Raw) -cne "sync`nverify-installed`napplication`nchild`nchild-completed`n"){throw 'Portable child did not finish with live drained output handles'} + Write-Output 'Offline launch, installed-verification rejection and child output lifetime passed (fixture scope).' } finally { Remove-Item Function:\Get-AuthenticodeSignature -ErrorAction SilentlyContinue $env:LOCALAPPDATA=$oldLocal;$env:SWAN_STATE_DIR=$oldState $env:SWAN_TEST_TRACE=$oldTrace;$env:SWAN_TEST_DENY=$oldDeny + $env:SWAN_TEST_CHILD=$oldChild # Delete only the unique fixture directory after checking its absolute parent. $resolved=[IO.Path]::GetFullPath($root) if([IO.Path]::GetDirectoryName($resolved) -ine [IO.Path]::GetFullPath([IO.Path]::GetTempPath()).TrimEnd('\') -or [IO.Path]::GetFileName($resolved) -notmatch '^swan-launcher-test-[a-f0-9]{32}$'){throw 'Fixture cleanup path changed'} diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index f54037c9133..8153e448626 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -2260,3 +2260,21 @@ Its original launcher and failed diagnostic logs remain preserved privately. The executable subsequently exited without a visible technician window. This fixes the observed launcher failure; graphical startup and native sessions remain unverified and require further investigation. No host production service changed. + +The subsequent Application Error event identifies the portable technician EXE +itself, exception `0xc0000409`, offset `0x2ab55`. An owned interactive diagnostic +launched the unchanged signed EXE with explicit standard input/output/error pipes. +It returned zero and spawned `rustdesk.exe` in the same standard-user session; +the actual window displayed the cached company technician name, teal swan/gear +mark, company HTTPS domain, support link, username/password/TOTP fields and +product/license attribution. No technician login or native connection passed. + +The launcher now supplies and asynchronously drains the portable wrapper's +standard handles, keeping the readers alive until inherited child pipes close. +The Windows PowerShell fixture additionally spawns a child that writes 128 KiB +to each output stream after its parent exits, and verifies child completion +before launcher return. All three launcher control-flow cases pass. The final +combined launcher still needs a newly generated package and native lifecycle +verification; the observed GUI used the isolated handle diagnostic with the +existing signed lab package. Original failure evidence and existing services +remain preserved. From 6b23cca13d66c3d6225d58f3290add22a24feb4b Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Sat, 3 Oct 2026 13:36:58 +0200 Subject: [PATCH 230/652] Restore required Docker inputs and host-compatible recovery fixture --- .dockerignore | 3 +++ deployment/linux/Dockerfile.dockerignore | 3 +++ product/agent/src/update.rs | 3 +++ 3 files changed, 9 insertions(+) diff --git a/.dockerignore b/.dockerignore index 5aed7b50cec..1f327a9c018 100644 --- a/.dockerignore +++ b/.dockerignore @@ -47,6 +47,7 @@ product/worker/** !product/worker/src/ product/worker/src/** !product/worker/src/main.rs +!product/worker/src/windows.rs !branding/ branding/** !branding/swan-support-mark.svg @@ -56,6 +57,8 @@ deployment/** deployment/windows/** !deployment/windows/Install-Company.ps1 !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 diff --git a/deployment/linux/Dockerfile.dockerignore b/deployment/linux/Dockerfile.dockerignore index 5aed7b50cec..1f327a9c018 100644 --- a/deployment/linux/Dockerfile.dockerignore +++ b/deployment/linux/Dockerfile.dockerignore @@ -47,6 +47,7 @@ product/worker/** !product/worker/src/ product/worker/src/** !product/worker/src/main.rs +!product/worker/src/windows.rs !branding/ branding/** !branding/swan-support-mark.svg @@ -56,6 +57,8 @@ deployment/** deployment/windows/** !deployment/windows/Install-Company.ps1 !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 diff --git a/product/agent/src/update.rs b/product/agent/src/update.rs index 6451b9cdfbd..3e63d46a3c2 100644 --- a/product/agent/src/update.rs +++ b/product/agent/src/update.rs @@ -1068,6 +1068,9 @@ pub(crate) fn test_failed_release_quarantine(state:&AgentState,release:&Release, std::fs::write(preparation.join("pending-update.json"),&pending_bytes).unwrap(); std::fs::write(preparation.join("pending-uninstall"),b"explicit fixture cancellation").unwrap(); let mut requested=installer.clone();requested.sequence=3; + // This fixture exercises interrupted setup, using the runner's signed + // OS binary. Its metadata must support the host running the test. + requested.windows_versions=swan_protocol::WINDOWS_VERSIONS.iter().map(|version|(*version).into()).collect(); let mut invalid=requested.clone();invalid.sha256=digest(b"wrong verified setup bytes"); assert!(state.prepare_installation(&preparation,&SignedEnvelope::sign(&invalid,key).unwrap(),&source,false).is_err()); assert!(!preparation.join("pending-install.json").exists()); From 845478efa26f3f2bef3961b911d7a263daaed803 Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Sat, 3 Oct 2026 13:42:49 +0200 Subject: [PATCH 231/652] Probe disposable transport through its private bridge endpoint --- deployment/linux/test_container_stack.py | 26 ++++++++++++++----- docs/ACCEPTANCE.md | 32 ++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 6 deletions(-) diff --git a/deployment/linux/test_container_stack.py b/deployment/linux/test_container_stack.py index cd1dc71d8b8..c092ac4b95f 100644 --- a/deployment/linux/test_container_stack.py +++ b/deployment/linux/test_container_stack.py @@ -6,6 +6,7 @@ """ import base64 import json +import ipaddress import pathlib import re import secrets @@ -124,7 +125,7 @@ def complete_backup(image, prefix, volumes, lab, proxy_image, transport_image, c 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"]: - nat_probe(port(names["hbbs"], service)) + 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) @@ -185,7 +186,20 @@ def public_file(name, remote, destination): raise RuntimeError("Component did not publish its public trust file") -def nat_probe(listener): +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") + nat_probe(int(service.split("/")[0]), address) + + +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): @@ -209,7 +223,7 @@ def integer(data, offset): return value, offset raise RuntimeError("Invalid NAT response integer") - with socket.create_connection(("127.0.0.1", listener), timeout=5) as connection: + 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) @@ -218,7 +232,7 @@ def integer(data, offset): if length > 65536: raise RuntimeError("NAT response exceeds probe limit") frame = exact(connection, length) - if frame.startswith(b"\xc2\x01"): + 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") @@ -364,7 +378,7 @@ def main(): with socket.create_connection(("127.0.0.1", port(names[role], service)), timeout=5): pass for service in ["21115/tcp", "21116/tcp"]: - nat_probe(port(names["hbbs"], service)) + 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" @@ -404,7 +418,7 @@ def main(): 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"]: - nat_probe(port(names["hbbs"], service)) + 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) diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index 8153e448626..017fcc48113 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -2278,3 +2278,35 @@ combined launcher still needs a newly generated package and native lifecycle verification; the observed GUI used the isolated handle diagnostic with the existing signed lab package. Original failure evidence and existing services remain preserved. + +### Combined launcher and deployment-check follow-up (2026-10-03) + +The final combined launcher ran in the owned Server 2016 interactive task as +SwanLabTech, session 1, administrator false. Real installed-publisher verification +passed; the company-branded technician frontend remained open without a recorded +launcher failure. The task remains running while it drains inherited GUI output +pipes. This used the existing signed lab binaries with the development launcher, +not a newly generated release. GUI MFA login and native connections remain +unverified: the computer-use helper could not activate the TigerVNC options modal, +and its documented selection/activation recovery failed. No credentials were +entered into an uncertain field and no alternative UI injection was used. + +The existing guest management service started after its signed executable and +loopback-only proxy configuration were verified. Normal guest HTTPS validation +returned 200 for the company profile; the customer process and installer worker +were preserved. No host service, DNS, firewall or trust settings changed. + +Native Windows build 37120044435 was dispatched once from 01d24ab to embed both +launcher fixes. It is pending; build completion, artifact integrity and generated +package verification must be recorded separately. + +Component run 37120224142 at 6b23cca passed Ubuntu and Windows workspace tests +and builds, including all three Windows PowerShell technician-launcher cases. +The Docker context check, image build, company setup and persistence passed after +adding the three required public inputs to both deny-by-default allowlists. +The later stack probe failed with a reset on the NAT-test socket. The probe now +uses the owned container's private bridge address because upstream hbbs treats +loopback peers on that port as administrative text connections. Published host +sockets retain separate connection checks. The KeyExchange protobuf tag was also +corrected to field 25. Actual stack verification of this change is pending; this +is not public NAT traversal or remote-session evidence. From 10daa674b91fda0b944d53ce48e155b969263990 Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Sat, 3 Oct 2026 13:47:38 +0200 Subject: [PATCH 232/652] Retain owned transport startup evidence on failed NAT probe --- deployment/linux/test_container_stack.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/deployment/linux/test_container_stack.py b/deployment/linux/test_container_stack.py index c092ac4b95f..a14146bb9e5 100644 --- a/deployment/linux/test_container_stack.py +++ b/deployment/linux/test_container_stack.py @@ -196,7 +196,15 @@ def container_nat_probe(name, service): raise RuntimeError("NAT probe requires a disposable private bridge endpoint") if service not in ["21115/tcp", "21116/tcp"]: raise RuntimeError("Unexpected NAT-test port") - nat_probe(int(service.split("/")[0]), address) + try: + nat_probe(int(service.split("/")[0]), address) + 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"): From 41fcc366872bc807188063b828ad248a3a1a4f85 Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Sat, 3 Oct 2026 13:50:55 +0200 Subject: [PATCH 233/652] Wait for owned hbbs listener before protocol readiness check --- deployment/linux/test_container_stack.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/deployment/linux/test_container_stack.py b/deployment/linux/test_container_stack.py index a14146bb9e5..a1a9adae4f8 100644 --- a/deployment/linux/test_container_stack.py +++ b/deployment/linux/test_container_stack.py @@ -197,7 +197,18 @@ def container_nat_probe(name, service): if service not in ["21115/tcp", "21116/tcp"]: raise RuntimeError("Unexpected NAT-test port") try: - nat_probe(int(service.split("/")[0]), address) + 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 From 3c3ce43607cf321fe254dac6a3bdaa21f2ee6dc8 Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Sat, 3 Oct 2026 13:55:13 +0200 Subject: [PATCH 234/652] Classify backup fixture failures without exposing captured secrets --- deployment/linux/test_container_startup.py | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/deployment/linux/test_container_startup.py b/deployment/linux/test_container_startup.py index d545acc435e..fbbd8a01797 100644 --- a/deployment/linux/test_container_startup.py +++ b/deployment/linux/test_container_startup.py @@ -74,7 +74,21 @@ def secret_command(arguments, passphrase, expected_success=True): result = subprocess.run(["docker", *arguments], input=passphrase + "\n", capture_output=True, text=True, timeout=90) if (result.returncode == 0) != expected_success: - raise RuntimeError("Unexpected encrypted backup/restore command result") + # Report only known error categories, never captured output: diagnostics + # can contain local paths, deployment settings or Docker arguments. + categories = { + "sqlite_readonly": "attempt to write a readonly database", + "sqlite_open": "unable to open database file", + "sqlite_locked": "database is locked", + "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 in result.stderr] + raise RuntimeError("Unexpected encrypted backup/restore command result; exit=" + + str(result.returncode) + "; categories=" + repr(matched)) if not expected_success and "Backup password incorrect or backup modified" not in result.stderr: raise RuntimeError("Restore failed before demonstrating backup authentication rejection") From 08dda64d2593668c5712d6e1f7688919057d2aa1 Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Sat, 3 Oct 2026 13:59:10 +0200 Subject: [PATCH 235/652] Report SQLite and filesystem backup error categories --- deployment/linux/test_container_startup.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/deployment/linux/test_container_startup.py b/deployment/linux/test_container_startup.py index fbbd8a01797..b8cf90e3e5d 100644 --- a/deployment/linux/test_container_startup.py +++ b/deployment/linux/test_container_startup.py @@ -80,15 +80,19 @@ def secret_command(arguments, passphrase, expected_success=True): "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 in result.stderr] + 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)) + 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") From 8b3fa98c6fdf79dc48e3300e0dd494f45c327d15 Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Sat, 3 Oct 2026 14:03:40 +0200 Subject: [PATCH 236/652] Exercise distinct complete exports from stopped deployment volumes --- deployment/linux/test_container_stack.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/deployment/linux/test_container_stack.py b/deployment/linux/test_container_stack.py index a1a9adae4f8..21363bb9840 100644 --- a/deployment/linux/test_container_stack.py +++ b/deployment/linux/test_container_stack.py @@ -45,12 +45,15 @@ def complete_backup(image, prefix, volumes, lab, proxy_image, transport_image, c "--tmpfs", "/tmp", "--security-opt", "no-new-privileges:true"] read_password = "IFS= read -r SWAN_BACKUP_PASSPHRASE; export SWAN_BACKUP_PASSPHRASE; " try: - 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/complete.swanbackup --transport-directory /transport --tls-directory /tls --deployment-env /local-test.env"], password) + # 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", From d724321eb314b63570a5eb21639f8a57023b1834 Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Sat, 3 Oct 2026 14:08:37 +0200 Subject: [PATCH 237/652] Identify management and transport backup database failures --- deployment/linux/test_container_startup.py | 4 ++++ product/management/src/backup.rs | 8 ++++---- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/deployment/linux/test_container_startup.py b/deployment/linux/test_container_startup.py index b8cf90e3e5d..62b972b9d41 100644 --- a/deployment/linux/test_container_startup.py +++ b/deployment/linux/test_container_startup.py @@ -77,6 +77,10 @@ def secret_command(arguments, passphrase, expected_success=True): # 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", diff --git a/product/management/src/backup.rs b/product/management/src/backup.rs index 4ca7a4cef4a..89b9c910391 100644 --- a/product/management/src/backup.rs +++ b/product/management/src/backup.rs @@ -51,9 +51,9 @@ fn artifact_entry(name:&str)->bool { } pub fn export_deployment_with_artifacts(data:&Path,destination:&Path,password:&str,transport:Option<&Path>,configuration:Option<&Path>,tls_identity:Option<&Path>,tls_directory:Option<&Path>,artifacts:Option<&Path>)->Result<()> { ensure!(!destination.exists(),"Refusing to overwrite an existing backup"); - let db=rusqlite::Connection::open_with_flags(data.join("management.sqlite3"),rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY)?; + let db=rusqlite::Connection::open_with_flags(data.join("management.sqlite3"),rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY).context("Open management database for backup")?; let snapshot=data.join(format!("backup-{}.sqlite3",swan_protocol::random_token())); - db.backup(rusqlite::DatabaseName::Main,&snapshot,None)?; + db.backup(rusqlite::DatabaseName::Main,&snapshot,None).context("Snapshot management database for backup")?; let mut snapshots=vec![snapshot.clone()]; let result=(||->Result<()> { let mut archive=zip::ZipWriter::new(Cursor::new(Vec::new())); @@ -63,8 +63,8 @@ pub fn export_deployment_with_artifacts(data:&Path,destination:&Path,password:&s files.push(("transport-id_ed25519".into(),directory.join("id_ed25519")));files.push(("transport-id_ed25519.pub".into(),directory.join("id_ed25519.pub"))); if directory.join("db_v2.sqlite3").exists(){ let transport_snapshot=data.join(format!("backup-transport-{}.sqlite3",swan_protocol::random_token()));snapshots.push(transport_snapshot.clone()); - let transport_db=rusqlite::Connection::open_with_flags(directory.join("db_v2.sqlite3"),rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY)?; - transport_db.backup(rusqlite::DatabaseName::Main,&transport_snapshot,None)?; + let transport_db=rusqlite::Connection::open_with_flags(directory.join("db_v2.sqlite3"),rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY).context("Open transport database for backup")?; + transport_db.backup(rusqlite::DatabaseName::Main,&transport_snapshot,None).context("Snapshot transport database for backup")?; files.push(("transport-db.sqlite3".into(),transport_snapshot)); } } From 3cf41e78811bab1109ea7016d3cc041f1435e584 Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Sat, 3 Oct 2026 14:22:04 +0200 Subject: [PATCH 238/652] Record rebuilt technician MSI lifecycle and Linux export evidence --- docs/ACCEPTANCE.md | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index 017fcc48113..aa35d463da4 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -2310,3 +2310,39 @@ loopback peers on that port as administrative text connections. Published host sockets retain separate connection checks. The KeyExchange protobuf tag was also corrected to field 25. Actual stack verification of this change is pending; this is not public NAT traversal or remote-session evidence. + +### Native launcher packaging and Linux export follow-up (2026-10-03) + +Native build [37120044435](https://github.com/rijff24/SwanRemoteSupport/actions/runs/37120044435) +passed all four jobs from `01d24ab97cef1c7d9d29da252c55ea33a747c18f`, +including both MSIs and company server setup. Its technician lifecycle evidence +artifact `11273971224`, SHA-256 +`ba8e4a0d4967cb0f0dfdaf068578cc75b89736c622e2f3687f0928bbb825ba42`, +passed authenticated digest verification and ZIP CRC. The actual retained native +logs show zero returns for install, repair and uninstall and no `Return value 3`. +The fixture's payload and registration checks passed on Windows Server 2022 +Datacenter build 20348. This is unsigned MSI payload lifecycle under the CI +account; standard-user company enrollment, GUI authentication, signed upgrades +and native remote sessions remain separate requirements. Main package download +and independent payload/source verification are still pending. + +The new run's six-file generated bridge artifact `11273340217`, SHA-256 +`79f58d06a556f10da21d83d064e13c7de81a160c236299f70b5f61bfbfa46264`, +passed source-revision, digest, recipe and CRC verification. This is generated +build-input integrity, not binary reproducibility. + +Both component runs at `d724321` passed: push `37121916090` and pull request +`37121918050`. Their Linux deployment checks include the corrected private-bridge +NAT protocol probe, bounded readiness of the same owned container, three distinct +complete exports from stopped read-only transport/TLS volumes, authentication +failure without restore writes, actual restore, and retained company, transport, +TLS, MFA, device and consent identity. Host-published sockets retain separate +checks. This remains an isolated deployment rehearsal, not external NAT traversal +or an authorized remote desktop session. + +Earlier parallel Linux runs intermittently failed complete export with SQLite +error code 14 (`SQLITE_CANTOPEN`). They remain failed evidence despite subsequent +passing exports. Distinct management/transport open and snapshot error contexts +and redacted error-category reporting were added; no captured backup output or +credentials are printed. The precise failed operation has not yet been reproduced +with those contexts. Backup reliability remains an open concern. From 8fc7ed048bb1d2d95e01c56c50a10949394eb133 Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Sat, 3 Oct 2026 14:28:41 +0200 Subject: [PATCH 239/652] Test committed transport WAL export from read-only storage --- product/management/src/backup.rs | 33 ++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/product/management/src/backup.rs b/product/management/src/backup.rs index 89b9c910391..d2066ec7d75 100644 --- a/product/management/src/backup.rs +++ b/product/management/src/backup.rs @@ -162,6 +162,39 @@ pub fn restore(data:&Path,source:&Path,password:&str)->Result<()> { #[cfg(test)] mod tests { use super::*; + #[cfg(unix)] + #[test] + fn readonly_transport_wal_export_preserves_committed_peers() { + use std::os::unix::fs::PermissionsExt; + let root=std::env::temp_dir().join(format!("swan-readonly-wal-backup-{}",swan_protocol::random_token())); + let data=root.join("data");let original=root.join("original");let transport=root.join("transport"); + for directory in [&data,&original,&transport]{std::fs::create_dir_all(directory).unwrap();} + let management=rusqlite::Connection::open(data.join("management.sqlite3")).unwrap(); + management.execute_batch("CREATE TABLE identity(value TEXT);INSERT INTO identity VALUES('retained company');").unwrap();drop(management); + std::fs::write(data.join("profile-key.hex"),b"fixture key").unwrap();std::fs::write(data.join("setup-token.txt"),b"fixture token").unwrap(); + let writer=rusqlite::Connection::open(original.join("db_v2.sqlite3")).unwrap(); + writer.execute_batch("PRAGMA journal_mode=WAL;PRAGMA wal_autocheckpoint=0;CREATE TABLE peers(id TEXT);INSERT INTO peers VALUES('committed WAL peer');").unwrap(); + // Model stopped transport storage with committed WAL frames and no SHM + // file, as a read-only backup mount may present it after interruption. + for name in ["db_v2.sqlite3","db_v2.sqlite3-wal"]{std::fs::copy(original.join(name),transport.join(name)).unwrap();} + std::fs::write(transport.join("id_ed25519"),b"fixture private key").unwrap();std::fs::write(transport.join("id_ed25519.pub"),b"fixture public key").unwrap(); + std::fs::set_permissions(&transport,std::fs::Permissions::from_mode(0o555)).unwrap(); + let probe=transport.join("write-probe"); + if std::fs::OpenOptions::new().create_new(true).write(true).open(&probe).is_ok(){ + // Root bypasses Unix mode bits; actual read-only mount coverage is + // provided by the separate container deployment rehearsal. + std::fs::set_permissions(&transport,std::fs::Permissions::from_mode(0o755)).unwrap();drop(writer);std::fs::remove_dir_all(root).unwrap();return; + } + let archive=root.join("complete.backup"); + let result=export_complete(&data,&archive,"readonly WAL test passphrase",Some(&transport),None,None); + let source_unchanged=!transport.join("db_v2.sqlite3-shm").exists(); + std::fs::set_permissions(&transport,std::fs::Permissions::from_mode(0o755)).unwrap();drop(writer); + result.unwrap();assert!(source_unchanged,"Export must not write into read-only transport storage"); + let restored=root.join("restored");restore(&restored,&archive,"readonly WAL test passphrase").unwrap(); + let db=rusqlite::Connection::open(restored.join("transport-db.sqlite3")).unwrap(); + assert_eq!(db.query_row("SELECT id FROM peers",[],|row|row.get::<_,String>(0)).unwrap(),"committed WAL peer");drop(db); + std::fs::remove_dir_all(root).unwrap(); + } #[test] fn installer_artifacts_roundtrip_and_reject_unfinished_or_unsafe_entries() { let root=std::env::temp_dir().join(format!("swan-artifact-backup-{}",swan_protocol::random_token())); From 4359de7151cb3b696b9d36325bcd09d3c7f122a4 Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Sat, 3 Oct 2026 14:33:31 +0200 Subject: [PATCH 240/652] Snapshot read-only transport WAL from private recoverable staging --- product/management/src/backup.rs | 48 ++++++++++++++++++++++++++++++-- 1 file changed, 46 insertions(+), 2 deletions(-) diff --git a/product/management/src/backup.rs b/product/management/src/backup.rs index d2066ec7d75..bae4ab6445a 100644 --- a/product/management/src/backup.rs +++ b/product/management/src/backup.rs @@ -49,6 +49,48 @@ fn artifact_entry(name:&str)->bool { name.strip_prefix("artifacts/").and_then(|name|name.strip_suffix(".zip")) .is_some_and(|id|uuid::Uuid::parse_str(id).is_ok_and(|parsed|parsed.to_string()==id)) } +fn snapshot_transport(directory:&Path,data:&Path,destination:&Path)->Result<()> { + // A stopped WAL database can need SHM initialization or recovery, which + // cannot occur on a read-only transport mount. Recover a private copy; + // never ignore WAL frames or change the original transport storage. + let staging=data.join(format!("backup-transport-source-{}",swan_protocol::random_token())); + let mut builder=std::fs::DirBuilder::new(); + #[cfg(unix)] {use std::os::unix::fs::DirBuilderExt;builder.mode(0o700);} + builder.create(&staging).context("Create private transport backup staging")?; + let result=(||->Result<()> { + let mut total=0u64; + for name in ["db_v2.sqlite3","db_v2.sqlite3-wal","db_v2.sqlite3-journal"] { + let source=directory.join(name); + let metadata=match std::fs::symlink_metadata(&source) { + Ok(value)=>value, + Err(error) if error.kind()==std::io::ErrorKind::NotFound && name!="db_v2.sqlite3"=>continue, + Err(error)=>return Err(error).context("Inspect stopped transport backup source"), + }; + ensure!(metadata.is_file() && !metadata.file_type().is_symlink(),"Transport database backup source must be a regular file"); + #[cfg(windows)] {use std::os::windows::fs::MetadataExt;ensure!(metadata.file_attributes()&0x400==0,"Transport database backup source must not be a reparse point");} + total=total.checked_add(metadata.len()).context("Transport backup source size overflow")?; + ensure!(total<=512*1024*1024,"Transport backup source exceeds 512 MiB"); + let mut input=std::fs::File::open(&source)?; + let mut options=std::fs::OpenOptions::new();options.create_new(true).write(true); + #[cfg(unix)] {use std::os::unix::fs::OpenOptionsExt;options.mode(0o600);} + let mut output=options.open(staging.join(name))?; + ensure!(std::io::copy(&mut input,&mut output)?==metadata.len(),"Transport database changed during backup; stop transport before export"); + } + let transport_db=rusqlite::Connection::open(staging.join("db_v2.sqlite3")).context("Open staged transport database for backup")?; + transport_db.backup(rusqlite::DatabaseName::Main,destination,None).context("Snapshot staged transport database for backup")?; + Ok(()) + })(); + // Only remove the fixed files in the directory created by this operation. + // SQLite handles are dropped before cleanup, including on a failed snapshot. + for name in ["db_v2.sqlite3","db_v2.sqlite3-wal","db_v2.sqlite3-shm","db_v2.sqlite3-journal"] { + match std::fs::remove_file(staging.join(name)) { + Ok(())=>{},Err(error) if error.kind()==std::io::ErrorKind::NotFound=>{}, + Err(error)=>return Err(error).context("Remove private transport backup staging file"), + } + } + std::fs::remove_dir(&staging).context("Remove private transport backup staging directory")?; + result +} pub fn export_deployment_with_artifacts(data:&Path,destination:&Path,password:&str,transport:Option<&Path>,configuration:Option<&Path>,tls_identity:Option<&Path>,tls_directory:Option<&Path>,artifacts:Option<&Path>)->Result<()> { ensure!(!destination.exists(),"Refusing to overwrite an existing backup"); let db=rusqlite::Connection::open_with_flags(data.join("management.sqlite3"),rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY).context("Open management database for backup")?; @@ -63,8 +105,7 @@ pub fn export_deployment_with_artifacts(data:&Path,destination:&Path,password:&s files.push(("transport-id_ed25519".into(),directory.join("id_ed25519")));files.push(("transport-id_ed25519.pub".into(),directory.join("id_ed25519.pub"))); if directory.join("db_v2.sqlite3").exists(){ let transport_snapshot=data.join(format!("backup-transport-{}.sqlite3",swan_protocol::random_token()));snapshots.push(transport_snapshot.clone()); - let transport_db=rusqlite::Connection::open_with_flags(directory.join("db_v2.sqlite3"),rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY).context("Open transport database for backup")?; - transport_db.backup(rusqlite::DatabaseName::Main,&transport_snapshot,None).context("Snapshot transport database for backup")?; + snapshot_transport(directory,data,&transport_snapshot)?; files.push(("transport-db.sqlite3".into(),transport_snapshot)); } } @@ -177,6 +218,7 @@ mod tests { // Model stopped transport storage with committed WAL frames and no SHM // file, as a read-only backup mount may present it after interruption. for name in ["db_v2.sqlite3","db_v2.sqlite3-wal"]{std::fs::copy(original.join(name),transport.join(name)).unwrap();} + let source_database=std::fs::read(transport.join("db_v2.sqlite3")).unwrap();let source_wal=std::fs::read(transport.join("db_v2.sqlite3-wal")).unwrap(); std::fs::write(transport.join("id_ed25519"),b"fixture private key").unwrap();std::fs::write(transport.join("id_ed25519.pub"),b"fixture public key").unwrap(); std::fs::set_permissions(&transport,std::fs::Permissions::from_mode(0o555)).unwrap(); let probe=transport.join("write-probe"); @@ -190,6 +232,8 @@ mod tests { let source_unchanged=!transport.join("db_v2.sqlite3-shm").exists(); std::fs::set_permissions(&transport,std::fs::Permissions::from_mode(0o755)).unwrap();drop(writer); result.unwrap();assert!(source_unchanged,"Export must not write into read-only transport storage"); + assert_eq!(std::fs::read(transport.join("db_v2.sqlite3")).unwrap(),source_database);assert_eq!(std::fs::read(transport.join("db_v2.sqlite3-wal")).unwrap(),source_wal); + assert!(std::fs::read_dir(&data).unwrap().all(|entry|!entry.unwrap().file_name().to_string_lossy().starts_with("backup-")),"Plaintext snapshots and staging must be removed"); let restored=root.join("restored");restore(&restored,&archive,"readonly WAL test passphrase").unwrap(); let db=rusqlite::Connection::open(restored.join("transport-db.sqlite3")).unwrap(); assert_eq!(db.query_row("SELECT id FROM peers",[],|row|row.get::<_,String>(0)).unwrap(),"committed WAL peer");drop(db); From f71773bd54190d14ad0332ff1ac6e5213f3f879b Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Sat, 3 Oct 2026 14:35:54 +0200 Subject: [PATCH 241/652] Bound stopped transport copies and document private recovery backup --- docs/SERVER_BACKUP.md | 12 +++++++++--- product/management/src/backup.rs | 7 ++++--- 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/docs/SERVER_BACKUP.md b/docs/SERVER_BACKUP.md index 4ad0e3bb889..6c5e2c50984 100644 --- a/docs/SERVER_BACKUP.md +++ b/docs/SERVER_BACKUP.md @@ -41,8 +41,13 @@ HTTPS configuration and restore places it back in the replacement data directory Keep configuration edits stopped during export. Older archives without this file remain supported; reconstruct and validate their HTTPS recipe before use. -The transport directory must contain both `id_ed25519` and `id_ed25519.pub`. -When present, `db_v2.sqlite3` is also snapshotted using SQLite's backup API. +Stop transport services before exporting their directory, and keep them stopped +until export completes. The directory must contain both `id_ed25519` and +`id_ed25519.pub`. When present, `db_v2.sqlite3` and its WAL or rollback journal +are copied into private temporary storage, recovered there, and snapshotted using +SQLite's backup API. This preserves committed WAL changes when the stopped +source is mounted read-only and cannot create shared-memory files. Export does +not write into that source. Transport database inputs share a 512 MiB copy limit. Management uses the same API for its database, including committed WAL data. Schema version 2 keeps active and pending profile-signing keys in that database; the original `profile-key.hex` file alone is insufficient after rotation. See @@ -53,7 +58,8 @@ The export does not stop or modify running transport services. All contents are encrypted with AES-256-GCM and an Argon2-derived key. Existing backup files are never overwritten. Temporary plaintext database snapshots are -removed after archive generation. Archives and plaintext contents have size +removed after archive generation, including private transport recovery copies. +Archives and plaintext contents have size limits; this is not a general-purpose filesystem backup tool. Restore into an **empty directory** while the replacement management service diff --git a/product/management/src/backup.rs b/product/management/src/backup.rs index bae4ab6445a..0baad022ced 100644 --- a/product/management/src/backup.rs +++ b/product/management/src/backup.rs @@ -54,7 +54,8 @@ fn snapshot_transport(directory:&Path,data:&Path,destination:&Path)->Result<()> // cannot occur on a read-only transport mount. Recover a private copy; // never ignore WAL frames or change the original transport storage. let staging=data.join(format!("backup-transport-source-{}",swan_protocol::random_token())); - let mut builder=std::fs::DirBuilder::new(); + let builder=std::fs::DirBuilder::new(); + #[cfg(unix)] let mut builder=builder; #[cfg(unix)] {use std::os::unix::fs::DirBuilderExt;builder.mode(0o700);} builder.create(&staging).context("Create private transport backup staging")?; let result=(||->Result<()> { @@ -70,11 +71,11 @@ fn snapshot_transport(directory:&Path,data:&Path,destination:&Path)->Result<()> #[cfg(windows)] {use std::os::windows::fs::MetadataExt;ensure!(metadata.file_attributes()&0x400==0,"Transport database backup source must not be a reparse point");} total=total.checked_add(metadata.len()).context("Transport backup source size overflow")?; ensure!(total<=512*1024*1024,"Transport backup source exceeds 512 MiB"); - let mut input=std::fs::File::open(&source)?; + let input=std::fs::File::open(&source)?; let mut options=std::fs::OpenOptions::new();options.create_new(true).write(true); #[cfg(unix)] {use std::os::unix::fs::OpenOptionsExt;options.mode(0o600);} let mut output=options.open(staging.join(name))?; - ensure!(std::io::copy(&mut input,&mut output)?==metadata.len(),"Transport database changed during backup; stop transport before export"); + ensure!(std::io::copy(&mut input.take(metadata.len()+1),&mut output)?==metadata.len(),"Transport database changed during backup; stop transport before export"); } let transport_db=rusqlite::Connection::open(staging.join("db_v2.sqlite3")).context("Open staged transport database for backup")?; transport_db.backup(rusqlite::DatabaseName::Main,destination,None).context("Snapshot staged transport database for backup")?; From 05d7e28d1e6b50e0901551a768b64071af85a0c8 Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Sat, 3 Oct 2026 14:43:59 +0200 Subject: [PATCH 242/652] Record reproduced WAL backup defect and verified staging fix --- docs/ACCEPTANCE.md | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index aa35d463da4..9b1076932ea 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -2346,3 +2346,33 @@ passing exports. Distinct management/transport open and snapshot error contexts and redacted error-category reporting were added; no captured backup output or credentials are printed. The precise failed operation has not yet been reproduced with those contexts. Backup reliability remains an open concern. + +### Read-only transport WAL defect reproduced and corrected (2026-10-03) + +The Unix regression `readonly_transport_wal_export_preserves_committed_peers` +failed in run `37123006043` at source `8fc7ed0`: transport snapshot returned +SQLite code 14 when committed WAL frames existed, the source directory was +read-only, and no shared-memory file existed. This reproduces a concrete defect +with the same error code as the earlier intermittent exports; those earlier +logs did not identify the precise failed operation. + +Transport export now copies the stopped database and its WAL/rollback journal +into private writable staging, lets SQLite recover and snapshot that copy, and +removes fixed temporary files and the owned directory afterward. Copies are +bounded to the declared file length plus one byte and a combined 512 MiB limit; +regular-file/reparse checks reject unsafe sources. The original transport +storage is untouched. Transport must remain stopped for a consistent multi-file +copy, as documented in `SERVER_BACKUP.md`. + +The regression passed at `f71773bd54190d14ad0332ff1ac6e5213f3f879b`, verifying +restored committed peer data, unchanged source database/WAL bytes and cleanup of +plaintext snapshots/staging. Both full component runs passed: push `37123392820` +and pull request `37123395298`, including Windows tests/builds and the complete +Linux deployment/export/restore rehearsal. Local Windows GNU type checking also +passed; local test execution was unavailable because that toolchain lacked +`libgcc_eh`, and is not counted as a test pass. + +A new full native build `37123744363` was dispatched once from the verified +`f71773b` source to package this runtime fix. It is still pending. The earlier +`01d24ab` installer archive remains in its original live download process; +no partial download was replaced or treated as verified. From 5ae76f3ed93401523d394d70f8e27693df850bb9 Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Sat, 3 Oct 2026 14:48:06 +0200 Subject: [PATCH 243/652] Verify failed transport export removes plaintext staging --- product/management/src/backup.rs | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/product/management/src/backup.rs b/product/management/src/backup.rs index 0baad022ced..51323b90cee 100644 --- a/product/management/src/backup.rs +++ b/product/management/src/backup.rs @@ -204,6 +204,22 @@ pub fn restore(data:&Path,source:&Path,password:&str)->Result<()> { #[cfg(test)] mod tests { use super::*; + #[test] + fn failed_transport_snapshot_removes_private_staging_without_changing_source() { + let root=std::env::temp_dir().join(format!("swan-failed-transport-backup-{}",swan_protocol::random_token())); + let data=root.join("data");let transport=root.join("transport");std::fs::create_dir_all(&data).unwrap();std::fs::create_dir(&transport).unwrap(); + let db=rusqlite::Connection::open(data.join("management.sqlite3")).unwrap();db.execute_batch("CREATE TABLE identity(value TEXT);").unwrap();drop(db); + std::fs::write(data.join("profile-key.hex"),b"fixture key").unwrap();std::fs::write(data.join("setup-token.txt"),b"fixture token").unwrap(); + let invalid=b"corrupt transport database fixture"; + std::fs::write(transport.join("db_v2.sqlite3"),invalid).unwrap(); + std::fs::write(transport.join("id_ed25519"),b"fixture private key").unwrap();std::fs::write(transport.join("id_ed25519.pub"),b"fixture public key").unwrap(); + let archive=root.join("rejected.backup"); + let error=export_complete(&data,&archive,"failed backup test passphrase",Some(&transport),None,None).unwrap_err(); + assert!(format!("{error:#}").contains("transport database"));assert!(!archive.exists()); + assert_eq!(std::fs::read(transport.join("db_v2.sqlite3")).unwrap(),invalid); + assert!(std::fs::read_dir(&data).unwrap().all(|entry|!entry.unwrap().file_name().to_string_lossy().starts_with("backup-")),"Failed export must remove plaintext snapshots and staging"); + std::fs::remove_dir_all(root).unwrap(); + } #[cfg(unix)] #[test] fn readonly_transport_wal_export_preserves_committed_peers() { From 6852f6fdd6395b0c3fc3e507c84ac1cf1602308e Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Sat, 3 Oct 2026 15:01:22 +0200 Subject: [PATCH 244/652] Record native package provenance and backup cleanup evidence --- docs/ACCEPTANCE.md | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index 9b1076932ea..5bd537839bc 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -2376,3 +2376,31 @@ A new full native build `37123744363` was dispatched once from the verified `f71773b` source to package this runtime fix. It is still pending. The earlier `01d24ab` installer archive remains in its original live download process; no partial download was replaced or treated as verified. + +### Native package provenance and failed-export cleanup (2026-10-03) + +The original download of native run `37120044435` completed successfully. +Its 24-file archive passed authenticated artifact SHA-256 +`61a7618abd11fa1a60138f7ec944499e7d18460f1a86ff1fc6153c884d77f452`, +ZIP CRC and published payload checksums. Independent checks verified all 11 +embedded server setup resources, embedded/external source receipt agreement, +standalone management payload agreement, six public setup source inputs and +pinned transport/proxy hashes. The published source archive matched 1,103 +public Git files at `01d24ab` plus six independently verified generated bridge +files. Its working-tree-dirty flag is retained; matching archived inputs is +source provenance, not proof of a clean checkout or binary reproducibility. + +The packaged technician launcher matched its exact public source after line +ending normalization. The technician MSI, portable executable and agent hashes +matched their build manifest. The retained CI lifecycle receipt does not include +an independent hash of the tested MSI; its successful install/repair/uninstall +evidence remains scoped to that CI job and must not imply fresh-company or +standard-user acceptance. These artifacts predate the `f71773b` backup fix. + +At `5ae76f3`, `failed_transport_snapshot_removes_private_staging_without_changing_source` +passed in actual Windows MSVC and Linux test logs. The fixture verifies that +an invalid transport database fails export, leaves no archive or private plaintext +staging, and preserves the source bytes. Both complete component runs passed: +pull request `37124053449` and push `37124051532`, including the isolated Linux +deployment rehearsal and Windows launcher checks. This test-only commit does +not change the runtime currently being built in native run `37123744363`. From 940c64f0f33fbe826201110d3ae88e7068abf627 Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Sat, 3 Oct 2026 15:02:54 +0200 Subject: [PATCH 245/652] Bind MSI lifecycle receipts to tested package and payload hashes --- deployment/windows/test_technician_msi_lifecycle.ps1 | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/deployment/windows/test_technician_msi_lifecycle.ps1 b/deployment/windows/test_technician_msi_lifecycle.ps1 index ee10b44ed0d..7a24988f001 100644 --- a/deployment/windows/test_technician_msi_lifecycle.ps1 +++ b/deployment/windows/test_technician_msi_lifecycle.ps1 @@ -4,6 +4,7 @@ $ErrorActionPreference='Stop' if($env:GITHUB_ACTIONS -ne 'true' -or $env:RUNNER_OS -ne 'Windows' -or -not $env:RUNNER_TEMP){throw 'This destructive repair fixture is restricted to disposable Windows CI runners.'} $packagePath=(Resolve-Path -LiteralPath $Package).Path if($packagePath -notmatch '-unsigned\.msi$'){throw 'Select an explicitly unsigned test MSI.'} +$packageHash=(Get-FileHash -LiteralPath $packagePath -Algorithm SHA256).Hash.ToLowerInvariant() $expected=@{ 'SwanRemoteSupport-Technician.exe'=(Get-FileHash -LiteralPath $TechnicianExecutable -Algorithm SHA256).Hash 'swan-agent.exe'=(Get-FileHash -LiteralPath $AgentExecutable -Algorithm SHA256).Hash @@ -16,6 +17,7 @@ if(Test-Path -LiteralPath $installation){throw 'Existing technician installation New-Item -ItemType Directory -Path $root | Out-Null function Invoke-Msi([string[]]$Arguments,[string]$LogName){ if($Arguments.Count -ne 2 -or $Arguments[0] -notin @('/i','/fa','/x') -or $Arguments[1] -cne $packagePath -or $LogName -notmatch '^(install|repair|uninstall)\.log$'){throw 'Invalid MSI lifecycle command binding.'} + if((Get-FileHash -LiteralPath $packagePath -Algorithm SHA256).Hash.ToLowerInvariant() -cne $packageHash){throw 'MSI changed between lifecycle operations.'} $log=Join-Path $root $LogName $all=@($Arguments)+@('/qn','/norestart','/l*v',$log) if(@($all|Where-Object {$_ -match '["\r\n]'}).Count){throw 'Unsupported MSI argument'} @@ -23,11 +25,12 @@ function Invoke-Msi([string[]]$Arguments,[string]$LogName){ $start.FileName=Join-Path $env:SystemRoot 'System32\msiexec.exe' $start.UseShellExecute=$false $start.Arguments=($all|ForEach-Object {if($_.StartsWith('/')){$_}else{'"'+$_+'"'}}) -join ' ' - [ordered]@{executable=$start.FileName;arguments=$start.Arguments;installer_service=(Get-Service msiserver).Status.ToString();started_utc=[DateTime]::UtcNow.ToString('o')}|ConvertTo-Json|Set-Content -LiteralPath (Join-Path $root ($LogName+'.invocation.json')) + [ordered]@{executable=$start.FileName;arguments=$start.Arguments;package_sha256=$packageHash;fixture_revision=$env:GITHUB_SHA;installer_service=(Get-Service msiserver).Status.ToString();started_utc=[DateTime]::UtcNow.ToString('o')}|ConvertTo-Json|Set-Content -LiteralPath (Join-Path $root ($LogName+'.invocation.json')) $process=[Diagnostics.Process]::Start($start) try{ if(-not $process.WaitForExit(120000)){throw 'MSI still running; inspect its process and retained log before retrying.'} if($process.ExitCode -ne 0){throw ('MSI failed with '+$process.ExitCode+'; retained log '+$log)} + if((Get-FileHash -LiteralPath $packagePath -Algorithm SHA256).Hash.ToLowerInvariant() -cne $packageHash){throw 'MSI changed during a lifecycle operation.'} }finally{$process.Dispose()} } function Verify-Payload { @@ -47,5 +50,5 @@ Invoke-Msi -Arguments @('/x',$packagePath) -LogName 'uninstall.log' foreach($name in $expected.Keys){if(Test-Path -LiteralPath (Join-Path $installation $name)){throw 'MSI payload remains after uninstall.'}} if(Test-Path -LiteralPath $registry){throw 'MSI registration remains after uninstall.'} $version=Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion' -[ordered]@{passed=$true;windows=$version.ProductName;build=$version.CurrentBuildNumber;installation_type=$version.InstallationType;unsigned_test=$true;install=$true;repair=$true;uninstall=$true;scope='MSI payload lifecycle under the CI account; no enrollment, standard-user GUI, signature acceptance or remote session'}|ConvertTo-Json|Set-Content -LiteralPath (Join-Path $root 'result.json') +[ordered]@{passed=$true;windows=$version.ProductName;build=$version.CurrentBuildNumber;installation_type=$version.InstallationType;fixture_revision=$env:GITHUB_SHA;package_sha256=$packageHash;payload_sha256=$expected;unsigned_test=$true;install=$true;repair=$true;uninstall=$true;scope='MSI payload lifecycle under the CI account; no enrollment, standard-user GUI, signature acceptance or remote session'}|ConvertTo-Json|Set-Content -LiteralPath (Join-Path $root 'result.json') Write-Output ('Technician MSI lifecycle passed; evidence: '+$root) From 58763b1db5c610f6d22eb211740d2c6dfb69c046 Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Sat, 3 Oct 2026 15:05:42 +0200 Subject: [PATCH 246/652] Record independently verified package-bound MSI lifecycle --- docs/ACCEPTANCE.md | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index 5bd537839bc..24d2b6e2cd8 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -2404,3 +2404,25 @@ staging, and preserves the source bytes. Both complete component runs passed: pull request `37124053449` and push `37124051532`, including the isolated Linux deployment rehearsal and Windows launcher checks. This test-only commit does not change the runtime currently being built in native run `37123744363`. + +### MSI evidence bound to tested artifacts (2026-10-03) + +The lifecycle fixture at `940c64f` now records the package SHA-256, expected +installed payload hashes and fixture revision in its retained receipt. Each +native invocation records the package hash, and the fixture checks that the +package remains unchanged before and after each operation. Fixture revision +identifies the test code; it does not imply that older downloaded binaries were +built from that revision. + +Disposable Server 2022 run `37124891111` passed installation, deliberate payload +removal followed by repair, and uninstall against the pinned `c7d118c` package. +Evidence artifact `11275180118` passed authenticated SHA-256 +`9940004bb6893f8cade24d18667bc0ebe5cd3140c84aa72ec37d23b3e8c93def` +and CRC verification. The receipt and all three invocation hashes matched the +independently retained MSI SHA-256 +`5083a718a1b7f367fe680ada622ca8ce6a55c99f55bf80224c7841d3c257979b`; +expected executable/agent hashes matched the retained native build. All three +native logs contained successful zero returns and no `Return value 3`. +This strengthens artifact-specific lifecycle evidence for that older package; +it does not cover the new backup runtime, standard-user enrollment, production +signature acceptance, GUI login or remote desktop sessions. From 391541e0062648dca1299170c0ff1461988cc357 Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Sat, 3 Oct 2026 15:08:17 +0200 Subject: [PATCH 247/652] Reject late lease renewal and packets after managed authorization expires --- product/agent/src/lib.rs | 21 +++++++++++++++++++-- src/server/connection.rs | 8 ++++++++ 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/product/agent/src/lib.rs b/product/agent/src/lib.rs index 9f233778a86..b89e02c7921 100644 --- a/product/agent/src/lib.rs +++ b/product/agent/src/lib.rs @@ -206,11 +206,12 @@ impl AgentState { Ok(Lease { grant_id:grant.grant_id,unattended:grant.unattended,permissions:grant.permissions,expires_at:expires,last_renewed:now(),_activity:activity }) } pub async fn renew(&self,lease:&mut Lease)->Result<()> { + let remaining=lease.expires_at.checked_sub(now()).filter(|seconds|*seconds>0).context("Session authorization expired")?; let profile=self.company_profile()?; ensure!(!lease.unattended || (self.unattended_consent && profile.allow_unattended),"Unattended consent or company permission revoked"); - let result:Value=self.client()?.post(self.endpoint(&format!("grants/{}/renew",lease.grant_id))?).bearer_auth(self.device_token.as_ref().context("Not enrolled")?).send().await?.error_for_status()?.json().await?; + let result:Value=self.client()?.post(self.endpoint(&format!("grants/{}/renew",lease.grant_id))?).timeout(std::time::Duration::from_secs(remaining.min(15))).bearer_auth(self.device_token.as_ref().context("Not enrolled")?).send().await?.error_for_status()?.json().await?; let expires=result["lease_until"].as_u64().context("Missing lease expiry")?; - ensure!(expires>now() && expires<=now()+310,"Invalid session lease"); + validate_lease_renewal(lease.expires_at,expires,now())?; lease.expires_at=expires;lease.last_renewed=now();Ok(()) } pub async fn close(&self,grant_id:&str)->Result<()> { @@ -218,6 +219,14 @@ impl AgentState { } } +fn validate_lease_renewal(previous_expiry:u64,new_expiry:u64,current:u64)->Result<()> { + // An in-flight response cannot resurrect authorization after the device's + // existing lease expired, even when its proposed new expiry is valid. + ensure!(currentcurrent && new_expiry<=current.saturating_add(310),"Invalid session lease"); + Ok(()) +} + fn replace_state(temporary:&Path,path:&Path)->Result<()> { #[cfg(windows)] { use std::os::windows::ffi::OsStrExt; @@ -270,6 +279,14 @@ pub fn http_client(timeout_seconds:u64,artifact_redirects:bool)->ResultAgentState { let key=SigningKey::from_bytes(&[7;32]);let public=STANDARD.encode(key.verifying_key().as_bytes()); let profile=CompanyProfile{schema:1,company_id:"test-company".into(),revision:1,issued_at:now()-1000,expires_at:now()+3600,management_url:"https://support.example.com".into(),rendezvous:"support.example.com".into(),relay:"support.example.com".into(),transport_public_key:public.clone(),customer:Branding::default(),technician:Branding::default(),allow_unattended:true,updates_paused:true,rollout_percent:100,maintenance_start_utc:0,maintenance_end_utc:0,update_channel:"test".into(),next_profile_public_key:None}; diff --git a/src/server/connection.rs b/src/server/connection.rs index 38614f13cf5..9b61f3b07d9 100644 --- a/src/server/connection.rs +++ b/src/server/connection.rs @@ -2544,6 +2544,14 @@ impl Connection { } } if self.authorized { + #[cfg(feature = "swan_custom")] + if self.managed_lease.as_ref().map(|lease| swan_agent::protocol::now() < lease.expires_at) != Some(true) { + // Reject input immediately at expiry instead of relying on the + // next timer tick while packets continue to arrive. + self.send_close_reason_no_retry("Company session authorization expired or revoked").await; + self.on_close("Managed authorization unavailable", true).await; + return false; + } if matches!(msg.union.as_ref(), Some(message::Union::LoginRequest(_))) { return true; } From deb38a81d4e62a0f0614b91375b5a2fd574c1cb1 Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Sat, 3 Oct 2026 15:14:16 +0200 Subject: [PATCH 248/652] Record authorization expiry fix and cross-platform regression results --- docs/ACCEPTANCE.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index 24d2b6e2cd8..23f60e0d45b 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -2426,3 +2426,20 @@ native logs contained successful zero returns and no `Return value 3`. This strengthens artifact-specific lifecycle evidence for that older package; it does not cover the new backup runtime, standard-user enrollment, production signature acceptance, GUI login or remote desktop sessions. + +### Authorization expiry boundary (2026-10-03) + +Review identified that an in-flight renewal response could extend an already +expired device-side lease. At `391541e`, renewal rejects an expired lease before +the request and again before applying the response. The request timeout is +bounded by the remaining lease time and the normal 15-second limit. The receiving +native app also closes an authorized connection on an incoming packet when its +lease has expired, instead of relying only on the next periodic timer tick. + +`delayed_renewal_cannot_resurrect_expired_authorization` passed in independently +read Linux and Windows MSVC logs from pull-request run `37125172883`. Both full +component runs passed, including push `37125170016` and the isolated Linux +deployment rehearsal. Local Windows GNU type checking passed. These prove the +renewal validation regression, not a live native session or its termination. +Native build `37125194672` is running from this exact source; build `37123744363` +contains the earlier backup fix and cannot validate the new expiry behavior. From 217a783eb22088b31bb4132a9636418996c6ccec Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Sun, 4 Oct 2026 10:35:56 +0200 Subject: [PATCH 249/652] Record native readonly WAL backup and package evidence --- docs/ACCEPTANCE.md | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index 23f60e0d45b..7dba9cc3728 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -2443,3 +2443,35 @@ deployment rehearsal. Local Windows GNU type checking passed. These prove the renewal validation regression, not a live native session or its termination. Native build `37125194672` is running from this exact source; build `37123744363` contains the earlier backup fix and cannot validate the new expiry behavior. + +### Native backup runtime verification (2026-10-04) + +Native runs `37123744363` (`f71773b`, backup fix) and `37125194672` +(`391541e`, authorization expiry fix) completed successfully. The latter's +package download remains pending independent verification. The former's archive +`11275251202` passed SHA-256 +`bb99ad350b2f633cf738fc91028e2e80794b77575229e688a3a84322ee841125`, +CRC and payload checksum verification. Independent checks matched 11 embedded +server resources, six setup source inputs, component pins, 1,103 public Git +files and six generated bridge files. The dirty-checkout flag remains retained; +this is source provenance, not binary reproducibility. + +The verified native management executable SHA-256 +`f193918d1c089172c5de88ca62fd7d56323498f36050b53619ccc4693aa5fa55` +passed a synthetic Windows CLI export/restore rehearsal. Its stopped transport +fixture contained committed WAL frames and no SHM, with a read-only directory +ACL verified by a denied write probe. Restore retained the committed peer row +and management trust files. Source bytes stayed unchanged, no SHM appeared in +the source, and plaintext staging was removed. Invalid transport export left no +archive or staging; tampered restore created no destination. Fixture setup first +failed because Windows PowerShell inherited incompatible module paths; correcting +only the child process environment allowed the actual runtime test to execute. +No existing service, company database or host security setting was changed. +This is native CLI behavior with synthetic data, not service deployment or a +real-company disaster recovery test. + +The build's retained MSI lifecycle artifact `11274617033` independently passed +SHA-256 `659aa1ee0a18f18dcb30494e4a13937aeeeeb4eb4cec5c0f859d74aa37cdead2`, +CRC and native successful install/repair/uninstall log checks on Server 2022 +build 20348. Its older fixture lacks package hashes in the receipt; standard-user +enrollment, GUI login and native remote sessions remain unverified. From 5ef989bcddc2fd355f55fa24dee32efd61b51c86 Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Sun, 4 Oct 2026 10:40:21 +0200 Subject: [PATCH 250/652] Record expiry-build MSI receipt with pending artifact comparison --- docs/ACCEPTANCE.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index 7dba9cc3728..f3ce19b938b 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -2475,3 +2475,13 @@ SHA-256 `659aa1ee0a18f18dcb30494e4a13937aeeeeb4eb4cec5c0f859d74aa37cdead2`, CRC and native successful install/repair/uninstall log checks on Server 2022 build 20348. Its older fixture lacks package hashes in the receipt; standard-user enrollment, GUI login and native remote sessions remain unverified. + +The authorization-expiry native build's MSI evidence artifact `11274947759` +also passed authenticated digest +`4dc584d2a4cbdc62f1e247ff76395173ead5364b4ffc0e12555e2091e533ab60`, +CRC and successful native install/repair/uninstall log checks. Its receipt and +all invocation records identify fixture `391541e` and MSI SHA-256 +`91b63c8cb2f164d25955c10ae82b8e5bc7dc2d10ac9ae3b99680a42f4132d33f`. +Comparison of that recorded package/payload identity with the main downloaded +archive remains pending; the archive's original process is still running. +This evidence remains limited to unsigned Server 2022 CI payload lifecycle. From 597b0aa3ac8e9f159d3a568797655174d7301f79 Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Wed, 21 Oct 2026 13:05:41 +0200 Subject: [PATCH 251/652] Verify resumed expiry-fix package and current native backup runtime --- docs/ACCEPTANCE.md | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index f3ce19b938b..acde590eb19 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -2485,3 +2485,29 @@ all invocation records identify fixture `391541e` and MSI SHA-256 Comparison of that recorded package/payload identity with the main downloaded archive remains pending; the archive's original process is still running. This evidence remains limited to unsigned Server 2022 CI payload lifecycle. + +### Expiry-fix artifact and native backup checks (2026-10-21) + +The interrupted `391541e` download was found stopped with 111,149,056 bytes +retained and no live downloader. Resume requested only the remaining byte range +of the authenticated artifact; the complete 24-file archive passed SHA-256 +`b627e5369fff25f0555159577b14956e06f983facd963e3c059a1cc3fe14eb37`, +ZIP CRC and published payload checksums. Independent verification matched the +11 server setup resources, six source inputs, pinned components, 1,103 public +Git files and six generated bridge files. The MSI, technician executable and +agent matched the hashes recorded by the CI lifecycle test. This resolves the +pending artifact comparison above; dirty-checkout and binary reproducibility +limits remain unchanged. + +Native management SHA-256 +`c2b3cc3c20ab5199777d1bacddedf2d31deff0bef4a6ea311ac77975afd9b0c1` +passed the same synthetic Windows read-only WAL export/restore, unchanged-source, +staging cleanup, invalid-transport failure and tampered-restore checks. This +verifies backup CLI behavior in the current packaged runtime; it does not verify +native session expiry, company disaster recovery or deployment lifecycle. + +The existing disposable Server 2016 VM was restarted through its verified +launcher without changing its memory guard or network settings. An encrypted +management probe confirmed `SWAN-LAB-16V2`, Server 2016 Standard Evaluation, +64-bit build 14393. Boot and guest identity are readiness evidence only; no new +GUI login or remote-session acceptance is claimed. From baf027c20a34d30c60eaa2d25dae22392bb4fb22 Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Wed, 21 Oct 2026 13:43:31 +0200 Subject: [PATCH 252/652] Record native technician MFA success and failed rendezvous evidence --- docs/ACCEPTANCE.md | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index acde590eb19..e6e721dc31a 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -2511,3 +2511,31 @@ launcher without changing its memory guard or network settings. An encrypted management probe confirmed `SWAN-LAB-16V2`, Server 2016 Standard Evaluation, 64-bit build 14393. Boot and guest identity are readiness evidence only; no new GUI login or remote-session acceptance is claimed. + +### Native technician login and failed rendezvous attempt (2026-10-21) + +The existing signed lab technician installation launched as unelevated +`SwanLabTech` in session 1 after configuration sync and two successful package +signature checks. File preparation was slow but showed changing read/CPU +counters; the original process was retained until its frontend opened. The +native company-branded form accepted the restricted `lab-technician` account +and a fresh TOTP, then displayed the approved lab device in its authorized +inventory. Credentials and the authenticator secret were kept private. This +uses the existing older signed lab payload, not the newly downloaded release. + +Requesting attended support opened the native connection window for the +enrolled device, then failed with `Failed to connect via rendezvous server`. +No customer-consent prompt or desktop session was demonstrated. Client logs +recorded three TCP punch attempts; the lab hbbs log recorded Windows UDP error +10014 and UDP-loop restarts at the same three times. Both endpoint state files +had accepted profile revision 15, whose transport addresses resolve to the +loopback-only lab server. This is failed transport acceptance, with diagnosis +pending; it is not evidence of working NAT traversal or relay fallback. + +After the long lab downtime, the TLS front end initially served an expired +internal leaf certificate. A normal restart of the verified guest company +service restored validated HTTPS; customer and worker services stayed running. +No invalid certificate was accepted. The viewer's relative mouse mapping also +misplaced clicks; full-screen keyboard capture allowed verified keyboard-only +form navigation. Adding a USB controller live was rejected by the VM's PCI bus, +so no live USB hardware change succeeded. From 16fce025a17631397f3d3a03435c661cb4185a70 Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Wed, 21 Oct 2026 13:56:52 +0200 Subject: [PATCH 253/652] Add pinned candidate transport fix and real UDP regression builds --- .gitattributes | 1 + .github/workflows/swan-transport.yml | 70 +++++++++++++++++++ deployment/transport/README.md | 27 +++++++ deployment/transport/archive_source.py | 33 +++++++++ deployment/transport/check_baseline.py | 35 ++++++++++ .../patches/0001-udp-address-regression.patch | 62 ++++++++++++++++ .../0002-udp-address-normalization.patch | 36 ++++++++++ deployment/transport/prepare_source.py | 60 ++++++++++++++++ deployment/transport/source.json | 23 ++++++ 9 files changed, 347 insertions(+) create mode 100644 .github/workflows/swan-transport.yml create mode 100644 deployment/transport/README.md create mode 100644 deployment/transport/archive_source.py create mode 100644 deployment/transport/check_baseline.py create mode 100644 deployment/transport/patches/0001-udp-address-regression.patch create mode 100644 deployment/transport/patches/0002-udp-address-normalization.patch create mode 100644 deployment/transport/prepare_source.py create mode 100644 deployment/transport/source.json diff --git a/.gitattributes b/.gitattributes index 176a458f94e..d402a9b0010 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1 +1,2 @@ * text=auto +deployment/transport/** text eol=lf diff --git a/.github/workflows/swan-transport.yml b/.github/workflows/swan-transport.yml new file mode 100644 index 00000000000..98d20751290 --- /dev/null +++ b/.github/workflows/swan-transport.yml @@ -0,0 +1,70 @@ +name: Verify and build candidate Swan transport +on: + workflow_dispatch: + pull_request: + paths: ['deployment/transport/**', '.github/workflows/swan-transport.yml'] +permissions: + contents: read +concurrency: + group: swan-transport-${{ github.ref }} + cancel-in-progress: false +jobs: + transport: + timeout-minutes: 60 + strategy: + fail-fast: false + matrix: + os: [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: Build candidate rendezvous and relay executables + working-directory: transport-source + run: cargo +1.90.0 build --release --locked --bin hbbs --bin hbbr + - name: Preserve exact public build inputs + run: python deployment/transport/archive_source.py transport-source transport-artifacts/transport-build-source.zip + - 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/deployment/transport/README.md b/deployment/transport/README.md new file mode 100644 index 00000000000..b0c8e51f74d --- /dev/null +++ b/deployment/transport/README.md @@ -0,0 +1,27 @@ +# 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. diff --git a/deployment/transport/archive_source.py b/deployment/transport/archive_source.py new file mode 100644 index 00000000000..6a772a31523 --- /dev/null +++ b/deployment/transport/archive_source.py @@ -0,0 +1,33 @@ +"""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") +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..42ef000c269 --- /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", "--", "--nocapture"], 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..b9e122d763b --- /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 { port: 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])?.port, 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/source.json b/deployment/transport/source.json new file mode 100644 index 00000000000..71259d5f78f --- /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": "6b46c0d1fbeb72ba5e619eec7403894bd55c3b15e512a74bdc1005e267484138", + "before_sha256": "c7da75a4db64fea07f6dce1ad5a791fb4d791b15dcc41672129b0f6438ad8b2f", + "after_sha256": "89479aeb6acfdfaa9fa8ff1e43572b3e41c9803be01f7e830dbbfabe20a07496" + }, + { + "path": "patches/0002-udp-address-normalization.patch", + "sha256": "c862020cf0fddf6a50e401674006d41775dd955c3eb2a2e7c149260ad888adc2", + "before_sha256": "89479aeb6acfdfaa9fa8ff1e43572b3e41c9803be01f7e830dbbfabe20a07496", + "after_sha256": "fd5ec0c2dc8e8cb3e75a8135d4748a187feef4eedf499a9254dbb6b6c53e1b6b" + } + ], + "scope": "Candidate transport patch; not approved for production or substituted into current installers" +} From 23d2ced22efaafe93801e66f806ee52ee5e7c1dd Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Wed, 21 Oct 2026 13:59:15 +0200 Subject: [PATCH 254/652] Use pinned protobuf serial field in UDP regression --- .../transport/patches/0001-udp-address-regression.patch | 4 ++-- deployment/transport/source.json | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/deployment/transport/patches/0001-udp-address-regression.patch b/deployment/transport/patches/0001-udp-address-regression.patch index b9e122d763b..10d2fa495a8 100644 --- a/deployment/transport/patches/0001-udp-address-regression.patch +++ b/deployment/transport/patches/0001-udp-address-regression.patch @@ -27,11 +27,11 @@ + 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 { port: 21116, ..Default::default() }; ++ 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])?.port, 21116); ++ 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"); diff --git a/deployment/transport/source.json b/deployment/transport/source.json index 71259d5f78f..b801cc1608b 100644 --- a/deployment/transport/source.json +++ b/deployment/transport/source.json @@ -8,15 +8,15 @@ "patches": [ { "path": "patches/0001-udp-address-regression.patch", - "sha256": "6b46c0d1fbeb72ba5e619eec7403894bd55c3b15e512a74bdc1005e267484138", + "sha256": "e2ecf9da94c84e5447e81e8667a75dc73d4ab9179efec4f465414599c1f9ec15", "before_sha256": "c7da75a4db64fea07f6dce1ad5a791fb4d791b15dcc41672129b0f6438ad8b2f", - "after_sha256": "89479aeb6acfdfaa9fa8ff1e43572b3e41c9803be01f7e830dbbfabe20a07496" + "after_sha256": "024397429d5ec03888bff1b856f968764c4bb4e6b09ffa1b9c780ef93a6efac3" }, { "path": "patches/0002-udp-address-normalization.patch", "sha256": "c862020cf0fddf6a50e401674006d41775dd955c3eb2a2e7c149260ad888adc2", - "before_sha256": "89479aeb6acfdfaa9fa8ff1e43572b3e41c9803be01f7e830dbbfabe20a07496", - "after_sha256": "fd5ec0c2dc8e8cb3e75a8135d4748a187feef4eedf499a9254dbb6b6c53e1b6b" + "before_sha256": "024397429d5ec03888bff1b856f968764c4bb4e6b09ffa1b9c780ef93a6efac3", + "after_sha256": "c881d983b3b1cb77be30cf557be84e65064c72f69b414792cd62230fa99811f4" } ], "scope": "Candidate transport patch; not approved for production or substituted into current installers" From e7953c98b01a497d8129c84d0ec2421aa829d071 Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Wed, 21 Oct 2026 14:04:53 +0200 Subject: [PATCH 255/652] Serialize UDP regression output for strict Windows result verification --- deployment/transport/check_baseline.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/deployment/transport/check_baseline.py b/deployment/transport/check_baseline.py index 42ef000c269..8a9fa1e9052 100644 --- a/deployment/transport/check_baseline.py +++ b/deployment/transport/check_baseline.py @@ -10,7 +10,7 @@ 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", "--", "--nocapture"], cwd=source, + "swan_udp_tests", "--", "--test-threads=1"], cwd=source, stdout=subprocess.PIPE, stderr=subprocess.STDOUT) log = result.stdout.decode(errors="replace") evidence = source / "swan-evidence" From d4fba6ad94eba31cb67896e9b93a1160bf470b2d Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Wed, 21 Oct 2026 14:14:45 +0200 Subject: [PATCH 256/652] Record verified cross-platform UDP regression results and remaining runtime gates --- docs/ACCEPTANCE.md | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index e6e721dc31a..1807baf9a29 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -2539,3 +2539,27 @@ No invalid certificate was accepted. The viewer's relative mouse mapping also misplaced clicks; full-screen keyboard capture allowed verified keyboard-only form navigation. Adding a USB controller live was rejected by the VM's PCI bus, so no live USB hardware change succeeded. + +### Candidate Windows UDP correction (2026-10-21) + +An isolated Winsock probe on Server 2016 build 14393 reproduced error 10014 +when an IPv4 destination was passed to a dual-stack IPv6 UDP socket. The same +probe delivered its datagram when the destination used an IPv4-mapped IPv6 +address. No existing service or network configuration changed for this probe. + +Commit `e7953c9` provides pinned public transport inputs, two reviewed patches, +and workflow run `39596805961`. The Windows 2022 baseline reproduced error +10014 in the actual shared Rust UDP message path while the IPv4 and IPv6 +controls passed. After destination normalization, all three real socket tests +passed on Windows and Ubuntu 24.04. The tests cover protobuf message delivery, +raw datagram delivery, IPv4 sockets, and native IPv6 destinations. An earlier +test fixture used the wrong protobuf field; its correction exposed interleaved +parallel test output. The final fixture runs sequentially and retains strict +failure and control checks; a compilation failure does not count as reproduction. + +The Linux candidate rendezvous and relay build succeeded; the Windows build +and independent artifact verification remain pending. Existing distribution +pins and running lab services have not +been replaced. Passing these network regressions does not establish native +session authorization, Server 2016 candidate compatibility, external NAT +traversal, relay fallback, or release readiness. From 839ce372566b3bf5adac242a56c735f73885b221 Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Wed, 21 Oct 2026 14:19:58 +0200 Subject: [PATCH 257/652] Record verified transport artifacts and isolated Server 2016 runtime checks --- docs/ACCEPTANCE.md | 30 ++++++++++++++++++++++++------ 1 file changed, 24 insertions(+), 6 deletions(-) diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index 1807baf9a29..b077015c215 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -2557,9 +2557,27 @@ test fixture used the wrong protobuf field; its correction exposed interleaved parallel test output. The final fixture runs sequentially and retains strict failure and control checks; a compilation failure does not count as reproduction. -The Linux candidate rendezvous and relay build succeeded; the Windows build -and independent artifact verification remain pending. Existing distribution -pins and running lab services have not -been replaced. Passing these network regressions does not establish native -session authorization, Server 2016 candidate compatibility, external NAT -traversal, relay fallback, or release readiness. +Both candidate executable builds succeeded. The four downloaded candidate and +regression artifacts passed their authenticated archive digests, ZIP integrity, +receipt, patch, lock, and public recipe checks. The Windows candidate artifact +`12344418248` has SHA-256 +`be388753b8b3edf3530dceea59137b1477300cb67eb46d6cc1a12a584c4e3049`. +The source receipt identifies PR merge `c09c4bd8ac85f86e1f0fd10d2853a09b266545a4`; +its parent includes `e7953c9`, and archived recipe files match that reviewed +revision. This is input provenance, not binary reproducibility. + +On the existing Server 2016 build 14393 guest, candidate `hbbs.exe` SHA-256 +`83d402b48cec61513b6a0e3a6277c648b31a6ccbce0b87eee022b96ca746a443` +and `hbbr.exe` SHA-256 +`8ba65379f7a025cad6ec0520d481af62c135e117ac58ac7b460c7b1afbf751cd` +both started and reported version 1.1.14, as specified by the pinned upstream +Cargo package. The candidate rendezvous server then answered real IPv4 and +IPv6 `RegisterPeer` datagrams with a request for a public key. It used separate +ports and a fresh database/key directory, logged no UDP failure, and was stopped +after the test. The temporary loopback artifact transfer server was also stopped. +Existing company, worker, customer and technician processes remained running. + +Existing distribution pins and lab services have not been replaced. These +unsigned candidate checks establish startup and basic registration on Server +2016; they do not establish a native authorized session, relay operation, +external NAT traversal, complete Windows compatibility, or release readiness. From 74c3449286a24d82488a91eecd7aead09acebdff Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Wed, 21 Oct 2026 14:27:51 +0200 Subject: [PATCH 258/652] Apply synced transport settings in the running technician edition --- src/managed.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/managed.rs b/src/managed.rs index cec0c26947b..a4ed64a1c87 100644 --- a/src/managed.rs +++ b/src/managed.rs @@ -70,6 +70,9 @@ async fn technician_request_inner(request:&str)->ResultType { } if action=="sync" { let mut state=state;state.sync().await?;state.save(&directory)?; + // Technician editions do not run the incoming rendezvous mediator, + // whose timer normally applies changes to the running transport. + refresh_defaults(); return Ok(json!({"revision":state.accepted_revision})); } if action=="status" { @@ -134,6 +137,9 @@ async fn technician_request_inner(request:&str)->ResultType { return Ok(json!({"version":release.version,"sequence":release.sequence,"channel":release.channel})); } if action=="connect" { + // Also consume profiles synchronized by the background agent before + // creating an outgoing window or selecting its server and trust key. + refresh_defaults(); let device=input["device_id"].as_str().ok_or_else(||anyhow!("Missing device"))?; let key=protocol::signing_key_from_hex(&protocol::random_token())?; let (envelope,grant)=state.technician_ticket(&token,device,input["unattended"].as_bool().unwrap_or(false),&key).await?; From bd1350012227f226db168be51bc823d3f086e493 Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Wed, 21 Oct 2026 14:36:36 +0200 Subject: [PATCH 259/652] Reload signed company settings before failed rendezvous retries --- src/rendezvous_mediator.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/rendezvous_mediator.rs b/src/rendezvous_mediator.rs index 11624ec4729..8927581a7b7 100644 --- a/src/rendezvous_mediator.rs +++ b/src/rendezvous_mediator.rs @@ -151,6 +151,11 @@ impl RendezvousMediator { scrap::codec::test_av1(); *LAST_NOT_DEPLOYED_REGISTER.lock().await = None; loop { + // DNS failure can prevent either transport's refresh timer from + // starting, including the fail-closed unconfigured endpoint after + // a long offline period. Consume newly signed policy on retries. + #[cfg(feature = "swan_custom")] + crate::managed::refresh_defaults(); let timeout = Arc::new(RwLock::new(CONNECT_TIMEOUT)); let conn_start_time = Instant::now(); *SOLVING_PK_MISMATCH.lock().await = "".to_owned(); From 759dbeaad5753e98851fd7d99aef050a54e0eaae Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Wed, 21 Oct 2026 14:46:42 +0200 Subject: [PATCH 260/652] Record native attended session and grant closure with parallel transport --- docs/ACCEPTANCE.md | 41 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index b077015c215..abf8a29f586 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -2581,3 +2581,44 @@ Existing distribution pins and lab services have not been replaced. These unsigned candidate checks establish startup and basic registration on Server 2016; they do not establish a native authorized session, relay operation, external NAT traversal, complete Windows compatibility, or release readiness. + +### Native attended session through parallel patched transport (2026-10-21) + +The candidate ran alongside the original lab company services on rendezvous +32116 and relay 32117, using a separate database and a protected copy of the +lab transport keys. A normal authenticated profile change published revision +16 with those addresses and paused lab updates. Both endpoint state files +accepted the signed revision; customer identity, release sequence and unattended +consent (`false`) were preserved. Original component pins and services stayed +intact. No host DNS, firewall, trust store or Tailscale setting changed. + +This exposed two runtime configuration defects. The running technician accepted +the new profile but continued selecting port 21116. Commit `74c3449` applies +transport defaults after synchronization and before a new outgoing request. +The customer, booted after its cached profile expired, kept resolving the +fail-closed `unconfigured.invalid` address: DNS failed before either transport's +refresh timer could run. Commit `bd13500` also reloads signed settings before +each outer rendezvous retry. Native build `39600988305` is verifying both fixes; +neither fix's runtime acceptance is claimed yet. The initial suspicion of a +transport-ID mismatch was disproved: native and enrolled IDs matched. + +Restarting the existing signed lab technician and customer service consumed +revision 16 without changing identity or consent. The customer registered its +public key with the parallel server. The technician's actual branded GUI login +with the restricted account and fresh TOTP obtained a grant, punched a secure +local TCP connection, and reached the customer's Accept/Cancel prompt. Explicit +customer acceptance at 14:43:23 lab time opened a VP8 desktop stream and the +clipboard loop. The desktop was visibly rendered; the recursive image is +expected because both editions ran in the same VM. The management service +recorded the grant as claimed and renewed its lease. + +Device-authenticated closure of that specific grant stopped clipboard, video +and audio at 14:45:21. The client requested a fresh grant but returned to the +customer approval prompt. Closing that pending reconnect also closed its grant; +no continuing desktop access was observed. Viewer relative-pointer offset made +consent input difficult; measured pointer correction eventually selected Accept. +The existing signed endpoint payloads were used, not the pending native build. +This establishes an attended local session and enforced grant closure with the +candidate transport. It does not establish natural lease-expiry behavior, data +transfer or clipboard-content correctness, unattended access, separate-machine +input, external NAT traversal, relay fallback, or the full Windows test matrix. From 31495ffac831c8517ae61f6eddef893ccae5e4a3 Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Wed, 21 Oct 2026 15:08:18 +0200 Subject: [PATCH 261/652] docs: record reboot and incomplete relay test evidence --- docs/ACCEPTANCE.md | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index abf8a29f586..28e9d352a0c 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -2622,3 +2622,31 @@ This establishes an attended local session and enforced grant closure with the candidate transport. It does not establish natural lease-expiry behavior, data transfer or clipboard-content correctness, unattended access, separate-machine input, external NAT traversal, relay fallback, or the full Windows test matrix. + +### Reboot and relay-test follow-up (2026-10-21) + +The disposable Server 2016 guest was shut down and relaunched with a USB tablet. +The company server, installer worker and customer service started automatically; +HTTPS worked, and device identity, consent and release sequence were preserved. +The manual parallel transport task was restarted separately. The viewer's +connection-refused dialog during shutdown cleared after reconnecting. Absolute +pointer input subsequently selected the actual customer Accept button without +the previous relative-pointer compensation. Windows displayed Shutdown Event +Tracker, so clean shutdown-event logging is not claimed. + +The existing customer payload again remained on `unconfigured.invalid` after +startup despite accepting profile revision 16. Restarting only that lab service +recovered registration. This reproduces the pending configuration-retry fix; +it is not evidence that the new native build has passed. Build `39600988305` +reached the branded-client compilation step after its prerequisite jobs passed. + +The parallel server reported `ALWAYS_USE_RELAY=Y`, but the subsequent accepted +session still logged a secure local TCP connection (`is_local: true`). The +server preference therefore did not make this same-machine test a relay test. +Desktop video appeared after customer approval at 15:04 lab time. Later the +guest display stopped changing, both WinRM probes timed out, and SSH timed out +during banner exchange while QEMU reported the VM running. Attempts to close +the native connection through its window were not confirmed. The guest state +was preserved for diagnosis; the cause of the unresponsiveness is unproven. +Relay operation, session closure for this attempt, and post-failure recovery +remain unverified. From 0f714478a4d8e5001b5cdc56d5f23b4d10493bfa Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Wed, 21 Oct 2026 15:17:39 +0200 Subject: [PATCH 262/652] docs: make current acceptance limits visible first --- docs/ACCEPTANCE.md | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index 28e9d352a0c..d3f1d98bb81 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -1,9 +1,21 @@ # Configurable product acceptance report -Development status, 3 October 2026. **The product is not complete or approved for +Evidence through 21 October 2026. **The product is not complete or approved for production publication.** A passing component test is not evidence of a working native support session or a clean-machine installation. +The latest native lab run established a customer-approved desktop session on +Server 2016 through the separately built transport candidate. Both editions +were on the same guest: this does not verify separate-machine input, relay +fallback or Internet connectivity. The attempted relay test still selected a +local direct connection, and that guest later became unresponsive. Recovery +and diagnosis are pending. Two runtime configuration fixes are in native build +`39600988305`; compilation reached installer packaging, but its runtime tests +have not passed. See the final two dated sections for exact scope and failures. +Earlier entries below retain their original source-specific observations; +later evidence does not retroactively validate older packages or every Windows +version. The full release gates remain open. + ## Evidence obtained ### Corrected native Windows build inputs, 3 October From f115fdeca48d1b235981193a00ccf5c46c048268 Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Wed, 21 Oct 2026 15:30:52 +0200 Subject: [PATCH 263/652] docs: record verified configuration recovery build artifacts --- docs/ACCEPTANCE.md | 30 ++++++++++++++++++++++++++++-- 1 file changed, 28 insertions(+), 2 deletions(-) diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index d3f1d98bb81..6e388056c10 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -10,8 +10,8 @@ were on the same guest: this does not verify separate-machine input, relay fallback or Internet connectivity. The attempted relay test still selected a local direct connection, and that guest later became unresponsive. Recovery and diagnosis are pending. Two runtime configuration fixes are in native build -`39600988305`; compilation reached installer packaging, but its runtime tests -have not passed. See the final two dated sections for exact scope and failures. +`39600988305`; its build and artifact/source checks passed, but its runtime tests +have not passed. See the final dated sections for exact scope and failures. Earlier entries below retain their original source-specific observations; later evidence does not retroactively validate older packages or every Windows version. The full release gates remain open. @@ -373,6 +373,32 @@ then denied unattended grant requests. Executable hashes, exact harness source dirty-state and the recovery result are recorded privately. Native sessions remain unverified. +### Configuration-recovery native build verified (2026-10-21) + +Native Windows run `39600988305` completed successfully for exact public source +`bd1350012227f226db168be51bc823d3f086e493`, containing both configuration-refresh +fixes. Artifact `12349480599` was downloaded once and verified against its +authenticated archive digest: 141,973,242 bytes, SHA-256 +`31bf6a8905de084dfe837afa5efaafc87f1e14a196b3d7acbace2e6fd5cc6c0b`. +Its 24 entries passed bounded extraction, path and published checksum checks. +The corresponding-source archive matched 1,111 public Git files and all six +generated bridge files. The bridge input was independently fetched as artifact +`12344994676`, SHA-256 +`d979023c7c01202fb93470a899cc2fdf7bf1d7825fd7151cb505a1ac6143930f`. +Six server-setup source inputs and the existing component pins also matched. +The build receipt still marks the checkout dirty; these source comparisons do +not establish a clean build or binary reproducibility. + +Retained MSI lifecycle artifact `12349770219`, SHA-256 +`b1313134dafac51a8758a1958861b374dc5113c11dd59534a93fb78acce21246`, +passed authenticated digest and ZIP checks. Its result records successful +installation, repair and uninstall on Server 2022 Datacenter build 20348. +The actual tested technician MSI, portable payload and agent hashes matched +the main archive. This is unsigned payload lifecycle under the CI account, +not company enrollment, standard-user GUI, signed upgrades, native sessions +or a complete Server 2022 acceptance pass. The configuration fixes' native +runtime recovery tests remain pending. + | Requirement | Current evidence and remaining work | | --- | --- | | Reproducible server, customer, technician and worker builds | Product components pass pinned builds. Native Windows build/package run 36998413349 passed at exact source `2eec008`. Later native source, including consent/reconnect/version and rollback changes, still needs CI and VM verification. Baseline rebuild and clean-machine installation are not newly proved. | From 553c9d04f7e3fb907190f7604985be123be32a6c Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Wed, 21 Oct 2026 16:25:51 +0200 Subject: [PATCH 264/652] docs: record clean Server 2025 MSI lifecycle and lab recovery --- docs/ACCEPTANCE.md | 47 ++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 45 insertions(+), 2 deletions(-) diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index 6e388056c10..dfa06ce0fe9 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -8,8 +8,9 @@ The latest native lab run established a customer-approved desktop session on Server 2016 through the separately built transport candidate. Both editions were on the same guest: this does not verify separate-machine input, relay fallback or Internet connectivity. The attempted relay test still selected a -local direct connection, and that guest later became unresponsive. Recovery -and diagnosis are pending. Two runtime configuration fixes are in native build +local direct connection, and that guest later became unresponsive. The guest +recovered with identity and consent preserved; the cause remains unproven. +Two runtime configuration fixes are in native build `39600988305`; its build and artifact/source checks passed, but its runtime tests have not passed. See the final dated sections for exact scope and failures. Earlier entries below retain their original source-specific observations; @@ -399,6 +400,48 @@ not company enrollment, standard-user GUI, signed upgrades, native sessions or a complete Server 2022 acceptance pass. The configuration fixes' native runtime recovery tests remain pending. +### Clean Server 2025 MSI lifecycle and Server 2016 recovery (2026-10-21) + +A fresh official Windows Server 2025 Standard Evaluation Desktop Experience +guest completed installation as x64 build 26100.32230. Its independent guest +identity check passed. Provisioning disabled automatic sign-in and removed its +stored automatic sign-in password. A lab SSH-key ACL error was diagnosed and +corrected before product testing; Windows-provided SSH then passed the pinned +host-key check. Setup used 4 GiB RAM; the controlled runtime boot used 3 GiB, +retaining the host's 1.5 GiB reserve. Setup media was detached for that boot. +A clean pre-product disk checkpoint passed an offline qcow2 integrity check. + +The independently verified `bd13500` customer and technician MSIs were staged +with exact-source test helpers and verified again inside the guest. All six +actual Windows Installer phases passed: install, repair and uninstall for each +edition, each returning 0 without requesting a reboot. Installed package and +agent hashes, MSI registration and technician shortcuts were checked. Customer +checks additionally verified its complete installed-files manifest and running +LocalSystem service. Customer repair replaced a deliberately damaged packaged +license file with its expected bytes. Uninstall checks verified removal of the +packaged files, relevant registration and customer service. + +The six results and raw MSI logs were exported over pinned SSH to protected +host storage. Independent verification matched source revision, package and +agent hashes, the exact test-runner hash and OS build; every log contained the +zero-return completion marker without `Return value 3`. This establishes bare +unsigned MSI lifecycle as Administrator on this particular Server 2025 build. +Standard-user installation, company enrollment/branding, publisher acceptance, +upgrades and native sessions on Server 2025 remain unverified. It is not a +complete compatibility-matrix pass or production release approval. + +The previously hung Server 2016 guest was powered off after its normal shutdown +request did not complete. This QEMU backend rejected memory snapshots. A full +stopped-disk copy was therefore retained privately and verified against the +original SHA-256; the original disk then passed its qcow2 integrity check. +After boot, device identity/token, consent and release sequence matched the +pre-reboot state, profile revision 16 remained accepted, and the customer, +management and worker services were Running. Management HTTPS and configured +status passed. The manually started candidate transport remained stopped as +designed. The event log recorded an unclean shutdown; it did not establish +the cause of the earlier unresponsiveness. This is crash-recovery evidence, +not a complete encrypted deployment-restore or migration rehearsal. + | Requirement | Current evidence and remaining work | | --- | --- | | Reproducible server, customer, technician and worker builds | Product components pass pinned builds. Native Windows build/package run 36998413349 passed at exact source `2eec008`. Later native source, including consent/reconnect/version and rollback changes, still needs CI and VM verification. Baseline rebuild and clean-machine installation are not newly proved. | From 2e1ed2e81eb3142f018040dd749fb61af375d867 Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Wed, 21 Oct 2026 17:32:34 +0200 Subject: [PATCH 265/652] docs: record signed lab packages and failed standard-user MSI attempt --- docs/ACCEPTANCE.md | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index dfa06ce0fe9..f3b66a6fde8 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -2731,3 +2731,32 @@ the native connection through its window were not confirmed. The guest state was preserved for diagnosis; the cause of the unresponsiveness is unproven. Relay operation, session closure for this attempt, and post-failure recovery remain unverified. + +### Private signed packages and standard-user attempt, 21 October + +Source `bd1350012227f226db168be51bc823d3f086e493` now has private-lab +customer and technician 1.5.7 MSI wrappers, plus signed agent and worker inputs. +Authenticode publisher, pinned certificate and RFC3161 timestamp checks passed +in the signing guest. This is company lab signing, not project signing approval. +Signed release metadata for test channel sequence 8 was prepared and locally +verified; it has not been imported, approved or rolled out. + +On the disposable Server 2025 build 26100 guest, a new non-administrator account +verified the signed technician MSI's expected hash, publisher and certificate. +Its background scheduled-task installation then failed with MSI exit 1601: +the raw log reports access denied (0x80070005) connecting to Windows Installer. +Repair and uninstall were not attempted. The account needed an explicit batch +logon right to start the task; it was never added to Administrators. This failed +background test does not establish interactive standard-user install behavior. +The result, wrapper and raw MSI log were exported and retained privately. +The VM console subsequently showed a black display while administrative remote +access remained available; an interactive rerun is still outstanding. + +The second guest cannot yet reach the company HTTPS service because that +service binds only to the first guest's loopback address. Automatic approval +review rejected the proposed private-NIC listener addition and service restart +with only "blocked by policy." No listener change was applied. A reviewable +private draft and an explicit approval request remain pending. The initial peer +HTTPS request timed out; this is not a passing negative certificate test. +Cross-machine native sessions and the runtime configuration fixes remain +unverified. Host RustDesk and its transport servers were not stopped. From ccda35e6f0d627b83e0f731da50f6603d0587eca Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Wed, 21 Oct 2026 17:46:11 +0200 Subject: [PATCH 266/652] fix: report unapproved installer releases as conflicts --- docs/ACCEPTANCE.md | 35 +++++++++++++++++++++++++++++++ product/management/src/service.rs | 14 ++++++++++++- 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index f3b66a6fde8..169af1af968 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -2760,3 +2760,38 @@ private draft and an explicit approval request remain pending. The initial peer HTTPS request timed out; this is not a passing negative certificate test. Cross-machine native sessions and the runtime configuration fixes remain unverified. Host RustDesk and its transport servers were not stopped. + +### Matched worker and interactive MSI follow-up, 21 October + +The Server 2025 console recovered to its normal logon screen. The same signed +technician MSI was then tested as the non-administrator account in interactive +session 1. Publisher verification passed, but installation returned 1625. The +raw MSI log says `DisableMsi` is 1 and rejects non-assigned applications for +non-admin users; repair and uninstall were not attempted. This independently +reproduces the Server 2016 policy limitation documented in INSTALLER_REPAIR.md. +No Windows Installer policy was changed. Both failed attempts remain evidence; +neither is a passing standard-user MSI lifecycle test. + +The `bd1350012227f226db168be51bc823d3f086e493` worker was privately signed and +installed over the isolated lab worker with a rollback binary/receipt retained. +The service runs with its existing protected configuration. After administrator +MFA login, both sequence-8 releases were imported and approved with rollout +still paused. Unapproved build requests did not change the queue, but returned +500. The source now returns 404 for unknown releases and 409 for unapproved +releases, with regression assertions covering anonymous/technician denial and +an unchanged build count. CI verification of this correction is pending. + +The new worker verified the signed packages and generated both ZIP bundles, +but upload to the older installed management service returned 400. The worker's +embedded launcher differs from the older server's expected launcher. Neither +job reached completed state; generated files alone do not demonstrate server +publication. The matching management binary was signed and timestamp-verified +(private lab hash `cf7b5077a633f8ff9ad830d2e2a9847d16b35494bbeaf061645abbe8c37c07d4`), +but has not been installed. + +Automatic approval review rejected the proposed management-backup preparation +with only "blocked by policy" before any command ran. No release withdrawal, +backup, restore or management-service change occurred. A separate reviewable +private draft and approval request remain pending. This is distinct from the +previously rejected private-NIC HTTPS listener change. Host services were not +changed; latest native runtime, cross-machine and update acceptance remain open. diff --git a/product/management/src/service.rs b/product/management/src/service.rs index 670b2f5a193..f53bf3f1f5a 100644 --- a/product/management/src/service.rs +++ b/product/management/src/service.rs @@ -555,7 +555,9 @@ async fn create_build(State(s):State,headers:HeaderMap,Json(input):Json< let actor=s.user(&headers,true)?;let mut profile=s.profile()?; profile.issued_at=now();profile.expires_at=now()+7*86400; let db=s.db.lock().unwrap(); - let envelope:String=db.query_row("SELECT envelope FROM releases WHERE id=?1 AND approved=1",[&input.release_id],|r|r.get(0))?; + let release:Option<(String,bool)>=db.query_row("SELECT envelope,approved FROM releases WHERE id=?1",[&input.release_id],|r|Ok((r.get(0)?,r.get(1)?))).optional()?; + let (envelope,approved)=release.ok_or(ApiError(StatusCode::NOT_FOUND,"Unknown release"))?; + if !approved{return Err(ApiError(StatusCode::CONFLICT,"Release must be approved before generating installers"));} let id=Uuid::new_v4().to_string();let body=json!({"release":serde_json::from_str::(&envelope)?,"profile":SignedEnvelope::sign(&profile,&s.key)?,"profile_public_key":STANDARD.encode(s.key.verifying_key().as_bytes()),"release_public_key":std::env::var("SWAN_RELEASE_PUBLIC_KEY").map_err(|_|bad("Release key not configured"))?}); db.execute("INSERT INTO builds(id,body,state) VALUES(?1,?2,'queued')",params![id,body.to_string()])?;drop(db);s.audit(&actor,"build.queued",&id)?;Ok(Json(json!({"id":id}))) } @@ -802,6 +804,16 @@ mod worker_completion_tests { } persist_verified_artifact(&store,&in_flight,&worker,b"previously validated bundle").ok().unwrap(); let headers=|credential:&str| {let mut h=HeaderMap::new();h.insert(header::AUTHORIZATION,format!("Bearer {credential}").parse().unwrap());h}; + let build_count=||store.db.lock().unwrap().query_row("SELECT COUNT(*) FROM builds",[],|r|r.get::<_,u64>(0)).unwrap(); + let before=build_count(); + let request=||Json(BuildRequest{release_id:"release".into()}); + assert_eq!(create_build(State(store.clone()),HeaderMap::new(),request()).await.err().unwrap().0,StatusCode::UNAUTHORIZED); + assert_eq!(create_build(State(store.clone()),headers(&technician_token),request()).await.err().unwrap().0,StatusCode::FORBIDDEN); + assert_eq!(create_build(State(store.clone()),headers(&admin_token),Json(BuildRequest{release_id:"missing".into()})).await.err().unwrap().0,StatusCode::NOT_FOUND); + store.db.lock().unwrap().execute("UPDATE releases SET approved=0 WHERE id='release'",[]).unwrap(); + assert_eq!(create_build(State(store.clone()),headers(&admin_token),request()).await.err().unwrap().0,StatusCode::CONFLICT); + assert_eq!(build_count(),before); + store.db.lock().unwrap().execute("UPDATE releases SET approved=1 WHERE id='release'",[]).unwrap(); assert!(!select_update(&store,"technician",Edition::Technician,None).ok().unwrap().0.is_null()); assert!(select_update(&store,"device",Edition::Customer,None).ok().unwrap().0.is_null()); let mut portable=release.clone();portable.sequence=2;portable.format="exe".into();portable.sha256=portable.installed_sha256.clone(); From 8c321705e0b3450f440e3c1a7ade6a328aad47ec Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Wed, 21 Oct 2026 17:50:57 +0200 Subject: [PATCH 267/652] test: match administrator-only build authentication response --- product/management/src/service.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/product/management/src/service.rs b/product/management/src/service.rs index f53bf3f1f5a..65c2ab1f033 100644 --- a/product/management/src/service.rs +++ b/product/management/src/service.rs @@ -808,7 +808,7 @@ mod worker_completion_tests { let before=build_count(); let request=||Json(BuildRequest{release_id:"release".into()}); assert_eq!(create_build(State(store.clone()),HeaderMap::new(),request()).await.err().unwrap().0,StatusCode::UNAUTHORIZED); - assert_eq!(create_build(State(store.clone()),headers(&technician_token),request()).await.err().unwrap().0,StatusCode::FORBIDDEN); + assert_eq!(create_build(State(store.clone()),headers(&technician_token),request()).await.err().unwrap().0,StatusCode::UNAUTHORIZED); assert_eq!(create_build(State(store.clone()),headers(&admin_token),Json(BuildRequest{release_id:"missing".into()})).await.err().unwrap().0,StatusCode::NOT_FOUND); store.db.lock().unwrap().execute("UPDATE releases SET approved=0 WHERE id='release'",[]).unwrap(); assert_eq!(create_build(State(store.clone()),headers(&admin_token),request()).await.err().unwrap().0,StatusCode::CONFLICT); From c0bf4b40ff8257c9fb69e4c5f6aceb2394f1adc0 Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Wed, 21 Oct 2026 18:05:16 +0200 Subject: [PATCH 268/652] fix: isolate managed editions from unsigned legacy policy sync --- .github/workflows/swan-windows-build.yml | 7 ++++ docs/ACCEPTANCE.md | 41 ++++++++++++++++++++++++ src/hbbs_http/sync.rs | 34 ++++++++++++++++++++ 3 files changed, 82 insertions(+) diff --git a/.github/workflows/swan-windows-build.yml b/.github/workflows/swan-windows-build.yml index efa23c2ecef..96c619d76f4 100644 --- a/.github/workflows/swan-windows-build.yml +++ b/.github/workflows/swan-windows-build.yml @@ -140,6 +140,13 @@ jobs: python3 .\build.py --portable --flutter --skip-portable-pack --hwcodec --vram Move-Item .\flutter\build\windows\x64\runner\Release .\swan-client + - 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: Add signed topmost-window helper uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index 169af1af968..a1e80b1cd1d 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -2795,3 +2795,44 @@ backup, restore or management-service change occurred. A separate reviewable private draft and approval request remain pending. This is distinct from the previously rejected private-NIC HTTPS listener change. Host services were not changed; latest native runtime, cross-machine and update acceptance remain open. + +### Latest component checks and local customer upgrade, 21 October + +Component workflow `39629396018` passed for exact source +`8c321705e0b3450f440e3c1a7ade6a328aad47ec`: locked Rust tests and release builds +on Linux and Windows, Windows setup/launcher/removal checks, and the Linux +HTTPS/transport deployment rehearsal. The new release-request assertions passed +on both operating systems. The first attempt failed because the test expected +403 instead of the existing administrator-only authentication response 401; +the test was corrected without changing authentication policy. + +Both `bd13500` worker ZIP files passed independent path/count/CRC, exact source +script/license, signed profile/release and package/agent hash checks. Bootstrap +company identity, endpoint and both public trust keys matched the independently +installed customer state. These checks do not turn the rejected server uploads +into completed publication jobs. + +Using that verified local customer bundle, an explicit Server 2016 upgrade from +1.5.6 sequence 7 to 1.5.7 sequence 8 passed. Device identity/token, unattended +consent and its revision were preserved, expected native/agent hashes matched, +recovery markers cleared, and the support service and configuration watcher +ran afterward. This was local explicit setup, not automatic rollout or a public +download test. The separate transport candidate was restarted after preserving +its previous logs; the upgraded native receiver contacted the configured +32116 endpoint and received a public-key registration request and NAT response. +This is not a separate-machine or relay desktop-session test. + +Runtime logs also showed legacy RustDesk Pro HTTP heartbeat/sysinfo attempts. +The source now disables that endpoint and ignores unsigned legacy strategy +options for `swan_custom` editions. The native Windows workflow includes an exact +regression-test gate requiring one executed passing test. Native compilation +and runtime verification of this guard remain pending; no unauthorized desktop +session through legacy strategy was demonstrated. The installed `bd13500` +candidate predates this guard and is not approved for production. + +A new isolated Server 2019 evaluation VM was created from the verified official +ISO, with a unique NIC address and private credentials/seed outside Git. The idle +Server 2025 guest shut down normally before launch. Server 2019 installation is +in progress, not yet a passing product compatibility test. Host RustDesk and +its server remain running. The rejected management backup and HTTPS listener +operations remain unchanged pending explicit approvals. diff --git a/src/hbbs_http/sync.rs b/src/hbbs_http/sync.rs index 1bb61943f86..aa26506b619 100644 --- a/src/hbbs_http/sync.rs +++ b/src/hbbs_http/sync.rs @@ -273,6 +273,14 @@ async fn start_hbbs_sync_async() { } } +#[cfg(feature = "swan_custom")] +fn heartbeat_url() -> String { + // Managed editions use authenticated company APIs and signed profiles. + // The legacy Pro API may use HTTP and accept unsigned strategy options. + String::new() +} + +#[cfg(not(feature = "swan_custom"))] fn heartbeat_url() -> String { let url = crate::common::get_api_server( Config::get_option("api-server"), @@ -284,6 +292,12 @@ fn heartbeat_url() -> String { format!("{}/api/heartbeat", url) } +#[cfg(feature = "swan_custom")] +fn handle_config_options(_config_options: HashMap) { + // Even a legacy sync response must never replace signed company policy. +} + +#[cfg(not(feature = "swan_custom"))] fn handle_config_options(config_options: HashMap) { let mut options = Config::get_options(); let default_settings = config::DEFAULT_SETTINGS.read().unwrap().clone(); @@ -308,3 +322,23 @@ fn handle_config_options(config_options: HashMap) { pub fn is_pro() -> bool { PRO.lock().unwrap().clone() } + +#[cfg(all(test, feature = "swan_custom"))] +mod managed_sync_tests { + use super::*; + + #[test] + fn managed_sync_rejects_legacy_endpoint_and_unsigned_options() { + assert!(heartbeat_url().is_empty()); + let key = "swan-unsigned-policy-regression-test"; + let previous = Config::get_option(key); + let mut options = HashMap::new(); + options.insert(key.to_owned(), "unsigned-remote-policy".to_owned()); + handle_config_options(options); + let actual = Config::get_option(key); + if actual != previous { + Config::set_option(key.to_owned(), previous.clone()); + } + assert_eq!(actual, previous); + } +} From 95ee22c6e7015744a0eb102528a3bb45e5d36e13 Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Wed, 21 Oct 2026 18:33:36 +0200 Subject: [PATCH 269/652] docs: record clean signed Server 2019 MSI lifecycle --- docs/ACCEPTANCE.md | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index a1e80b1cd1d..bcacbe8b556 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -2836,3 +2836,41 @@ Server 2025 guest shut down normally before launch. Server 2019 installation is in progress, not yet a passing product compatibility test. Host RustDesk and its server remain running. The rejected management backup and HTTPS listener operations remain unchanged pending explicit approvals. + +### Clean signed Server 2019 MSI lifecycle, 21 October + +The new VM installed Windows Server 2019 Standard Evaluation Desktop Experience +x64, exact build `17763.3650`, from the verified Microsoft evaluation ISO. Its +unattended lab bootstrap did not run; authenticated remote administration verified +the guest before the hash-pinned private seed bootstrap was run manually. SSH +provisioning completed, automatic logon was disabled and readiness was recorded. +This was lab OS preparation, not a product installer failure. + +After verifying staged source/package hashes, the unique NIC address and absence +of Swan product services, Windows shut down normally. The stopped QCOW2 disk +passed its integrity check and received clean checkpoint +`swan-clean-server2019-bd13500`. It then booted with installation media detached. + +Both privately signed 1.5.7 MSI packages from exact native source +`bd1350012227f226db168be51bc823d3f086e493` passed install, repair and uninstall +as `SWAN-LAB-19\Administrator` (administrator true, session 0). Each package +passed its pinned publisher/certificate/hash verifier before its lifecycle phases. +The public lifecycle runner verified Windows Installer registration, installed +payload identities, customer service ownership/state, technician shortcut and +registry entries, damaged customer license repair, and uninstall cleanup. +All six MSI exit codes were 0, with no reboot required. Exported reports matched +the pinned runner and package inputs; independent raw-log checks found successful +engine returns and no `Return value 3`. The machine's trust changes were limited +to the public private-lab publisher certificate in the disposable guest. + +Private receipt `windows-server2019-results-bd13500/verified-lifecycle.json` +retains hashes for all six reports and raw logs. This is signed administrator MSI +lifecycle evidence, not standard-user installation, company enrollment, native +sessions, approved automatic rollout or complete Server 2019 acceptance. + +The native Windows build for the legacy-sync guard is workflow `39631663952`, +exact source `c0bf4b40ff8257c9fb69e4c5f6aceb2394f1adc0`. Bridge/helper jobs passed; +its native build and exact regression gate are still running. Do not substitute +the older `bd13500` installed candidate for runtime verification of that guard. +The lab management-backup and private-NIC HTTPS operations remain unapplied +pending the explicit approvals requested after automatic review rejected them. From 0d17a30cd2e323f87e798eabab5aa53918096d3f Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Wed, 21 Oct 2026 18:46:26 +0200 Subject: [PATCH 270/652] feat: add a verified company bundle setup entry point --- .dockerignore | 1 + .github/workflows/company-product.yml | 6 ++ .github/workflows/swan-windows-build.yml | 2 + deployment/linux/Dockerfile.dockerignore | 1 + deployment/windows/Install-Company.ps1 | 21 ++++- .../windows/Install-SwanRemoteSupport.cmd | 12 +++ .../windows/test_company_setup_entrypoint.ps1 | 93 +++++++++++++++++++ docs/ACCEPTANCE.md | 29 ++++++ docs/CUSTOMER_INSTALL.md | 54 ++++++++--- product/management/src/service.rs | 3 + product/protocol/src/lib.rs | 3 +- 11 files changed, 211 insertions(+), 14 deletions(-) create mode 100644 deployment/windows/Install-SwanRemoteSupport.cmd create mode 100644 deployment/windows/test_company_setup_entrypoint.ps1 diff --git a/.dockerignore b/.dockerignore index 1f327a9c018..64c9f2d4bd1 100644 --- a/.dockerignore +++ b/.dockerignore @@ -56,6 +56,7 @@ 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 diff --git a/.github/workflows/company-product.yml b/.github/workflows/company-product.yml index f25b9974dcd..fbcd6b991c7 100644 --- a/.github/workflows/company-product.yml +++ b/.github/workflows/company-product.yml @@ -49,6 +49,12 @@ jobs: 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 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.' } - name: Render the Windows setup wizard without installation if: runner.os == 'Windows' shell: pwsh diff --git a/.github/workflows/swan-windows-build.yml b/.github/workflows/swan-windows-build.yml index 96c619d76f4..41d413ed09a 100644 --- a/.github/workflows/swan-windows-build.yml +++ b/.github/workflows/swan-windows-build.yml @@ -184,6 +184,8 @@ jobs: 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 + & "$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_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 diff --git a/deployment/linux/Dockerfile.dockerignore b/deployment/linux/Dockerfile.dockerignore index 1f327a9c018..64c9f2d4bd1 100644 --- a/deployment/linux/Dockerfile.dockerignore +++ b/deployment/linux/Dockerfile.dockerignore @@ -56,6 +56,7 @@ 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 diff --git a/deployment/windows/Install-Company.ps1 b/deployment/windows/Install-Company.ps1 index 1fd23d9fefc..6bbd62b664b 100644 --- a/deployment/windows/Install-Company.ps1 +++ b/deployment/windows/Install-Company.ps1 @@ -1,6 +1,7 @@ [CmdletBinding()] -param([switch]$UnattendedConsent,[switch]$Repair,[switch]$ReplaceFailedSetup,[string]$BootstrapPath,[string]$ConfirmedCompanyDomain) +param([switch]$UnattendedConsent,[switch]$Repair,[switch]$ReplaceFailedSetup,[string]$BootstrapPath,[string]$ConfirmedCompanyDomain,[switch]$RequestElevation) $ErrorActionPreference = 'Stop' +if (-not [Environment]::Is64BitProcess) { throw 'Use the 64-bit company setup entry point.' } $root = $PSScriptRoot if (-not $BootstrapPath) { $BootstrapPath = Join-Path $root 'bootstrap.json' } $BootstrapPath = [IO.Path]::GetFullPath($BootstrapPath) @@ -8,6 +9,24 @@ $bootstrap = Get-Content -LiteralPath $BootstrapPath -Raw | ConvertFrom-Json if ($bootstrap.edition -notin @('customer','technician')) { throw 'Unknown package edition.' } $domain = [uri]$bootstrap.management_url if ($domain.Scheme -ne 'https' -or $domain.UserInfo -or -not $domain.Host) { throw 'A company HTTPS endpoint is required.' } +if ($RequestElevation -and $bootstrap.edition -eq 'customer') { + $identity = [Security.Principal.WindowsIdentity]::GetCurrent() + if (-not ([Security.Principal.WindowsPrincipal]::new($identity)).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { + $arguments = @('-NoLogo','-NoProfile','-ExecutionPolicy','Bypass','-File',('"' + $PSCommandPath + '"'),'-BootstrapPath',('"' + $BootstrapPath + '"')) + if ($Repair) { $arguments += '-Repair' } + if ($ReplaceFailedSetup) { $arguments += '-ReplaceFailedSetup' } + if ($UnattendedConsent) { $arguments += '-UnattendedConsent' } + if ($ConfirmedCompanyDomain) { + if ($ConfirmedCompanyDomain -cne $domain.Host) { throw 'Company verification cancelled.' } + $arguments += @('-ConfirmedCompanyDomain',('"' + $domain.Host + '"')) + } + try { + $process = Start-Process -FilePath (Join-Path ([Environment]::GetFolderPath('System')) 'WindowsPowerShell/v1.0/powershell.exe') -ArgumentList $arguments -Verb RunAs -PassThru -Wait + } catch { throw 'Customer setup requires administrator approval. Setup was not started.' } + if ($process.ExitCode -ne 0) { throw "Customer setup did not complete ($($process.ExitCode)). Contact your support company before retrying." } + return + } +} Write-Host "Company server: $($domain.AbsoluteUri)" Write-Host 'Confirm this is the support company you intended to install. The company must approve your device before remote access.' if (-not $ConfirmedCompanyDomain) { $ConfirmedCompanyDomain = Read-Host 'Type the company server hostname to confirm' } diff --git a/deployment/windows/Install-SwanRemoteSupport.cmd b/deployment/windows/Install-SwanRemoteSupport.cmd new file mode 100644 index 00000000000..55ca9d4b3bc --- /dev/null +++ b/deployment/windows/Install-SwanRemoteSupport.cmd @@ -0,0 +1,12 @@ +@echo off +setlocal DisableDelayedExpansion +set "swan_powershell=%SystemRoot%\System32\WindowsPowerShell\v1.0\powershell.exe" +if exist "%SystemRoot%\Sysnative\WindowsPowerShell\v1.0\powershell.exe" set "swan_powershell=%SystemRoot%\Sysnative\WindowsPowerShell\v1.0\powershell.exe" +"%swan_powershell%" -NoLogo -NoProfile -ExecutionPolicy Bypass -File "%~dp0Install-Company.ps1" -RequestElevation +set "swan_setup_exit=%errorlevel%" +if not "%swan_setup_exit%"=="0" ( + echo. + echo Setup did not complete. Keep this folder and contact your support company. + pause +) +exit /b %swan_setup_exit% diff --git a/deployment/windows/test_company_setup_entrypoint.ps1 b/deployment/windows/test_company_setup_entrypoint.ps1 new file mode 100644 index 00000000000..40035921179 --- /dev/null +++ b/deployment/windows/test_company_setup_entrypoint.ps1 @@ -0,0 +1,93 @@ +$ErrorActionPreference='Stop' +if (-not [Environment]::Is64BitProcess) { throw 'Run the x64 setup-entry tests.' } +$root=Join-Path ([IO.Path]::GetTempPath()) ('swan-setup-entry-'+[guid]::NewGuid().ToString('N')) +$bundle=Join-Path $root "Company & setup's folder!" +$oldLocal=$env:LOCALAPPDATA +$process=$null +New-Item -ItemType Directory -Path $bundle|Out-Null +try { + foreach($name in @('Install-Company.ps1','Install-SwanRemoteSupport.cmd')) { + $text=[IO.File]::ReadAllText((Join-Path $PSScriptRoot $name)).Replace("`r`n","`n") + [IO.File]::WriteAllText((Join-Path $bundle $name),$text,[Text.UTF8Encoding]::new($false)) + } + $setup=Join-Path $bundle 'Install-Company.ps1' + $bootstrap=Join-Path $bundle 'bootstrap.json' + $env:LOCALAPPDATA=Join-Path $root 'profile' + [IO.File]::WriteAllText((Join-Path $bundle 'swan-agent.exe'),'This fixture must never execute.') + function Write-Bootstrap([string]$Edition,[string]$Url) { + @{edition=$Edition;management_url=$Url}|ConvertTo-Json|Set-Content -LiteralPath $bootstrap -Encoding UTF8 + } + $setupTestState=[pscustomobject]@{Calls=@();ExitCode=0;Cancel=$false} + function Start-Process { + param($FilePath,$ArgumentList,$Verb,[switch]$PassThru,[switch]$Wait) + $setupTestState.Calls+=@{file=$FilePath;arguments=$ArgumentList;verb=$Verb;wait=[bool]$Wait} + if($setupTestState.Cancel){throw 'Simulated elevation cancellation'} + [pscustomobject]@{ExitCode=$setupTestState.ExitCode} + } + function Assert-Rejected([string]$Edition,[string]$Url,[string]$Expected) { + Write-Bootstrap $Edition $Url + $rejected=$false + try { & $setup -RequestElevation -ConfirmedCompanyDomain 'company.test' } + catch { if($_.Exception.Message -cne $Expected){throw};$rejected=$true } + if(-not $rejected -or $setupTestState.Calls.Count){throw 'Preflight unexpectedly started an elevated process'} + } + Assert-Rejected 'other' 'https://company.test' 'Unknown package edition.' + Assert-Rejected 'customer' 'http://company.test' 'A company HTTPS endpoint is required.' + Assert-Rejected 'technician' 'https://company.test' 'Configuration agent is not trusted.' + if(Test-Path $env:LOCALAPPDATA){throw 'Rejected technician setup wrote installation state'} + + $administrator=([Security.Principal.WindowsPrincipal]::new([Security.Principal.WindowsIdentity]::GetCurrent())).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) + if(-not $administrator) { + Write-Bootstrap 'customer' 'https://company.test' + & $setup -RequestElevation -ConfirmedCompanyDomain 'company.test' -Repair -ReplaceFailedSetup -UnattendedConsent + if($setupTestState.Calls.Count -ne 1 -or $setupTestState.Calls[0].verb -cne 'RunAs' -or -not $setupTestState.Calls[0].wait){throw 'Customer setup did not request one awaited elevation'} + $a=$setupTestState.Calls[0].arguments + foreach($value in @(('"'+$setup+'"'),('"'+$bootstrap+'"'),'-Repair','-ReplaceFailedSetup','-UnattendedConsent','"company.test"')){if($value -cnotin $a){throw 'Elevation lost a quoted path or explicit setup option'}} + if('-RequestElevation' -in $a){throw 'Elevation could loop'} + $setupTestState.Calls=@();$setupTestState.ExitCode=32 + $rejected=$false + try { & $setup -RequestElevation -ConfirmedCompanyDomain 'company.test' } + catch { if($_.Exception.Message -notlike 'Customer setup did not complete (32).*'){throw};$rejected=$true } + if(-not $rejected){throw 'Elevated failure was reported as success'} + $setupTestState.Calls=@();$setupTestState.Cancel=$true;$rejected=$false + try { & $setup -RequestElevation -ConfirmedCompanyDomain 'company.test' } + catch { if($_.Exception.Message -cne 'Customer setup requires administrator approval. Setup was not started.'){throw};$rejected=$true } + if(-not $rejected){throw 'Cancelled elevation did not fail closed'} + } + Remove-Item Function:\Start-Process + + # Exercise the real CMD/Windows PowerShell path with LF bundle text, spaces, + # apostrophe and ampersand in its folder. The invalid fixture agent must fail + # signature verification before any installation or enrollment can occur. + Write-Bootstrap 'technician' 'https://company.test' + $start=[Diagnostics.ProcessStartInfo]::new() + $start.FileName=Join-Path ([Environment]::GetFolderPath('System')) 'cmd.exe' + $start.Arguments='/d /s /c ""'+(Join-Path $bundle 'Install-SwanRemoteSupport.cmd')+'""' + $start.UseShellExecute=$false;$start.CreateNoWindow=$true + $start.RedirectStandardInput=$true;$start.RedirectStandardOutput=$true;$start.RedirectStandardError=$true + $process=[Diagnostics.Process]::Start($start) + $stdout=$process.StandardOutput.ReadToEndAsync();$stderr=$process.StandardError.ReadToEndAsync() + $process.StandardInput.WriteLine('company.test');$process.StandardInput.Close() + if(-not $process.WaitForExit(60000)){throw 'Setup fixture did not exit; inspect the owned test process'} + $output=$stdout.GetAwaiter().GetResult()+$stderr.GetAwaiter().GetResult() + if($process.ExitCode -ne 1 -or $output -notmatch 'Configuration agent is not trusted.' -or $output -notmatch 'Setup did not complete.'){throw 'Real entry point lost its path, trust rejection or failure exit code'} + $process.Dispose() + $process=$null + if(Test-Path $env:LOCALAPPDATA){throw 'Rejected CMD setup wrote installation state'} + @{real_cmd_trust_rejection=$true;technician_never_elevates=$true;unsafe_bootstrap_rejected=$true;elevation_routing_fixture_tested=(-not $administrator);actual_uac_tested=$false;scope='Entry-point routing and real unsigned-agent rejection; no successful product installation'}|ConvertTo-Json -Compress +} finally { + if($null -ne $process) { + try { + if(-not $process.HasExited) { + # This handle identifies only the CMD process created above and + # its fixture children; never stop a user's existing console. + & (Join-Path ([Environment]::GetFolderPath('System')) 'taskkill.exe') /PID $process.Id /T /F|Out-Null + } + } finally { $process.Dispose() } + } + Remove-Item Function:\Start-Process -ErrorAction SilentlyContinue + $env:LOCALAPPDATA=$oldLocal + $resolved=[IO.Path]::GetFullPath($root) + if([IO.Path]::GetDirectoryName($resolved) -ine [IO.Path]::GetFullPath([IO.Path]::GetTempPath()).TrimEnd('\') -or [IO.Path]::GetFileName($resolved) -notmatch '^swan-setup-entry-[a-f0-9]{32}$'){throw 'Fixture cleanup path changed'} + Remove-Item -LiteralPath $resolved -Recurse -Force +} diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index bcacbe8b556..4bcdfdce09e 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -2874,3 +2874,32 @@ its native build and exact regression gate are still running. Do not substitute the older `bd13500` installed candidate for runtime verification of that guard. The lab management-backup and private-NIC HTTPS operations remain unapplied pending the explicit approvals requested after automatic review rejected them. + +### Company bundle entry point implementation, 21 October + +The company bundle recipe now includes `Install-SwanRemoteSupport.cmd` as an +exact server-validated file. It starts 64-bit Windows PowerShell from the +extracted bundle, including from a 32-bit calling environment. Customer setup +can request elevation before enrollment or installation state is created; +technician setup retains the current user's context. Explicit repair, failed +setup replacement and consent flags survive a customer elevation handoff, while +the elevated child still performs the existing company and package checks. +A cancelled or failed handoff returns failure. The default entry point does not +opt into unattended access. + +Local Windows PowerShell 5.1 checks passed: malformed/HTTP bootstrap rejection, +no technician elevation or state writes on signature rejection, argument/error +routing fixtures for a non-admin customer caller, and a real CMD-to-PowerShell +run with LF bundle text and spaces, ampersand, apostrophe and exclamation mark in +the folder name. That real entry-point run rejected an unsigned inert agent and +returned failure without installation or enrollment. The elevation broker was +replaced only in the routing fixture; these checks do not demonstrate a real +successful UAC handoff or a complete installed company bundle. + +Windows CI now runs the entry-point checks, and bundle validation tests reject +missing or changed entry-point bytes. Both Docker context allowlists include +only this new required public script. CI builds and actual company-generated +bundle acceptance for this recipe remain pending. Server and worker must use +the matching updated recipe. The customer installation guide now describes the +configurable company flow and links the Tailscale instructions to the preserved +baseline rather than presenting them as current setup. diff --git a/docs/CUSTOMER_INSTALL.md b/docs/CUSTOMER_INSTALL.md index 93ba06a6e57..8758348db84 100644 --- a/docs/CUSTOMER_INSTALL.md +++ b/docs/CUSTOMER_INSTALL.md @@ -1,16 +1,46 @@ -# Swan Remote Support customer installation +# Company customer installation -This release is for an installation performed by Swan Computing with the computer owner's authorization. Do not proceed if the customer has not agreed to the support scope and whether access will be attended or unattended. +The configurable product is still undergoing acceptance testing. See +[the acceptance report](ACCEPTANCE.md) before publishing production downloads. +Each company operates its own server and publishes its customer bundle from its +verified HTTPS website. Customer setup does not require Tailscale or manual +server-address changes. -1. Extract the entire release ZIP to a normal local folder. -2. Confirm Swan has published the same release version and SHA-256 on its HTTPS download page. -3. Double-click `Install-SwanRemoteSupport.cmd` and approve the Windows administrator prompt. -4. Complete the manual Tailscale login when the browser opens. Swan may need to approve the restricted customer-device tag in the Tailscale admin console. -5. Wait while the installer verifies the network, installs Swan, and creates a unique device credential. -6. Swan stores the one-time receipt in its password manager and clears the clipboard. -7. Confirm the Swan tray icon is visible and ask Swan to demonstrate how to stop or uninstall the service. -8. Test one authorized connection, restart Windows, and test again at the sign-in screen before considering installation complete. +1. Download the complete customer ZIP from your support company's website. +2. Extract the whole ZIP to a normal local folder. Keep its installer, scripts, + `bootstrap.json`, signed profile and signed release metadata together. +3. Double-click `Install-SwanRemoteSupport.cmd`. Customer setup requests Windows + administrator approval; declining it leaves setup unstarted. +4. Check the company's HTTPS hostname shown by setup and type that hostname to + confirm the company you intended to install. +5. Setup verifies company configuration and the release's package hash and trusted + publisher, installs the app, and enrolls this device. No technician password or + reusable privileged enrollment secret is included in the public bundle. +6. Wait for the company to approve the new device. Sessions require an authorized + technician and your approval by default. +7. Ask the company to demonstrate support status, stop controls and uninstall. + Unattended support requires separate explicit consent; it is not enabled by + double-clicking the setup entry point. -The installer must show a valid trusted publisher. Do not disable Microsoft Defender, SmartScreen, Windows Firewall, or another antivirus product to make the installation succeed. Stop and contact Swan through its verified business channel if the signature is missing, the publisher is unexpected, the hash differs, Tailscale requests the wrong account/tailnet, or any security product reports the release. +Use the complete company bundle for setup. Running the generic EXE/MSI alone +does not supply company configuration or complete managed enrollment. Managed +deployment and explicit repair can use `Install-Company.ps1` with an explicit +`-BootstrapPath`; see [installer repair](INSTALLER_REPAIR.md). -Swan Remote Support is based on RustDesk and its corresponding source is linked from the release. The official Tailscale client is downloaded separately from Tailscale and remains a Tailscale product. See the public [privacy statement](PRIVACY.md), [security policy](../SECURITY.md), and [source/licensing information](SOURCE_AND_LICENSE.md). +Technician bundles use the same double-click entry point and install for the +current Windows user without requesting elevation. Obtain them through the +company's authenticated internal download. Windows Installer policy still +applies to technician MSI installation; company IT must arrange an approved +deployment when unmanaged per-user packages are blocked. Technician login and +device authorization happen in the installed app. + +The app retains product attribution and license information in About. Project +binaries retain their certificate publisher even when the company customizes +their visible branding. Missing or unexpected signatures and failed verification +must be resolved through the support company; setup does not bypass those checks. +See [privacy](PRIVACY.md), [security](../SECURITY.md), and +[source/licensing information](SOURCE_AND_LICENSE.md). + +The original Swan-only Tailscale deployment is preserved in the +[tagged baseline](https://github.com/rijff24/SwanRemoteSupport/tree/swan-single-company-baseline-1.4.9), +with its original installation and build instructions. diff --git a/product/management/src/service.rs b/product/management/src/service.rs index 65c2ab1f033..00c57d95c3f 100644 --- a/product/management/src/service.rs +++ b/product/management/src/service.rs @@ -697,6 +697,9 @@ mod worker_completion_tests { assert_eq!(entries.len(),recipe_count); let verify=|bytes:&[u8]|validate_worker_bundle(bytes,&job,&release,&company.verifying_key(),&trust).is_ok(); let valid=archive(&entries);assert!(verify(&valid),"Complete {edition:?}/{format} bundle was rejected"); + let entrypoint=entries.iter().position(|(name,_)|name=="Install-SwanRemoteSupport.cmd").unwrap(); + let mut altered=entries.clone();altered[entrypoint].1=b"altered setup entry point".to_vec();assert!(!verify(&archive(&altered))); + let mut missing=entries.clone();missing.remove(entrypoint);assert!(!verify(&archive(&missing))); let helper=entries.iter().position(|(name,_)|name=="Get-MsiInstallMode.ps1").unwrap(); let mut altered=entries.clone();altered[helper].1=b"altered script".to_vec();assert!(!verify(&archive(&altered))); let uninstall=entries.iter().position(|(name,_)|name=="Uninstall-Technician.ps1").unwrap(); diff --git a/product/protocol/src/lib.rs b/product/protocol/src/lib.rs index 2b1dd5a79f3..06f38a0da5a 100644 --- a/product/protocol/src/lib.rs +++ b/product/protocol/src/lib.rs @@ -10,7 +10,8 @@ pub const SCHEMA: u32 = 1; pub const PRODUCT: &str = "Swan Remote Support"; pub const WIRE_PREFIX: &[u8] = b"SWAN1:"; // Worker output and server upload validation must use the same trusted recipe. -pub const COMPANY_BUNDLE_TEXT_FILES:[(&str,&[u8]);6]=[ +pub const COMPANY_BUNDLE_TEXT_FILES:[(&str,&[u8]);7]=[ + ("Install-SwanRemoteSupport.cmd",include_bytes!("../../../deployment/windows/Install-SwanRemoteSupport.cmd")), ("Install-Company.ps1",include_bytes!("../../../deployment/windows/Install-Company.ps1")), ("Get-MsiInstallMode.ps1",include_bytes!("../../../deployment/windows/Get-MsiInstallMode.ps1")), ("Open-Technician.ps1",include_bytes!("../../../deployment/windows/Open-Technician.ps1")), From 90be887e42cf19293b3aee1ec2e4a64f3a782096 Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Wed, 21 Oct 2026 18:59:55 +0200 Subject: [PATCH 271/652] test: retain bounded setup entry-point failure diagnostics --- deployment/windows/test_company_setup_entrypoint.ps1 | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/deployment/windows/test_company_setup_entrypoint.ps1 b/deployment/windows/test_company_setup_entrypoint.ps1 index 40035921179..494761fcf7f 100644 --- a/deployment/windows/test_company_setup_entrypoint.ps1 +++ b/deployment/windows/test_company_setup_entrypoint.ps1 @@ -70,7 +70,11 @@ try { $process.StandardInput.WriteLine('company.test');$process.StandardInput.Close() if(-not $process.WaitForExit(60000)){throw 'Setup fixture did not exit; inspect the owned test process'} $output=$stdout.GetAwaiter().GetResult()+$stderr.GetAwaiter().GetResult() - if($process.ExitCode -ne 1 -or $output -notmatch 'Configuration agent is not trusted.' -or $output -notmatch 'Setup did not complete.'){throw 'Real entry point lost its path, trust rejection or failure exit code'} + if($process.ExitCode -ne 1 -or $output -notmatch 'Configuration agent is not trusted.' -or $output -notmatch 'Setup did not complete.'){ + # This subprocess has only inert fixture inputs, no credentials or company state. + Write-Output (@{fixture_exit_code=$process.ExitCode;fixture_output=$output.Substring(0,[Math]::Min($output.Length,4096))}|ConvertTo-Json -Compress) + throw 'Real entry point lost its path, trust rejection or failure exit code' + } $process.Dispose() $process=$null if(Test-Path $env:LOCALAPPDATA){throw 'Rejected CMD setup wrote installation state'} From ee857fdb8aca8cd02fbd4eb0a42353b916308031 Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Wed, 21 Oct 2026 19:08:41 +0200 Subject: [PATCH 272/652] test: send setup fixture input without an encoding preamble --- deployment/windows/test_company_setup_entrypoint.ps1 | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/deployment/windows/test_company_setup_entrypoint.ps1 b/deployment/windows/test_company_setup_entrypoint.ps1 index 494761fcf7f..4c8e1d87a21 100644 --- a/deployment/windows/test_company_setup_entrypoint.ps1 +++ b/deployment/windows/test_company_setup_entrypoint.ps1 @@ -67,7 +67,10 @@ try { $start.RedirectStandardInput=$true;$start.RedirectStandardOutput=$true;$start.RedirectStandardError=$true $process=[Diagnostics.Process]::Start($start) $stdout=$process.StandardOutput.ReadToEndAsync();$stderr=$process.StandardError.ReadToEndAsync() - $process.StandardInput.WriteLine('company.test');$process.StandardInput.Close() + # Windows PowerShell may select a BOM-emitting encoding for redirected + # input on hosted runners. Supply the same plain hostname a user types. + $inputWriter=[IO.StreamWriter]::new($process.StandardInput.BaseStream,[Text.UTF8Encoding]::new($false)) + try { $inputWriter.WriteLine('company.test') } finally { $inputWriter.Dispose() } if(-not $process.WaitForExit(60000)){throw 'Setup fixture did not exit; inspect the owned test process'} $output=$stdout.GetAwaiter().GetResult()+$stderr.GetAwaiter().GetResult() if($process.ExitCode -ne 1 -or $output -notmatch 'Configuration agent is not trusted.' -or $output -notmatch 'Setup did not complete.'){ From 43e5b37dc09fa0ed04bb5e602ca07dc19d705592 Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Wed, 21 Oct 2026 19:15:29 +0200 Subject: [PATCH 273/652] build: preserve testable company components with matching source --- .github/workflows/company-product.yml | 31 ++++++++++++++ deployment/windows/archive-build-source.py | 20 +++++---- deployment/windows/test_build_source.py | 25 +++++++++++ .../windows/test_company_setup_entrypoint.ps1 | 11 +++-- docs/ACCEPTANCE.md | 41 +++++++++++++++++++ product/README.md | 9 ++++ 6 files changed, 127 insertions(+), 10 deletions(-) diff --git a/.github/workflows/company-product.yml b/.github/workflows/company-product.yml index fbcd6b991c7..7a268eaa643 100644 --- a/.github/workflows/company-product.yml +++ b/.github/workflows/company-product.yml @@ -32,6 +32,12 @@ jobs: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - name: Install pinned Rust run: rustup toolchain install 1.90.0 --profile minimal + - name: Preserve company component source inputs + shell: bash + run: | + python -m unittest discover -s deployment/windows -p test_build_source.py + mkdir -p component-artifacts + python deployment/windows/archive-build-source.py . component-artifacts/CompanyComponents-source.zip --components-only - name: Test security and management run: cargo +1.90.0 test --manifest-path product/Cargo.toml --workspace --locked - name: Build components @@ -61,6 +67,31 @@ jobs: 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: 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 diff --git a/deployment/windows/archive-build-source.py b/deployment/windows/archive-build-source.py index 52df2f7390e..159c86286fc 100644 --- a/deployment/windows/archive-build-source.py +++ b/deployment/windows/archive-build-source.py @@ -29,7 +29,7 @@ def safe_path(value): return path -def collect(root): +def collect(root, components_only=False): files, repositories = {}, {} def repository(directory, prefix="", expected=None): @@ -45,6 +45,11 @@ def repository(directory, prefix="", expected=None): raise ValueError("Unmerged source index") relative = name.decode("utf-8") safe_path(relative) + if components_only and not ( + prefix + relative in ("LICENCE", ".cargo/config.toml", ".dockerignore") + or (prefix + relative).startswith(("product/", "deployment/", "branding/")) + ): + continue archive_name = prefix + relative path = directory / relative if path.is_symlink() or not path.resolve().is_relative_to(root): @@ -61,7 +66,7 @@ def repository(directory, prefix="", expected=None): raise ValueError("Unsupported source file mode") repository(root) - for name in BRIDGE_FILES: + for name in (() if components_only else BRIDGE_FILES): path = root / name if not path.is_file() or path.is_symlink() or not path.resolve().is_relative_to(root): raise ValueError("Generated bridge source missing or unsafe") @@ -69,12 +74,12 @@ def repository(directory, prefix="", expected=None): return files, repositories -def archive(root, output): +def archive(root, output, components_only=False): root = root.resolve(strict=True) - files, repositories = collect(root) + files, repositories = collect(root, components_only) # Explicit exclusive creation prevents silently replacing release evidence. - manifest = {"schema": 1, "snapshot": "before-application-compilation", - "repositories": repositories, "generated_bridge_files": list(BRIDGE_FILES), + manifest = {"schema": 1, "snapshot": "before-component-compilation" if components_only else "before-application-compilation", + "repositories": repositories, "generated_bridge_files": [] if components_only else list(BRIDGE_FILES), "files": {}} with output.open("xb") as destination, zipfile.ZipFile(destination, "w", compression=zipfile.ZIP_DEFLATED) as package: for name, (path, mode) in sorted(files.items()): @@ -103,5 +108,6 @@ def archive(root, output): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("root", type=Path) parser.add_argument("output", type=Path) + parser.add_argument("--components-only", action="store_true", help="Archive company components and their deployment inputs without unrelated client submodules or generated bridges") args = parser.parse_args() - print(archive(args.root, args.output)) + print(archive(args.root, args.output, args.components_only)) diff --git a/deployment/windows/test_build_source.py b/deployment/windows/test_build_source.py index 054985039d3..4b1d9215499 100644 --- a/deployment/windows/test_build_source.py +++ b/deployment/windows/test_build_source.py @@ -53,6 +53,31 @@ def test_missing_generated_input_fails_before_output(self): source.archive(root, output) self.assertFalse(output.exists()) + def test_component_archive_preserves_embedded_inputs_without_client_bridges_or_private_files(self): + with tempfile.TemporaryDirectory() as folder: + root = Path(folder) / "checkout" + self.fixture(root) + names = ("product/Cargo.lock", "product/management/src/main.rs", "deployment/windows/Install-SwanRemoteSupport.cmd", + "branding/swan-support-mark.svg", "LICENCE", ".cargo/config.toml", ".dockerignore") + for name in names: + path = root / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(b"public build input\r\n") + subprocess.run(["git", "-C", str(root), "add", name], check=True, capture_output=True) + (root / "product/private.env").write_text("private canary", encoding="utf-8") + # A component build does not initialize or consume desktop bridges. + for name in source.BRIDGE_FILES: + (root / name).unlink() + output, repeated = Path(folder) / "component.zip", Path(folder) / "repeated.zip" + self.assertEqual(source.archive(root, output, True), source.archive(root, repeated, True)) + with zipfile.ZipFile(output) as package: + receipt = json.loads(package.read("BUILD-SOURCE.json")) + self.assertEqual(receipt["generated_bridge_files"], []) + self.assertEqual(set(receipt["files"]), set(names)) + self.assertEqual(set(package.namelist()), {"BUILD-SOURCE.json", *("source/" + name for name in names)}) + for name in names: + self.assertEqual(package.read("source/" + name), b"public build input\r\n") + def test_submodule_pin_mismatch_is_rejected(self): with tempfile.TemporaryDirectory() as folder: root = Path(folder) / "checkout" diff --git a/deployment/windows/test_company_setup_entrypoint.ps1 b/deployment/windows/test_company_setup_entrypoint.ps1 index 4c8e1d87a21..64ae49b55c6 100644 --- a/deployment/windows/test_company_setup_entrypoint.ps1 +++ b/deployment/windows/test_company_setup_entrypoint.ps1 @@ -65,12 +65,17 @@ try { $start.Arguments='/d /s /c ""'+(Join-Path $bundle 'Install-SwanRemoteSupport.cmd')+'""' $start.UseShellExecute=$false;$start.CreateNoWindow=$true $start.RedirectStandardInput=$true;$start.RedirectStandardOutput=$true;$start.RedirectStandardError=$true - $process=[Diagnostics.Process]::Start($start) + # .NET Framework creates and flushes its stdin writer during Process.Start, + # before StandardInput is first accessed. Set its encoding at creation time. + $previousInputEncoding=[Console]::InputEncoding + try { + [Console]::InputEncoding=[Text.UTF8Encoding]::new($false) + $process=[Diagnostics.Process]::Start($start) + } finally { [Console]::InputEncoding=$previousInputEncoding } $stdout=$process.StandardOutput.ReadToEndAsync();$stderr=$process.StandardError.ReadToEndAsync() # Windows PowerShell may select a BOM-emitting encoding for redirected # input on hosted runners. Supply the same plain hostname a user types. - $inputWriter=[IO.StreamWriter]::new($process.StandardInput.BaseStream,[Text.UTF8Encoding]::new($false)) - try { $inputWriter.WriteLine('company.test') } finally { $inputWriter.Dispose() } + $process.StandardInput.WriteLine('company.test');$process.StandardInput.Close() if(-not $process.WaitForExit(60000)){throw 'Setup fixture did not exit; inspect the owned test process'} $output=$stdout.GetAwaiter().GetResult()+$stderr.GetAwaiter().GetResult() if($process.ExitCode -ne 1 -or $output -notmatch 'Configuration agent is not trusted.' -or $output -notmatch 'Setup did not complete.'){ diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index 4bcdfdce09e..872644bc77c 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -2903,3 +2903,44 @@ bundle acceptance for this recipe remain pending. Server and worker must use the matching updated recipe. The customer installation guide now describes the configurable company flow and links the Tailscale instructions to the preserved baseline rather than presenting them as current setup. + +### Real setup elevation handoff and native guard build, 21 October + +The actual Server 2016 UAC broker was exercised from the existing standard lab +account in interactive session 1 using setup source +`0d17a30cd2e323f87e798eabab5aa53918096d3f`. The expanded prompt showed the exact +pinned setup path, bootstrap path and confirmed test domain. Authentication used +only the disposable VM's administrator. The parent received elevated exit code 1 +from the unsigned inert-agent fixture and correctly reported failure. Its task +completed successfully as a test, with the parent still non-administrator. +An independent fingerprint comparison preserved installed customer identity, +credential, unattended consent/revision and installed release. This verifies +actual elevation and failed-child propagation; it is not successful installation +or remote-session control of a UAC secure desktop. + +The native guard workflow `39631663952` completed successfully. Its native log +records exactly one executed passing +`hbbs_http::sync::managed_sync_tests::managed_sync_rejects_legacy_endpoint_and_unsigned_options` +test (0 failures, 71 filtered out). Artifact `12365255815` is 141,969,478 bytes, +with GitHub SHA-256 `8de1a2b18ffffa86106d24dbbc2f641dec225c802b9f7d18afa5417282a3c391`. +The existing download is live and progressing; archive/source validation, +private signing and installed runtime rejection of legacy traffic remain open. +This build predates the new company-bundle CMD entry point. + +Hosted Windows CI exposed a setup-test input bug: .NET Framework flushed a UTF-8 +byte-order mark into redirected stdin at process creation, before a replacement +writer could be attached. Setup safely rejected the resulting hostname mismatch. +That failure was reproduced locally with a BOM-emitting console input encoding. +The fixture now selects a BOM-free encoding during process creation and restores +the previous encoding immediately; both the reproduced environment and normal +Windows PowerShell run pass. Production hostname confirmation was not relaxed. +Hosted CI verification of this correction is pending. + +The component workflow now preserves corresponding component/deployment source +before compilation and exports unsigned test binaries with source/compiler +metadata and hashes after successful checks. The source archiver's component +mode omits unrelated client bridges/submodules and private untracked files while +preserving actual file bytes; all five source archive tests pass locally. +These new artifacts and their provenance still require CI validation. Production +signing, matching company server/worker runtime, public connectivity, complete +Windows coverage and the remaining session/update/restore gates remain open. diff --git a/product/README.md b/product/README.md index fcf16abd591..11caea23dfd 100644 --- a/product/README.md +++ b/product/README.md @@ -15,6 +15,15 @@ cargo +1.90.0 build --manifest-path product/Cargo.toml --workspace --release --l Windows production builds use MSVC. GNU compilation has verified the product components on the local development machine. Native endpoint builds additionally require the pinned Flutter and vcpkg dependencies in `.github/workflows/swan-windows-build.yml`; the company-product CI separately checks Windows and Linux management components. The root application enables management integration with the `swan_custom` feature. +Successful company-product CI jobs export `CompanyComponents--UNSIGNED-TEST` +artifacts containing the management service, configuration agent and worker, +their SHA-256 values, compiler/source revision metadata and a source-input archive. +The archive preserves tracked component, branding and deployment inputs with their +actual build-time bytes; private environment files and desktop bridge generation +are excluded. Rebuild from its `source/` folder using the commands above. These +are unsigned verification inputs, not production releases or complete desktop +installation bundles. The installer worker still requires Windows. + ## Implemented development flows - One-time setup, local password/TOTP authentication with replay checks, administrator and technician roles, group access, pending device enrollment, revocation and auditing. From ce270b54b1f867908c1ade402919211b52a4f724 Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Wed, 21 Oct 2026 19:25:30 +0200 Subject: [PATCH 274/652] docs: record passing company component and setup entrypoint CI --- docs/ACCEPTANCE.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index 872644bc77c..6590e103e44 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -2934,13 +2934,13 @@ That failure was reproduced locally with a BOM-emitting console input encoding. The fixture now selects a BOM-free encoding during process creation and restores the previous encoding immediately; both the reproduced environment and normal Windows PowerShell run pass. Production hostname confirmation was not relaxed. -Hosted CI verification of this correction is pending. +Hosted Windows CI subsequently passed in push run `39642194595` and PR run `39642203316` for source `43e5b37dc09fa0ed04bb5e602ca07dc19d705592`. Both runs also passed Linux component and Linux deployment verification. The component workflow now preserves corresponding component/deployment source before compilation and exports unsigned test binaries with source/compiler metadata and hashes after successful checks. The source archiver's component mode omits unrelated client bridges/submodules and private untracked files while preserving actual file bytes; all five source archive tests pass locally. -These new artifacts and their provenance still require CI validation. Production +The successful push run exported Windows and Linux component artifacts, each with corresponding source and hashes. Independent downloaded artifact/source verification and runtime acceptance remain pending. Production signing, matching company server/worker runtime, public connectivity, complete Windows coverage and the remaining session/update/restore gates remain open. From 481439d62af5189706a1b83263af53d603f7de19 Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Wed, 21 Oct 2026 19:31:13 +0200 Subject: [PATCH 275/652] fix: reject incompatible installer workers before claiming jobs --- docs/ACCEPTANCE.md | 31 ++++++++++++++++ product/README.md | 10 ++++++ product/management/src/service.rs | 59 ++++++++++++++++++++++++++++++- product/protocol/src/lib.rs | 26 ++++++++++++++ product/worker/src/main.rs | 48 ++++++++++++++++++++++++- 5 files changed, 172 insertions(+), 2 deletions(-) diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index 6590e103e44..0c0e19d3398 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -2944,3 +2944,34 @@ preserving actual file bytes; all five source archive tests pass locally. The successful push run exported Windows and Linux component artifacts, each with corresponding source and hashes. Independent downloaded artifact/source verification and runtime acceptance remain pending. Production signing, matching company server/worker runtime, public connectivity, complete Windows coverage and the remaining session/update/restore gates remain open. + +### Downloaded component provenance and fresh management runtime, 21 October + +Windows component artifact `12366376059` from successful workflow `39642194595` +was downloaded and checked against GitHub's SHA-256 +`6cb1a4f73f8fd601b26b2530b7cb393ead02ac7b17f79955895ea0ce21a60963`. +All five entries passed CRC and declared hash verification. An independent check +compared all 114 component source files, modes and manifest hashes with public +Git revision `43e5b37dc09fa0ed04bb5e602ca07dc19d705592`, accounting for checkout +line endings. These checks prove corresponding build inputs, not bit-for-bit +binary reproducibility or production signing. + +That exact management executable ran against a fresh, protected company directory +and an ephemeral host loopback HTTP listener. Seventy-four assertions passed: +one-time setup, setup/MFA replay rejection, signed branded profile revision, +technician role and group restrictions, pending enrollment denial, target-bound +signed grants, tampered and repeated claim rejection, permission reduction, +unattended opt-in/revocation and stale consent rejection, build rejection without +queue mutation, worker authentication/revocation, audit history, user disable and +logout. A process restart preserved company key, branding revision, device +enrollment, closed grant history and revoked access. The isolated test process +was stopped afterward; existing company and transport services were untouched. +This is executable/SQLite/HTTP API evidence, not HTTPS deployment, native desktop +authorization, installer generation, migration, backup/restore or update evidence. + +The earlier worker/server bundle upload rejection motivates a compatibility +handshake before claiming jobs: the worker checks an authenticated recipe endpoint +and the server checks the matching versioned digest on every claim before queue +mutation. Old or different recipes are rejected explicitly. Source regression +checks cover preserved queued/expired claims on mismatch and preflight responses +from old/incompatible servers; CI and installed worker verification are pending. diff --git a/product/README.md b/product/README.md index 11caea23dfd..b149045150f 100644 --- a/product/README.md +++ b/product/README.md @@ -34,6 +34,16 @@ installation bundles. The installer worker still requires Windows. Installers contain public bootstrap information only. The worker pins both profile and project release verification keys locally. Release metadata must include package, installed executable and agent SHA-256 identities, exact source URL, publisher, signing-certificate SHA-256 and supported Windows families. No profile value becomes a shell command. +Install management and installer workers from matching project releases. Before +requesting work, a worker authenticates to the server's recipe endpoint and checks +the versioned digest of the fixed ZIP layout and normalized bundled script/license +bytes. The claim request repeats this digest; a mismatch returns HTTP 409 before +claiming or recovering jobs. Older workers without the digest cannot claim on a +new server, and a new worker declines an older server without the recipe endpoint. +This compatibility check leaves jobs queued for a matching worker and reports the +mismatch in its protected log. Upload signature, hash and exact-content validation +remain mandatory; the recipe digest does not authorize different scripts. + The native Flutter technician home now includes company branding, MFA login, authorized inventory, attended/unattended connection requests and session history. Its Rust broker retains tokens and proof keys in memory and consumes the selected peer's ticket during the transport challenge. The graphical flow requires native build and end-to-end validation before it is considered delivered. The launcher opens this interface; automatic technician update handoff while closing the running executable remains incomplete. The updater writes `pending-update.json` before installation and advances the sequence only after verifying the complete signed installed payload and agent. A staged, publisher-verified replacement agent performs installation and replaces the configuration agent while retaining session exclusion. Pending receipts support delayed retries; an already registered MSI uses forced repair. `swan-agent recover-update` completes recovery only when the signed installed identity matches. These paths compile and have component trust tests; actual signed self-replacement, interrupted installation and scheduled-task restart remain unverified. Full rollback and automatic technician GUI shutdown/handoff remain incomplete. diff --git a/product/management/src/service.rs b/product/management/src/service.rs index 00c57d95c3f..1045546c9fa 100644 --- a/product/management/src/service.rs +++ b/product/management/src/service.rs @@ -196,6 +196,7 @@ pub fn router(store:Shared)->Router { .route("/api/v1/workers/{id}",axum::routing::delete(revoke_worker)) .route("/api/v1/builds",get(builds).post(create_build)) .route("/api/v1/worker/claim",post(claim_build)) + .route("/api/v1/worker/recipe",get(worker_recipe)) .route("/api/v1/worker/builds/{id}",put(finish_build)) .route("/api/v1/worker/builds/{id}/artifact",put(upload_artifact).layer(DefaultBodyLimit::max(512*1024*1024))) .route("/api/v1/downloads/{id}",get(download_artifact)) @@ -565,8 +566,16 @@ async fn builds(State(s):State,headers:HeaderMap)->ApiResult { s.user(&headers,true)?;let db=s.db.lock().unwrap();let mut stmt=db.prepare("SELECT id,state,result FROM builds ORDER BY rowid DESC LIMIT 100")?; let rows=stmt.query_map([],|r|Ok(json!({"id":r.get::<_,String>(0)?,"state":r.get::<_,String>(1)?,"result":r.get::<_,String>(2)?})))?.collect::,_>>()?;Ok(Json(json!(rows))) } +async fn worker_recipe(State(s):State,headers:HeaderMap)->ApiResult { + s.worker(&headers)?; + Ok(Json(json!({"schema":1,"bundle_recipe":company_bundle_recipe()?}))) +} async fn claim_build(State(s):State,headers:HeaderMap)->ApiResult { s.worker(&headers)?;let worker=digest(token(&headers)?); + let recipe=company_bundle_recipe()?; + if headers.get(COMPANY_BUNDLE_RECIPE_HEADER).and_then(|value|value.to_str().ok())!=Some(recipe.as_str()) { + return Err(ApiError(StatusCode::CONFLICT,"Company bundle recipe mismatch; install matching server and worker releases. No job claimed")); + } let mut db=s.db.lock().unwrap();let tx=db.transaction()?; // Recheck under the claim transaction: revocation may follow the initial // authentication check before this request acquires the database lock. @@ -584,6 +593,53 @@ struct BuildResult { success:bool, artifact_url:String,sha256:String,log:String mod worker_completion_tests { use super::*; #[tokio::test] + async fn incompatible_worker_cannot_claim_or_requeue_jobs() { + use tower::ServiceExt; + let directory=std::env::temp_dir().join(format!("swan-worker-recipe-{}",random_token())); + let store=Arc::new(Store::open(&directory).unwrap());let credential=random_token(); + { + let db=store.db.lock().unwrap(); + db.execute("INSERT INTO workers(id,token_hash) VALUES('worker',?1)",[digest(&credential)]).unwrap(); + db.execute("INSERT INTO builds(id,body,state,worker,claimed_at) VALUES('queued','{}','queued','',0),('expired','{}','running','other-worker',0)",[]).unwrap(); + } + let app=router(store.clone());let recipe=company_bundle_recipe().unwrap(); + let request=axum::http::Request::builder().uri("/api/v1/worker/recipe").body(axum::body::Body::empty()).unwrap(); + assert_eq!(app.clone().oneshot(request).await.unwrap().status(),StatusCode::UNAUTHORIZED); + let request=axum::http::Request::builder().uri("/api/v1/worker/recipe") + .header(header::AUTHORIZATION,format!("Bearer {credential}")).body(axum::body::Body::empty()).unwrap(); + let response=app.clone().oneshot(request).await.unwrap();assert_eq!(response.status(),StatusCode::OK); + let bytes=axum::body::to_bytes(response.into_body(),4096).await.unwrap(); + assert_eq!(serde_json::from_slice::(&bytes).unwrap(),json!({"schema":1,"bundle_recipe":recipe})); + for (token,header,expected) in [ + ("invalid",None,StatusCode::UNAUTHORIZED), + ("invalid",Some(recipe.as_str()),StatusCode::UNAUTHORIZED), + (credential.as_str(),None,StatusCode::CONFLICT), + (credential.as_str(),Some("old-recipe"),StatusCode::CONFLICT), + (credential.as_str(),Some(""),StatusCode::CONFLICT), + ] { + let mut request=axum::http::Request::builder().method("POST").uri("/api/v1/worker/claim").header(header::AUTHORIZATION,format!("Bearer {token}")); + if let Some(value)=header {request=request.header(COMPANY_BUNDLE_RECIPE_HEADER,value);} + let response=app.clone().oneshot(request.body(axum::body::Body::empty()).unwrap()).await.unwrap(); + assert_eq!(response.status(),expected); + let db=store.db.lock().unwrap(); + let queued:(String,String,i64)=db.query_row("SELECT state,worker,claimed_at FROM builds WHERE id='queued'",[],|r|Ok((r.get(0)?,r.get(1)?,r.get(2)?))).unwrap(); + let expired:(String,String,i64)=db.query_row("SELECT state,worker,claimed_at FROM builds WHERE id='expired'",[],|r|Ok((r.get(0)?,r.get(1)?,r.get(2)?))).unwrap(); + assert_eq!(queued,("queued".into(),"".into(),0)); + assert_eq!(expired,("running".into(),"other-worker".into(),0)); + } + let request=axum::http::Request::builder().method("POST").uri("/api/v1/worker/claim") + .header(header::AUTHORIZATION,format!("Bearer {credential}")).header(COMPANY_BUNDLE_RECIPE_HEADER,recipe) + .body(axum::body::Body::empty()).unwrap(); + let response=app.clone().oneshot(request).await.unwrap();assert_eq!(response.status(),StatusCode::OK); + { + let db=store.db.lock().unwrap(); + let claimed:i64=db.query_row("SELECT COUNT(*) FROM builds WHERE state='running' AND worker=?1",[digest(&credential)],|r|r.get(0)).unwrap(); + let recovered:i64=db.query_row("SELECT COUNT(*) FROM builds WHERE state='queued'",[],|r|r.get(0)).unwrap(); + assert_eq!(claimed,1);assert_eq!(recovered,1); + } + drop(app);drop(store);std::fs::remove_dir_all(directory).unwrap(); + } + #[tokio::test] async fn administrator_revocation_cancels_claims_and_hides_credentials() { let directory=std::env::temp_dir().join(format!("swan-worker-revocation-{}",random_token())); let store=Arc::new(Store::open(&directory).unwrap()); @@ -857,7 +913,8 @@ mod worker_completion_tests { // Restoring release approval cannot revive a cancelled worker claim. store.db.lock().unwrap().execute("UPDATE releases SET approved=1 WHERE id='release'",[]).unwrap(); assert!(persist_verified_artifact(&store,&in_flight,&worker,b"late replacement").is_err()); - assert!(claim_build(State(store.clone()),headers(&worker_token)).await.ok().unwrap().0.is_null()); + let mut matching=headers(&worker_token);matching.insert(COMPANY_BUNDLE_RECIPE_HEADER,company_bundle_recipe().unwrap().parse().unwrap()); + assert!(claim_build(State(store.clone()),matching).await.ok().unwrap().0.is_null()); drop(store);std::fs::remove_dir_all(directory).unwrap(); } #[test] diff --git a/product/protocol/src/lib.rs b/product/protocol/src/lib.rs index 06f38a0da5a..1bfc5548271 100644 --- a/product/protocol/src/lib.rs +++ b/product/protocol/src/lib.rs @@ -299,6 +299,22 @@ pub fn bundle_text(bytes:&[u8])->Result> { // Windows and Linux source checkouts must produce identical bundled scripts. Ok(std::str::from_utf8(bytes)?.replace("\r\n","\n").into_bytes()) } +pub const COMPANY_BUNDLE_RECIPE_HEADER:&str="x-swan-bundle-recipe"; +pub fn company_bundle_recipe()->Result { + bundle_recipe(&COMPANY_BUNDLE_TEXT_FILES) +} +fn bundle_recipe(files:&[(&str,&[u8])])->Result { + // Version the fixed ZIP layout, then frame names and normalized contents. + // This is a compatibility check, never a replacement for upload validation. + let mut hash=Sha256::new();hash.update(b"swan-company-bundle-v1\0"); + hash.update((files.len() as u64).to_be_bytes()); + for (name,bytes) in files { + let bytes=bundle_text(bytes)?; + hash.update((name.len() as u64).to_be_bytes());hash.update(name.as_bytes()); + hash.update((bytes.len() as u64).to_be_bytes());hash.update(bytes); + } + Ok(hex::encode(hash.finalize())) +} pub fn now() -> u64 { std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0) } pub fn maintenance_open(start: u8, end: u8, now: u64) -> bool { let hour = ((now / 3600) % 24) as u8; @@ -309,6 +325,16 @@ pub fn maintenance_open(start: u8, end: u8, now: u64) -> bool { mod tests { use super::*; #[test] + fn bundle_recipe_tracks_exact_names_and_normalized_contents() { + let original=bundle_recipe(&[("setup.ps1",b"one\r\ntwo\r\n")]).unwrap(); + assert_eq!(original,bundle_recipe(&[("setup.ps1",b"one\ntwo\n")]).unwrap()); + assert_ne!(original,bundle_recipe(&[("other.ps1",b"one\ntwo\n")]).unwrap()); + assert_ne!(original,bundle_recipe(&[("setup.ps1",b"changed\n")]).unwrap()); + assert_ne!(bundle_recipe(&[("ab",b"c")]).unwrap(),bundle_recipe(&[("a",b"bc")]).unwrap()); + assert_ne!(company_bundle_recipe().unwrap(),bundle_recipe(&COMPANY_BUNDLE_TEXT_FILES[1..]).unwrap()); + assert!(bundle_recipe(&[("invalid",&[0xff])]).is_err()); + } + #[test] fn company_contact_shortcuts_validate_and_old_profiles_keep_defaults() { let value=serde_json::to_value(Branding::default()).unwrap(); assert!(value.get("support_contacts").is_none());assert!(value.get("shortcuts").is_none()); diff --git a/product/worker/src/main.rs b/product/worker/src/main.rs index cc9d1c307ad..d35889611ac 100644 --- a/product/worker/src/main.rs +++ b/product/worker/src/main.rs @@ -32,14 +32,23 @@ async fn run_worker()->Result<()> { let output=PathBuf::from(std::env::var("SWAN_ARTIFACT_DIR").context("Set worker artifact output directory")?); tokio::fs::create_dir_all(&output).await?; let client=swan_agent::http_client(600,false)?; + let recipe=company_bundle_recipe()?; loop { - let response=client.post(format!("{}/api/v1/worker/claim",server.trim_end_matches('/'))).bearer_auth(&token).send().await; + // Older servers do not understand the claim header. Check their recipe + // endpoint first so upgrading only the worker cannot strand their jobs. + if let Err(error)=check_server_recipe(&client,&server,&token,&recipe).await { + eprintln!("Worker compatibility check failed: {error:#}. Install matching server and worker releases; no build was requested."); + tokio::time::sleep(std::time::Duration::from_secs(15)).await; + continue; + } + let response=client.post(format!("{}/api/v1/worker/claim",server.trim_end_matches('/'))).bearer_auth(&token).header(COMPANY_BUNDLE_RECIPE_HEADER,&recipe).send().await; match response { Ok(response) if response.status().is_success()=>{ if let Err(error)=process_claim(response,&client,&server,&token,&output,&release_key,&company_key).await { eprintln!("Worker job processing failed: {error:#}. Continuing polling; the server retains the job for recovery."); } } + Ok(response) if response.status()==reqwest::StatusCode::CONFLICT=>eprintln!("Company bundle recipe mismatch; install matching server and worker releases. No build was claimed."), Ok(response)=>eprintln!("Worker polling failed: {}",response.status()), Err(_)=>eprintln!("Worker management endpoint unavailable; retrying"), } @@ -47,6 +56,13 @@ async fn run_worker()->Result<()> { } } +async fn check_server_recipe(client:&reqwest::Client,server:&str,token:&str,recipe:&str)->Result<()> { + let response=client.get(format!("{}/api/v1/worker/recipe",server.trim_end_matches('/'))).bearer_auth(token).send().await?.error_for_status()?; + let value:Value=response.json().await?; + ensure!(value["schema"].as_u64()==Some(1) && value["bundle_recipe"].as_str()==Some(recipe),"Company bundle recipe mismatch"); + Ok(()) +} + async fn process_claim(response:reqwest::Response,client:&reqwest::Client,server:&str,token:&str,output:&Path,release_key:&str,company_key:&str)->Result<()> { let value:Value=response.json().await?; if !value.is_null(){ @@ -157,6 +173,36 @@ fn verify_windows(_path:&Path,_release:&Release,_directory:&Path,_check_msi:bool mod verification_tests { use super::*; + #[tokio::test] + async fn server_recipe_preflight_rejects_old_or_incompatible_servers() { + use tokio::io::{AsyncReadExt,AsyncWriteExt}; + let recipe=company_bundle_recipe().unwrap(); + let client=reqwest::Client::builder().no_proxy().timeout(std::time::Duration::from_secs(3)).build().unwrap(); + for (status,body,accepted) in [ + ("200 OK",json!({"schema":1,"bundle_recipe":recipe}).to_string(),true), + ("200 OK",json!({"schema":1,"bundle_recipe":"old"}).to_string(),false), + ("200 OK",json!({"schema":2,"bundle_recipe":recipe}).to_string(),false), + ("200 OK","null".into(),false), + ("404 Not Found","{}".into(),false), + ("401 Unauthorized","{}".into(),false), + ] { + let listener=tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();let address=listener.local_addr().unwrap(); + let server=tokio::spawn(async move { + let (mut stream,_)=listener.accept().await.unwrap();let mut request=Vec::new(); + while !request.ends_with(b"\r\n\r\n") { + let byte=stream.read_u8().await.unwrap();request.push(byte);assert!(request.len()<4096); + } + let request=String::from_utf8(request).unwrap(); + assert!(request.starts_with("GET /api/v1/worker/recipe HTTP/1.1\r\n")); + assert!(request.to_ascii_lowercase().contains("authorization: bearer fixture-worker\r\n")); + let response=format!("HTTP/1.1 {status}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",body.len()); + stream.write_all(response.as_bytes()).await.unwrap(); + }); + assert_eq!(check_server_recipe(&client,&format!("http://{address}"),"fixture-worker",&recipe).await.is_ok(),accepted); + tokio::time::timeout(std::time::Duration::from_secs(5),server).await.unwrap().unwrap(); + } + } + #[test] fn both_artifacts_require_trust_but_only_msi_installer_requires_msi_identity() { let agent=Path::new("swan-agent.exe"); From 9b49023c837a36c058731fe89202df66bdec7e30 Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Wed, 21 Oct 2026 19:39:44 +0200 Subject: [PATCH 276/652] docs: record verified worker compatibility regression results --- docs/ACCEPTANCE.md | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index 0c0e19d3398..b253571e4ce 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -2974,4 +2974,10 @@ handshake before claiming jobs: the worker checks an authenticated recipe endpoi and the server checks the matching versioned digest on every claim before queue mutation. Old or different recipes are rejected explicitly. Source regression checks cover preserved queued/expired claims on mismatch and preflight responses -from old/incompatible servers; CI and installed worker verification are pending. +from old/incompatible servers. Push workflow `39644529161` and PR workflow +`39644541313` passed for revision +`481439d62af5189706a1b83263af53d603f7de19`. Windows and Linux logs confirm the +recipe normalization/content regression, management claim-compatibility regression +and real loopback HTTP worker preflight regression executed and passed. Linux +deployment checks also passed. Independently downloaded executable and installed +worker verification of this change remain pending. From e175998048b22e5bbe9a9ab63c734c21a31c4aa4 Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Wed, 21 Oct 2026 19:47:21 +0200 Subject: [PATCH 277/652] docs: record real expiry checks and signed lab candidate provenance --- docs/ACCEPTANCE.md | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index b253571e4ce..855a87de62c 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -2981,3 +2981,36 @@ recipe normalization/content regression, management claim-compatibility regressi and real loopback HTTP worker preflight regression executed and passed. Linux deployment checks also passed. Independently downloaded executable and installed worker verification of this change remain pending. + +### Verified compatibility runtime and next signed lab candidate, 21 October + +Windows component artifact `12366189203` from workflow `39644529161` passed +independent archive verification against SHA-256 +`155e077843dedf212be655d122c57d00bbea034d100af4c4b064f6418f1b2100`, all five +payload entries and all 114 corresponding source files at revision `481439d`. +Its actual management executable passed 80 isolated loopback HTTP assertions, +including a recipe digest independently recomputed from its verified source and +rejection of missing/wrong claim digests. A second fresh-company run passed 85 +assertions, adding naturally elapsed grant expiry and authorization-lease expiry: +an unclaimed ticket was rejected after its 60-second lifetime, and an existing +lease could not be renewed after more than 300 seconds. No host clock changes, +mock timers or database expiry edits were used. Both test processes stopped. +These remain management API results; active native desktop disconnection on +lease expiry still requires separate evidence. + +Native artifact `12365255815` finished downloading and matched its GitHub digest. +Independent validation compared 1,111 tracked source files and six generated +bridge inputs with pinned public Git/CI inputs. All 95 extracted customer payload +files matched the published installed-file manifest. The technician MSI and both +payload hashes also matched the separately verified successful Server 2022 CI +install/repair/uninstall evidence. This does not prove enrolled native sessions. + +A private lab candidate combines native source `c0bf4b4` with component source +`481439d`; the separate origins are retained in its input/signing records. The +native applications and three component executables received verified timestamped +signatures from the existing nonexportable disposable-VM publisher. New customer +and technician MSI version `1.5.8` packages were built with the pinned WiX recipe +and separately signed/verified using that same private lab publisher. No private +key was exported, host trust imported, installed service replaced or production +publication performed. These packages still require installation, enrollment, +upgrade and native-session verification. From 075a72a38e837ac5dd7f28196fc558b92de691ff Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Wed, 21 Oct 2026 19:56:34 +0200 Subject: [PATCH 278/652] fix: retire customer uninstall rollback records only at MSI commit --- .dockerignore | 1 + .github/workflows/company-product.yml | 7 +++ deployment/linux/Dockerfile.dockerignore | 1 + .../windows/Commit-CustomerUninstall.ps1 | 29 +++++++++++ deployment/windows/UPDATE-RECOVERY.md | 10 +++- deployment/windows/generate-customer-msi.py | 5 ++ deployment/windows/test_customer_msi.py | 11 +++++ .../test_customer_uninstall_commit.ps1 | 48 +++++++++++++++++++ docs/ACCEPTANCE.md | 23 +++++++++ product/agent/src/main.rs | 7 +++ product/agent/src/update.rs | 7 +++ 11 files changed, 148 insertions(+), 1 deletion(-) create mode 100644 deployment/windows/Commit-CustomerUninstall.ps1 create mode 100644 deployment/windows/test_customer_uninstall_commit.ps1 diff --git a/.dockerignore b/.dockerignore index 64c9f2d4bd1..3b57dd9a1be 100644 --- a/.dockerignore +++ b/.dockerignore @@ -71,3 +71,4 @@ deployment/windows/** !deployment/windows/Remove-Configuration.ps1 !deployment/windows/Update-RecoveryTask.ps1 !deployment/windows/Update-UninstallCancellation.ps1 +!deployment/windows/Commit-CustomerUninstall.ps1 diff --git a/.github/workflows/company-product.yml b/.github/workflows/company-product.yml index 7a268eaa643..59f86df484e 100644 --- a/.github/workflows/company-product.yml +++ b/.github/workflows/company-product.yml @@ -36,6 +36,7 @@ jobs: 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 mkdir -p component-artifacts python deployment/windows/archive-build-source.py . component-artifacts/CompanyComponents-source.zip --components-only - name: Test security and management @@ -55,6 +56,12 @@ jobs: 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 customer uninstall commit and rollback preservation + if: runner.os == 'Windows' + shell: pwsh + run: | + powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -File ./deployment/windows/test_customer_uninstall_commit.ps1 + if ($LASTEXITCODE -ne 0) { throw 'Customer uninstall commit checks failed.' } - name: Verify company setup entry point and trust rejection if: runner.os == 'Windows' shell: pwsh diff --git a/deployment/linux/Dockerfile.dockerignore b/deployment/linux/Dockerfile.dockerignore index 64c9f2d4bd1..3b57dd9a1be 100644 --- a/deployment/linux/Dockerfile.dockerignore +++ b/deployment/linux/Dockerfile.dockerignore @@ -71,3 +71,4 @@ deployment/windows/** !deployment/windows/Remove-Configuration.ps1 !deployment/windows/Update-RecoveryTask.ps1 !deployment/windows/Update-UninstallCancellation.ps1 +!deployment/windows/Commit-CustomerUninstall.ps1 diff --git a/deployment/windows/Commit-CustomerUninstall.ps1 b/deployment/windows/Commit-CustomerUninstall.ps1 new file mode 100644 index 00000000000..c39fc553daf --- /dev/null +++ b/deployment/windows/Commit-CustomerUninstall.ps1 @@ -0,0 +1,29 @@ +[CmdletBinding()] +param([Parameter(Mandatory=$true)][string]$Directory) +$ErrorActionPreference='Stop' +function Complete-CustomerUninstall { + param([Parameter(Mandatory=$true)][string]$Directory) + $folder=Get-Item -LiteralPath $Directory + if (-not $folder.PSIsContainer -or ($folder.Attributes -band [IO.FileAttributes]::ReparsePoint)) { throw 'Unsafe uninstall state directory.' } + if (Test-Path -LiteralPath (Join-Path $Directory 'swan-agent.exe')) { throw 'Uninstall commit requires the installed agent to be removed.' } + $marker=Get-Item -LiteralPath (Join-Path $Directory 'pending-uninstall') + if ($marker.PSIsContainer -or ($marker.Attributes -band [IO.FileAttributes]::ReparsePoint)) { throw 'Invalid uninstall cancellation marker.' } + $records=@() + foreach ($name in @('uninstall-cancellation-rollback.json','configuration-task-rollback.json')) { + $path=Join-Path $Directory $name + if (-not (Test-Path -LiteralPath $path)) { continue } + $file=Get-Item -LiteralPath $path + if ($file.PSIsContainer -or ($file.Attributes -band [IO.FileAttributes]::ReparsePoint) -or $file.Length -gt 4096) { throw 'Invalid uninstall rollback record.' } + $record=Get-Content -LiteralPath $path -Raw|ConvertFrom-Json + if ($name -eq 'uninstall-cancellation-rollback.json') { + if ($record.marker_existed -isnot [bool]) { throw 'Invalid cancellation rollback state.' } + } elseif ($record.enabled -isnot [bool] -or $record.running -isnot [bool]) { throw 'Invalid task rollback state.' } + $records+=$path + } + # All validation precedes deletion. Keep the cancellation marker so staged + # update recovery cannot reinstall software the customer just uninstalled. + foreach ($path in $records) { Remove-Item -LiteralPath $path } +} +$expected=Join-Path ([Environment]::GetFolderPath('CommonApplicationData')) 'SwanRemoteSupport' +if ([IO.Path]::GetFullPath($Directory).TrimEnd('\') -ine [IO.Path]::GetFullPath($expected).TrimEnd('\')) { throw 'Uninstall commit requires the canonical customer state directory.' } +Complete-CustomerUninstall -Directory $Directory diff --git a/deployment/windows/UPDATE-RECOVERY.md b/deployment/windows/UPDATE-RECOVERY.md index 46d37838e8b..d8a9802c41f 100644 --- a/deployment/windows/UPDATE-RECOVERY.md +++ b/deployment/windows/UPDATE-RECOVERY.md @@ -38,7 +38,15 @@ watcher before retiring an earlier restart marker. Explicit MSI uninstall writes `pending-uninstall` before file removal, blocking new sessions and automatic recovery. Paired uninstall rollback restores the -previous cancellation state, including an earlier cancellation. Software +previous cancellation state, including an earlier cancellation. Uninstall commit +clears only the now-obsolete cancellation/task rollback records +after MSI has removed the installed agent. A separate agent copy embedded in the +signed MSI runs this commit action, so it does not depend on the deleted installed +executable. It retains `pending-uninstall`, identity and consent. Failed removal +retains rollback information until Windows Installer invokes rollback. This avoids +a successful uninstall leaving a record that blocks the next removal after a bare +MSI reinstall. Real repeated install/uninstall verification of this fix is pending. +Software rollback narrowly uninstalls its verified failed MSI with `SWAN_RECOVERY=1`, excluding user-uninstall cancellation actions. Only explicit verified setup or repair clears the cancellation marker. Explicit setup now verifies its incoming diff --git a/deployment/windows/generate-customer-msi.py b/deployment/windows/generate-customer-msi.py index f0e5b395b36..7de28db000e 100644 --- a/deployment/windows/generate-customer-msi.py +++ b/deployment/windows/generate-customer-msi.py @@ -92,11 +92,16 @@ def author(source, agent, version): ExeCommand="remove-configuration-task", Execute="deferred", Impersonate="no", Return="check") element(package, "CustomAction", Id="RestoreCompanyConfigurationTask", FileRef="ConfigurationAgentExe", ExeCommand="restore-configuration-task", Execute="rollback", Impersonate="no", Return="check") + element(package, "Binary", Id="UninstallCommitAgent", SourceFile=str(agent)) + element(package, "CustomAction", Id="CommitCompanyUninstall", BinaryRef="UninstallCommitAgent", + ExeCommand='commit-customer-uninstall "[STATEFOLDER]."', Execute="commit", Impersonate="no", Return="check") sequence = element(package, "InstallExecuteSequence") element(sequence, "Custom", Action="RestoreCompanyConfigurationTask", Before="RemoveCompanyConfigurationTask", Condition='REMOVE = "ALL" AND NOT UPGRADINGPRODUCTCODE AND NOT SWAN_RECOVERY') element(sequence, "Custom", Action="RemoveCompanyConfigurationTask", Before="RemoveFiles", Condition='REMOVE = "ALL" AND NOT UPGRADINGPRODUCTCODE AND NOT SWAN_RECOVERY') + element(sequence, "Custom", Action="CommitCompanyUninstall", After="RemoveCompanyConfigurationTask", + Condition='REMOVE = "ALL" AND NOT UPGRADINGPRODUCTCODE AND NOT SWAN_RECOVERY') return ET.ElementTree(root), files diff --git a/deployment/windows/test_customer_msi.py b/deployment/windows/test_customer_msi.py index e8168e5cd0c..8f5b3488110 100644 --- a/deployment/windows/test_customer_msi.py +++ b/deployment/windows/test_customer_msi.py @@ -54,6 +54,17 @@ def test_full_manifest_license_and_service_lifecycle(self): self.assertEqual(rollback.attrib["Execute"], "rollback") rollback_scheduling = tree.find(".//w:InstallExecuteSequence/w:Custom[@Action='RestoreCompanyConfigurationTask']", ns) self.assertEqual(rollback_scheduling.attrib["Before"], "RemoveCompanyConfigurationTask") + embedded = tree.find(".//w:Binary[@Id='UninstallCommitAgent']", ns) + self.assertEqual(embedded.attrib["SourceFile"], str(agent.resolve())) + commit = tree.find(".//w:CustomAction[@Id='CommitCompanyUninstall']", ns) + self.assertEqual(commit.attrib["BinaryRef"], "UninstallCommitAgent") + self.assertEqual(commit.attrib["Execute"], "commit") + self.assertEqual(commit.attrib["Impersonate"], "no") + self.assertEqual(commit.attrib["Return"], "check") + self.assertEqual(commit.attrib["ExeCommand"], 'commit-customer-uninstall "[STATEFOLDER]."') + commit_scheduling = tree.find(".//w:InstallExecuteSequence/w:Custom[@Action='CommitCompanyUninstall']", ns) + self.assertEqual(commit_scheduling.attrib["After"], "RemoveCompanyConfigurationTask") + self.assertEqual(commit_scheduling.attrib["Condition"], scheduling.attrib["Condition"]) installed_files = tree.findall(".//w:File", ns) self.assertEqual(len(installed_files), len(files)+1) diff --git a/deployment/windows/test_customer_uninstall_commit.ps1 b/deployment/windows/test_customer_uninstall_commit.ps1 new file mode 100644 index 00000000000..8b499737e4a --- /dev/null +++ b/deployment/windows/test_customer_uninstall_commit.ps1 @@ -0,0 +1,48 @@ +$ErrorActionPreference='Stop' +$script=Join-Path $PSScriptRoot 'Commit-CustomerUninstall.ps1' +$tokens=$null;$errors=$null +$ast=[System.Management.Automation.Language.Parser]::ParseFile($script,[ref]$tokens,[ref]$errors) +if ($errors.Count) { throw 'Uninstall commit script failed parsing.' } +$function=$ast.Find({param($node) $node -is [System.Management.Automation.Language.FunctionDefinitionAst] -and $node.Name -eq 'Complete-CustomerUninstall'},$true) +if ($null -eq $function) { throw 'Commit cleanup function missing.' } +. ([ScriptBlock]::Create($function.Extent.Text)) +$root=Join-Path ([IO.Path]::GetTempPath()) ('swan-uninstall-commit-'+[Guid]::NewGuid().ToString('N')) +New-Item -ItemType Directory -Path $root|Out-Null +$marker=Join-Path $root 'pending-uninstall';$cancellation=Join-Path $root 'uninstall-cancellation-rollback.json' +$task=Join-Path $root 'configuration-task-rollback.json';$state=Join-Path $root 'managed-state.json';$agent=Join-Path $root 'swan-agent.exe' +try { + [IO.File]::WriteAllText($state,'identity and consent fixture: preserve exactly') + & (Join-Path $PSScriptRoot 'Update-UninstallCancellation.ps1') -Directory $root -Operation cancel + $rejected=$false + try { & (Join-Path $PSScriptRoot 'Update-UninstallCancellation.ps1') -Directory $root -Operation cancel } catch { $rejected=$true } + if (-not $rejected) { throw 'Active cancellation rollback was overwritten.' } + [IO.File]::WriteAllText($task,'{"enabled":true,"running":false}') + [IO.File]::WriteAllText($agent,'inert installed agent fixture') + $rejected=$false + try { Complete-CustomerUninstall -Directory $root } catch { $rejected=$true } + if (-not $rejected -or -not (Test-Path $cancellation)) { throw 'Commit ran before agent removal.' } + Remove-Item -LiteralPath $agent + # The real entrypoint must reject arbitrary directories even though the + # pure cleanup function is exercised here against private temporary files. + $rejected=$false + try { & $script -Directory $root } catch { $rejected=$true } + if (-not $rejected -or -not (Test-Path $cancellation)) { throw 'Canonical state-directory guard failed.' } + [IO.File]::WriteAllText($task,'{"enabled":"invalid","running":false}') + $rejected=$false + try { Complete-CustomerUninstall -Directory $root } catch { $rejected=$true } + if (-not $rejected -or -not (Test-Path $cancellation) -or -not (Test-Path $marker)) { throw 'Invalid task record partially cleared cancellation.' } + [IO.File]::WriteAllText($task,'{"enabled":true,"running":false}') + Complete-CustomerUninstall -Directory $root + Complete-CustomerUninstall -Directory $root + if ((Test-Path $cancellation) -or (Test-Path $task) -or -not (Test-Path $marker)) { throw 'Commit did not preserve cancellation while clearing rollback records.' } + # A later removal can now create its own rollback; rollback must retain + # the earlier completed uninstall's cancellation rather than enabling support. + & (Join-Path $PSScriptRoot 'Update-UninstallCancellation.ps1') -Directory $root -Operation cancel + & (Join-Path $PSScriptRoot 'Update-UninstallCancellation.ps1') -Directory $root -Operation restore + if (-not (Test-Path $marker)) { throw 'Later uninstall rollback lost earlier cancellation.' } + if ([IO.File]::ReadAllText($state) -cne 'identity and consent fixture: preserve exactly') { throw 'Commit changed identity/consent state.' } +} finally { + foreach ($path in @($agent,$marker,$cancellation,$task,$state)) { if (Test-Path -LiteralPath $path) { Remove-Item -LiteralPath $path } } + Remove-Item -LiteralPath $root +} +Write-Output 'Customer uninstall commit regression checks passed; no installed application or task changed.' diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index 855a87de62c..7b8a229afad 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -3014,3 +3014,26 @@ and separately signed/verified using that same private lab publisher. No private key was exported, host trust imported, installed service replaced or production publication performed. These packages still require installation, enrollment, upgrade and native-session verification. + +### New candidate uninstall regression, 21 October + +The signed composite `1.5.8` candidate was exercised on the same Server 2019 +guest after the prior `1.5.7` lifecycle. Customer installation and deliberate +license-damage repair passed. Customer uninstall failed with MSI 1603 / custom +action 1722 at `RemoveCompanyConfigurationTask`; technician cases were not +reached. The full failing report and raw log were preserved. This is a failed +acceptance gate, not a six-case success. Windows Installer rollback retained +the installed customer executable/agent and running service. + +The existing cancellation helper rejects a retained uninstall rollback record. +Successful prior bare MSI uninstall left that record until explicit verified +company setup, making a later bare reinstall/removal encounter stale state. +The fix adds a commit-only cleanup action using an embedded copy of the release +agent, since its installed copy has already been deleted at commit. Cleanup is +restricted to the canonical customer directory, validates both rollback records +before removal, rejects reparse points and an agent still present, and preserves +the uninstall cancellation marker, identity and consent. Rollback data remains +available until transaction commit. Local Windows PowerShell 5 regression checks +and all three MSI-authoring tests pass; CI, rebuilt signed packages and actual +repeated uninstall/rollback verification remain pending. The failed candidate +has not been published or approved for production. diff --git a/product/agent/src/main.rs b/product/agent/src/main.rs index cde8eeb0d09..92c62a097dc 100644 --- a/product/agent/src/main.rs +++ b/product/agent/src/main.rs @@ -21,6 +21,13 @@ async fn main()->Result<()> { match command { "rollback-protocol"=>println!("1"), #[cfg(windows)] + "commit-customer-uninstall"=>{ + // The signed MSI embeds a separate agent copy: its installed copy + // has already been removed when the commit script executes. + let directory=PathBuf::from(args.get(2).context("Missing customer uninstall state directory")?); + swan_agent::update::commit_customer_uninstall(&directory)?; + } + #[cfg(windows)] "cancel-update-recovery"|"restore-update-recovery"=>{ let executable=std::env::current_exe()?; ensure!(executable.file_name().is_some_and(|name|name.to_string_lossy().eq_ignore_ascii_case("swan-agent.exe")),"Uninstall cancellation requires the installed agent"); diff --git a/product/agent/src/update.rs b/product/agent/src/update.rs index 3e63d46a3c2..15ea8a0dff9 100644 --- a/product/agent/src/update.rs +++ b/product/agent/src/update.rs @@ -284,6 +284,13 @@ fn verify_publisher(path:&Path,release:&Release)->Result<()> { ensure!(status.success(),"Package publisher verification failed");Ok(()) } +#[cfg(windows)] +pub fn commit_customer_uninstall(directory:&Path)->Result<()> { + let status=embedded_powershell_command(include_str!("../../../deployment/windows/Commit-CustomerUninstall.ps1"), + &[("Directory",directory.to_str().context("Uninstall state path is not valid Unicode")?)])?.status()?; + ensure!(status.success(),"Customer uninstall commit cleanup failed");Ok(()) +} + #[cfg(windows)] fn embedded_powershell_command(script:&str,arguments:&[(&str,&str)])->Result { let literal=|value:&str|format!("'{}'",value.replace('\'',"''")); From 053a52e3ac29cb0f7e9686da9c626f94bf153005 Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Wed, 21 Oct 2026 20:03:51 +0200 Subject: [PATCH 279/652] fix: commit technician uninstall cleanup in the user context --- .dockerignore | 2 +- .github/workflows/company-product.yml | 6 ++--- deployment/linux/Dockerfile.dockerignore | 2 +- ...omerUninstall.ps1 => Commit-Uninstall.ps1} | 10 ++++----- deployment/windows/UPDATE-RECOVERY.md | 4 ++++ deployment/windows/generate-customer-msi.py | 2 +- deployment/windows/msi/Technician.wxs | 3 +++ deployment/windows/test_customer_msi.py | 16 +++++++++++++- ...l_commit.ps1 => test_uninstall_commit.ps1} | 22 ++++++++++--------- docs/ACCEPTANCE.md | 12 ++++++++++ product/agent/src/main.rs | 8 ++++--- product/agent/src/update.rs | 9 ++++---- 12 files changed, 67 insertions(+), 29 deletions(-) rename deployment/windows/{Commit-CustomerUninstall.ps1 => Commit-Uninstall.ps1} (79%) rename deployment/windows/{test_customer_uninstall_commit.ps1 => test_uninstall_commit.ps1} (80%) diff --git a/.dockerignore b/.dockerignore index 3b57dd9a1be..4b5dccb1058 100644 --- a/.dockerignore +++ b/.dockerignore @@ -71,4 +71,4 @@ deployment/windows/** !deployment/windows/Remove-Configuration.ps1 !deployment/windows/Update-RecoveryTask.ps1 !deployment/windows/Update-UninstallCancellation.ps1 -!deployment/windows/Commit-CustomerUninstall.ps1 +!deployment/windows/Commit-Uninstall.ps1 diff --git a/.github/workflows/company-product.yml b/.github/workflows/company-product.yml index 59f86df484e..a75059dd871 100644 --- a/.github/workflows/company-product.yml +++ b/.github/workflows/company-product.yml @@ -56,12 +56,12 @@ jobs: 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 customer uninstall commit and rollback preservation + - name: Verify uninstall commit and rollback preservation if: runner.os == 'Windows' shell: pwsh run: | - powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -File ./deployment/windows/test_customer_uninstall_commit.ps1 - if ($LASTEXITCODE -ne 0) { throw 'Customer uninstall commit checks failed.' } + powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -File ./deployment/windows/test_uninstall_commit.ps1 + if ($LASTEXITCODE -ne 0) { throw 'Uninstall commit checks failed.' } - name: Verify company setup entry point and trust rejection if: runner.os == 'Windows' shell: pwsh diff --git a/deployment/linux/Dockerfile.dockerignore b/deployment/linux/Dockerfile.dockerignore index 3b57dd9a1be..4b5dccb1058 100644 --- a/deployment/linux/Dockerfile.dockerignore +++ b/deployment/linux/Dockerfile.dockerignore @@ -71,4 +71,4 @@ deployment/windows/** !deployment/windows/Remove-Configuration.ps1 !deployment/windows/Update-RecoveryTask.ps1 !deployment/windows/Update-UninstallCancellation.ps1 -!deployment/windows/Commit-CustomerUninstall.ps1 +!deployment/windows/Commit-Uninstall.ps1 diff --git a/deployment/windows/Commit-CustomerUninstall.ps1 b/deployment/windows/Commit-Uninstall.ps1 similarity index 79% rename from deployment/windows/Commit-CustomerUninstall.ps1 rename to deployment/windows/Commit-Uninstall.ps1 index c39fc553daf..e3acfa96aa0 100644 --- a/deployment/windows/Commit-CustomerUninstall.ps1 +++ b/deployment/windows/Commit-Uninstall.ps1 @@ -1,7 +1,7 @@ [CmdletBinding()] -param([Parameter(Mandatory=$true)][string]$Directory) +param([Parameter(Mandatory=$true)][string]$Directory,[Parameter(Mandatory=$true)][ValidateSet('customer','technician')][string]$Edition) $ErrorActionPreference='Stop' -function Complete-CustomerUninstall { +function Complete-Uninstall { param([Parameter(Mandatory=$true)][string]$Directory) $folder=Get-Item -LiteralPath $Directory if (-not $folder.PSIsContainer -or ($folder.Attributes -band [IO.FileAttributes]::ReparsePoint)) { throw 'Unsafe uninstall state directory.' } @@ -24,6 +24,6 @@ function Complete-CustomerUninstall { # update recovery cannot reinstall software the customer just uninstalled. foreach ($path in $records) { Remove-Item -LiteralPath $path } } -$expected=Join-Path ([Environment]::GetFolderPath('CommonApplicationData')) 'SwanRemoteSupport' -if ([IO.Path]::GetFullPath($Directory).TrimEnd('\') -ine [IO.Path]::GetFullPath($expected).TrimEnd('\')) { throw 'Uninstall commit requires the canonical customer state directory.' } -Complete-CustomerUninstall -Directory $Directory +$expected=if($Edition -eq 'customer'){Join-Path ([Environment]::GetFolderPath('CommonApplicationData')) 'SwanRemoteSupport'}else{Join-Path ([Environment]::GetFolderPath('LocalApplicationData')) 'SwanRemoteSupport-Technician'} +if ([IO.Path]::GetFullPath($Directory).TrimEnd('\') -ine [IO.Path]::GetFullPath($expected).TrimEnd('\')) { throw 'Uninstall commit requires the canonical edition state directory.' } +Complete-Uninstall -Directory $Directory diff --git a/deployment/windows/UPDATE-RECOVERY.md b/deployment/windows/UPDATE-RECOVERY.md index d8a9802c41f..c1a4e8f6374 100644 --- a/deployment/windows/UPDATE-RECOVERY.md +++ b/deployment/windows/UPDATE-RECOVERY.md @@ -46,6 +46,10 @@ executable. It retains `pending-uninstall`, identity and consent. Failed removal retains rollback information until Windows Installer invokes rollback. This avoids a successful uninstall leaving a record that blocks the next removal after a bare MSI reinstall. Real repeated install/uninstall verification of this fix is pending. +The technician MSI uses the same commit cleanup in the installing user's context +and restricts its state path to that user's local application data. Customer +cleanup runs under SYSTEM and restricts its path to the machine-wide customer +directory. Neither edition deletes the cancellation marker or company identity. Software rollback narrowly uninstalls its verified failed MSI with `SWAN_RECOVERY=1`, excluding user-uninstall cancellation actions. Only explicit verified setup or diff --git a/deployment/windows/generate-customer-msi.py b/deployment/windows/generate-customer-msi.py index 7de28db000e..b1dc850b042 100644 --- a/deployment/windows/generate-customer-msi.py +++ b/deployment/windows/generate-customer-msi.py @@ -94,7 +94,7 @@ def author(source, agent, version): ExeCommand="restore-configuration-task", Execute="rollback", Impersonate="no", Return="check") element(package, "Binary", Id="UninstallCommitAgent", SourceFile=str(agent)) element(package, "CustomAction", Id="CommitCompanyUninstall", BinaryRef="UninstallCommitAgent", - ExeCommand='commit-customer-uninstall "[STATEFOLDER]."', Execute="commit", Impersonate="no", Return="check") + ExeCommand='commit-uninstall customer "[STATEFOLDER]."', Execute="commit", Impersonate="no", Return="check") sequence = element(package, "InstallExecuteSequence") element(sequence, "Custom", Action="RestoreCompanyConfigurationTask", Before="RemoveCompanyConfigurationTask", Condition='REMOVE = "ALL" AND NOT UPGRADINGPRODUCTCODE AND NOT SWAN_RECOVERY') diff --git a/deployment/windows/msi/Technician.wxs b/deployment/windows/msi/Technician.wxs index 864d91805e5..d8f6955503e 100644 --- a/deployment/windows/msi/Technician.wxs +++ b/deployment/windows/msi/Technician.wxs @@ -4,9 +4,12 @@ + + + diff --git a/deployment/windows/test_customer_msi.py b/deployment/windows/test_customer_msi.py index 8f5b3488110..33640667580 100644 --- a/deployment/windows/test_customer_msi.py +++ b/deployment/windows/test_customer_msi.py @@ -61,13 +61,27 @@ def test_full_manifest_license_and_service_lifecycle(self): self.assertEqual(commit.attrib["Execute"], "commit") self.assertEqual(commit.attrib["Impersonate"], "no") self.assertEqual(commit.attrib["Return"], "check") - self.assertEqual(commit.attrib["ExeCommand"], 'commit-customer-uninstall "[STATEFOLDER]."') + self.assertEqual(commit.attrib["ExeCommand"], 'commit-uninstall customer "[STATEFOLDER]."') commit_scheduling = tree.find(".//w:InstallExecuteSequence/w:Custom[@Action='CommitCompanyUninstall']", ns) self.assertEqual(commit_scheduling.attrib["After"], "RemoveCompanyConfigurationTask") self.assertEqual(commit_scheduling.attrib["Condition"], scheduling.attrib["Condition"]) installed_files = tree.findall(".//w:File", ns) self.assertEqual(len(installed_files), len(files)+1) + def test_technician_uninstall_commit_retains_user_context(self): + import xml.etree.ElementTree as ET + tree = ET.parse(Path(__file__).with_name("msi") / "Technician.wxs") + ns = {"w": msi.NAMESPACE} + action = tree.find(".//w:CustomAction[@Id='CommitTechnicianUninstall']", ns) + self.assertEqual(action.attrib["BinaryRef"], "UninstallCommitAgent") + self.assertEqual(action.attrib["ExeCommand"], 'commit-uninstall technician "[INSTALLFOLDER]."') + self.assertEqual(action.attrib["Execute"], "commit") + self.assertEqual(action.attrib["Impersonate"], "yes") + self.assertEqual(action.attrib["Return"], "check") + sequence = tree.find(".//w:InstallExecuteSequence/w:Custom[@Action='CommitTechnicianUninstall']", ns) + self.assertEqual(sequence.attrib["After"], "CancelUpdateRecovery") + self.assertEqual(sequence.attrib["Condition"], 'REMOVE = "ALL" AND NOT UPGRADINGPRODUCTCODE AND NOT SWAN_RECOVERY') + def test_component_identity_survives_version_and_payload_changes(self): with tempfile.TemporaryDirectory() as folder: source, agent = self.fixture(Path(folder)) diff --git a/deployment/windows/test_customer_uninstall_commit.ps1 b/deployment/windows/test_uninstall_commit.ps1 similarity index 80% rename from deployment/windows/test_customer_uninstall_commit.ps1 rename to deployment/windows/test_uninstall_commit.ps1 index 8b499737e4a..69cdbe1cbdc 100644 --- a/deployment/windows/test_customer_uninstall_commit.ps1 +++ b/deployment/windows/test_uninstall_commit.ps1 @@ -1,9 +1,9 @@ $ErrorActionPreference='Stop' -$script=Join-Path $PSScriptRoot 'Commit-CustomerUninstall.ps1' +$script=Join-Path $PSScriptRoot 'Commit-Uninstall.ps1' $tokens=$null;$errors=$null $ast=[System.Management.Automation.Language.Parser]::ParseFile($script,[ref]$tokens,[ref]$errors) if ($errors.Count) { throw 'Uninstall commit script failed parsing.' } -$function=$ast.Find({param($node) $node -is [System.Management.Automation.Language.FunctionDefinitionAst] -and $node.Name -eq 'Complete-CustomerUninstall'},$true) +$function=$ast.Find({param($node) $node -is [System.Management.Automation.Language.FunctionDefinitionAst] -and $node.Name -eq 'Complete-Uninstall'},$true) if ($null -eq $function) { throw 'Commit cleanup function missing.' } . ([ScriptBlock]::Create($function.Extent.Text)) $root=Join-Path ([IO.Path]::GetTempPath()) ('swan-uninstall-commit-'+[Guid]::NewGuid().ToString('N')) @@ -19,21 +19,23 @@ try { [IO.File]::WriteAllText($task,'{"enabled":true,"running":false}') [IO.File]::WriteAllText($agent,'inert installed agent fixture') $rejected=$false - try { Complete-CustomerUninstall -Directory $root } catch { $rejected=$true } + try { Complete-Uninstall -Directory $root } catch { $rejected=$true } if (-not $rejected -or -not (Test-Path $cancellation)) { throw 'Commit ran before agent removal.' } Remove-Item -LiteralPath $agent # The real entrypoint must reject arbitrary directories even though the # pure cleanup function is exercised here against private temporary files. - $rejected=$false - try { & $script -Directory $root } catch { $rejected=$true } - if (-not $rejected -or -not (Test-Path $cancellation)) { throw 'Canonical state-directory guard failed.' } + foreach($edition in @('customer','technician')) { + $rejected=$false + try { & $script -Directory $root -Edition $edition } catch { $rejected=$true } + if (-not $rejected -or -not (Test-Path $cancellation)) { throw 'Canonical edition state-directory guard failed.' } + } [IO.File]::WriteAllText($task,'{"enabled":"invalid","running":false}') $rejected=$false - try { Complete-CustomerUninstall -Directory $root } catch { $rejected=$true } + try { Complete-Uninstall -Directory $root } catch { $rejected=$true } if (-not $rejected -or -not (Test-Path $cancellation) -or -not (Test-Path $marker)) { throw 'Invalid task record partially cleared cancellation.' } [IO.File]::WriteAllText($task,'{"enabled":true,"running":false}') - Complete-CustomerUninstall -Directory $root - Complete-CustomerUninstall -Directory $root + Complete-Uninstall -Directory $root + Complete-Uninstall -Directory $root if ((Test-Path $cancellation) -or (Test-Path $task) -or -not (Test-Path $marker)) { throw 'Commit did not preserve cancellation while clearing rollback records.' } # A later removal can now create its own rollback; rollback must retain # the earlier completed uninstall's cancellation rather than enabling support. @@ -45,4 +47,4 @@ try { foreach ($path in @($agent,$marker,$cancellation,$task,$state)) { if (Test-Path -LiteralPath $path) { Remove-Item -LiteralPath $path } } Remove-Item -LiteralPath $root } -Write-Output 'Customer uninstall commit regression checks passed; no installed application or task changed.' +Write-Output 'Uninstall commit regression checks passed for both editions; no installed application or task changed.' diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index 7b8a229afad..bbda8f7613e 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -3037,3 +3037,15 @@ available until transaction commit. Local Windows PowerShell 5 regression checks and all three MSI-authoring tests pass; CI, rebuilt signed packages and actual repeated uninstall/rollback verification remain pending. The failed candidate has not been published or approved for production. + +An independent technician-only `1.5.8` lifecycle attempt was subsequently stopped +by the fresh-install guard before installation: the earlier successfully +uninstalled technician product had left its cancellation marker, cancellation +rollback JSON and helper script in the per-user directory. No technician MSI was +registered. This is a blocked test precondition, not a passed or failed technician +MSI execution. The commit cleanup now covers both editions: SYSTEM/canonical +ProgramData for customer, impersonated user/canonical LocalApplicationData for +technician. Both use an embedded agent so removal does not delete the executable +needed for commit. Local fixture checks reject arbitrary paths for either edition; +MSI-authoring tests also assert the technician action retains user context. +New binaries, signed packages, repeated removal and rollback still need validation. diff --git a/product/agent/src/main.rs b/product/agent/src/main.rs index 92c62a097dc..3f47c5866da 100644 --- a/product/agent/src/main.rs +++ b/product/agent/src/main.rs @@ -21,11 +21,13 @@ async fn main()->Result<()> { match command { "rollback-protocol"=>println!("1"), #[cfg(windows)] - "commit-customer-uninstall"=>{ + "commit-uninstall"=>{ // The signed MSI embeds a separate agent copy: its installed copy // has already been removed when the commit script executes. - let directory=PathBuf::from(args.get(2).context("Missing customer uninstall state directory")?); - swan_agent::update::commit_customer_uninstall(&directory)?; + let edition=args.get(2).context("Missing uninstall edition")?; + ensure!(matches!(edition.as_str(),"customer"|"technician"),"Invalid uninstall edition"); + let directory=PathBuf::from(args.get(3).context("Missing uninstall state directory")?); + swan_agent::update::commit_uninstall(&directory,edition)?; } #[cfg(windows)] "cancel-update-recovery"|"restore-update-recovery"=>{ diff --git a/product/agent/src/update.rs b/product/agent/src/update.rs index 15ea8a0dff9..b0189bb1d62 100644 --- a/product/agent/src/update.rs +++ b/product/agent/src/update.rs @@ -285,10 +285,11 @@ fn verify_publisher(path:&Path,release:&Release)->Result<()> { } #[cfg(windows)] -pub fn commit_customer_uninstall(directory:&Path)->Result<()> { - let status=embedded_powershell_command(include_str!("../../../deployment/windows/Commit-CustomerUninstall.ps1"), - &[("Directory",directory.to_str().context("Uninstall state path is not valid Unicode")?)])?.status()?; - ensure!(status.success(),"Customer uninstall commit cleanup failed");Ok(()) +pub fn commit_uninstall(directory:&Path,edition:&str)->Result<()> { + ensure!(matches!(edition,"customer"|"technician"),"Invalid uninstall edition"); + let status=embedded_powershell_command(include_str!("../../../deployment/windows/Commit-Uninstall.ps1"), + &[("Directory",directory.to_str().context("Uninstall state path is not valid Unicode")?),("Edition",edition)])?.status()?; + ensure!(status.success(),"Uninstall commit cleanup failed");Ok(()) } #[cfg(windows)] From 2ace145031fe5520b00de0417e80e0a3e170dbc0 Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Wed, 21 Oct 2026 20:29:18 +0200 Subject: [PATCH 280/652] docs: record verified repeated removal and recovery upgrade on Server 2019 --- docs/ACCEPTANCE.md | 50 +++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 49 insertions(+), 1 deletion(-) diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index bbda8f7613e..e7c56a7d891 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -3048,4 +3048,52 @@ ProgramData for customer, impersonated user/canonical LocalApplicationData for technician. Both use an embedded agent so removal does not delete the executable needed for commit. Local fixture checks reject arbitrary paths for either edition; MSI-authoring tests also assert the technician action retains user context. -New binaries, signed packages, repeated removal and rollback still need validation. +Subsequent verified package results are recorded below; interrupted-transaction +rollback still needs separate verification. + +### Verified two-edition uninstall fix on Server 2019, 21 October + +Push workflow `39649347086` and PR workflow `39649354472` passed for source +`053a52e3ac29cb0f7e9686da9c626f94bf153005`, including Windows PowerShell 5 +commit/rollback-state fixtures and all four MSI-authoring checks. Windows artifact +`12368667260` matched GitHub SHA-256 +`b3bdf363098173d49efb4665ef6204f47d5e4ed0ac20512f1932ee5ef3fc4193`. +All five archive entries and 116 corresponding source files passed independent +hash/CRC/public-Git verification. These are build-input checks, not bit-for-bit +binary reproducibility. + +The fixed component agent was privately signed and combined with the previously +verified native `c0bf4b4` payload. Both rebuilt MSI `1.5.9` packages received +verified timestamped signatures from the existing nonexportable disposable-VM +publisher; no host trust or production signing was added. The signed customer +MSI SHA-256 is `8a64ca8a90573af75cfcc8e247e6c4d5999b7d488c1a5a9b2202f988b595f678`; +technician is `8caf16d8fcfa5e47e2ed153cc2d094154c3de8d098109863572c68d70a2bbbcc`. + +All 12 operations passed as Administrator on Server 2019 Standard Evaluation +Desktop Experience `17763.3650`: + +| Edition | Verified operations | +| --- | --- | +| Customer | Upgrade failed `1.5.8` candidate to `1.5.9`, repair, uninstall, reinstall, repair, uninstall | +| Technician | Install, repair, uninstall, reinstall, repair, uninstall | + +Every raw MSI log independently showed exit 0 with no `Return value 3`. The +guarded repair checks verified the installed agent, full customer payload and +license repair, or technician executable/shortcut/registration before and after +repair. Reinstalls retained their existing cancellation marker. Both editions' +uninstall commit actions executed, clearing obsolete rollback records after +removal. The earlier technician directory contained exactly its three known +legacy cancellation files and no registered product; those files were preserved +under the protected lab test directory before the first new technician install. +They were not discarded to make the repeated-install cases pass: subsequent +reinstalls used the new retained state in place. + +A separate read-only guest check confirmed no customer or technician product +registration, no installed configuration agents, no customer service or watcher, +both uninstall cancellation markers retained, both cancellation rollback records +absent, and unchanged hashes for all preserved legacy files. The native and +component source origins remain separate in the evidence. These tests used the +existing guest after prior package tests, not a new clean snapshot. Company +enrollment/identity preservation, standard-user technician installation, automatic +updates, interrupted-removal rollback and native desktop sessions remain separate +acceptance gates. From 650361413543baf7ef150f5960544154ec77ddeb Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Wed, 21 Oct 2026 21:10:40 +0200 Subject: [PATCH 281/652] fix: create private customer state before MSI service startup --- .dockerignore | 1 + .github/workflows/company-product.yml | 6 ++ deployment/linux/Dockerfile.dockerignore | 1 + deployment/windows/CUSTOMER-MSI.md | 12 ++++ deployment/windows/Install-Company.ps1 | 7 +- .../windows/Prepare-CustomerDirectory.ps1 | 72 +++++++++++++++++++ deployment/windows/generate-customer-msi.py | 11 +-- deployment/windows/msi/Technician.wxs | 4 +- .../windows/test_customer_directory.ps1 | 67 +++++++++++++++++ deployment/windows/test_customer_msi.py | 18 ++++- docs/ACCEPTANCE.md | 45 ++++++++++++ product/agent/src/main.rs | 44 +++++------- product/agent/src/update.rs | 37 ++++++++++ 13 files changed, 287 insertions(+), 38 deletions(-) create mode 100644 deployment/windows/Prepare-CustomerDirectory.ps1 create mode 100644 deployment/windows/test_customer_directory.ps1 diff --git a/.dockerignore b/.dockerignore index 4b5dccb1058..60639995365 100644 --- a/.dockerignore +++ b/.dockerignore @@ -72,3 +72,4 @@ deployment/windows/** !deployment/windows/Update-RecoveryTask.ps1 !deployment/windows/Update-UninstallCancellation.ps1 !deployment/windows/Commit-Uninstall.ps1 +!deployment/windows/Prepare-CustomerDirectory.ps1 diff --git a/.github/workflows/company-product.yml b/.github/workflows/company-product.yml index a75059dd871..74eca251e5f 100644 --- a/.github/workflows/company-product.yml +++ b/.github/workflows/company-product.yml @@ -62,6 +62,12 @@ jobs: 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 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 diff --git a/deployment/linux/Dockerfile.dockerignore b/deployment/linux/Dockerfile.dockerignore index 4b5dccb1058..60639995365 100644 --- a/deployment/linux/Dockerfile.dockerignore +++ b/deployment/linux/Dockerfile.dockerignore @@ -72,3 +72,4 @@ deployment/windows/** !deployment/windows/Update-RecoveryTask.ps1 !deployment/windows/Update-UninstallCancellation.ps1 !deployment/windows/Commit-Uninstall.ps1 +!deployment/windows/Prepare-CustomerDirectory.ps1 diff --git a/deployment/windows/CUSTOMER-MSI.md b/deployment/windows/CUSTOMER-MSI.md index 28292544806..cba51c4a952 100644 --- a/deployment/windows/CUSTOMER-MSI.md +++ b/deployment/windows/CUSTOMER-MSI.md @@ -16,6 +16,18 @@ RuntimeBroker into the application directory and removes that copy on uninstall; it does not redistribute a project copy of Microsoft's binary. Runtime verification compares its hash with the current Windows system binary. +Before creating installation folders or starting the service, the MSI uses its +embedded release agent to prepare the machine-wide customer state directory. +The helper creates a missing directory with SYSTEM/Administrators ownership and +permissions atomically, then checks existing files and directories without +adopting unsafe owners, permissions or reparse points. Unsafe pre-existing state +must be preserved and investigated; setup does not silently trust it by changing +its ACL. Explicit company setup uses the same helper before reading saved trust. +The MSI also executes its embedded agent for uninstall/rollback, rather than +trusting the installed agent or mutable helper scripts in that state directory. +New package installation and standard-user negative access checks for this +hardening remain required before release. + ```powershell ./deployment/windows/Build-CustomerMsi.ps1 ` -WixExe C:/BuildTools/WiX/bin/wix.exe -SourceDirectory C:/Release/swan-client ` diff --git a/deployment/windows/Install-Company.ps1 b/deployment/windows/Install-Company.ps1 index 6bbd62b664b..3869b901803 100644 --- a/deployment/windows/Install-Company.ps1 +++ b/deployment/windows/Install-Company.ps1 @@ -44,11 +44,10 @@ if ($bootstrap.edition -eq 'customer') { $identity = [Security.Principal.WindowsIdentity]::GetCurrent() if (-not ([Security.Principal.WindowsPrincipal]::new($identity)).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { throw 'Run customer setup as administrator.' } } -New-Item -ItemType Directory -Force -Path $editionDirectory | Out-Null if ($bootstrap.edition -eq 'customer') { - & icacls.exe $editionDirectory '/inheritance:r' '/grant:r' '*S-1-5-18:(OI)(CI)F' '*S-1-5-32-544:(OI)(CI)F' | Out-Null - if ($LASTEXITCODE -ne 0) { throw 'Cannot protect customer configuration directory.' } -} + & $agent prepare-customer-directory $editionDirectory + if ($LASTEXITCODE -ne 0) { throw 'Customer configuration directory is not trusted. Preserve and investigate existing data before setup.' } +} else { New-Item -ItemType Directory -Force -Path $editionDirectory | Out-Null } $env:SWAN_STATE_DIR = $editionDirectory if (-not (Test-Path -LiteralPath (Join-Path $editionDirectory 'managed-state.json'))) { & $agent setup $BootstrapPath --accept-company diff --git a/deployment/windows/Prepare-CustomerDirectory.ps1 b/deployment/windows/Prepare-CustomerDirectory.ps1 new file mode 100644 index 00000000000..8c9968bf834 --- /dev/null +++ b/deployment/windows/Prepare-CustomerDirectory.ps1 @@ -0,0 +1,72 @@ +[CmdletBinding()] +param([Parameter(Mandatory=$true)][string]$Directory,[ValidateSet('canonical','custom')][string]$Scope='canonical') +$ErrorActionPreference='Stop' +function Assert-CustomerAcl { + param([Parameter(Mandatory=$true)]$Acl) + $allowed=@('S-1-5-18','S-1-5-32-544') + if ($Acl.GetOwner([Security.Principal.SecurityIdentifier]).Value -notin $allowed) { throw 'Customer state has an untrusted owner. Preserve and investigate it before setup.' } + $rules=$Acl.GetAccessRules($true,$true,[Security.Principal.SecurityIdentifier]) + foreach($rule in $rules) { + if ($rule.AccessControlType -ne [Security.AccessControl.AccessControlType]::Allow -or $rule.IdentityReference.Value -notin $allowed) { throw 'Customer state permissions are not restricted to SYSTEM and Administrators.' } + } + foreach($sid in $allowed) { + if (@($rules|Where-Object {$_.IdentityReference.Value -eq $sid -and ($_.FileSystemRights -band [Security.AccessControl.FileSystemRights]::FullControl) -eq [Security.AccessControl.FileSystemRights]::FullControl}).Count -eq 0) { throw 'Customer state requires full control for SYSTEM and Administrators.' } + } +} +$identity=[Security.Principal.WindowsIdentity]::GetCurrent() +if (-not ([Security.Principal.WindowsPrincipal]::new($identity)).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { throw 'Customer state preparation requires administrator rights.' } +$Directory=[IO.Path]::GetFullPath($Directory).TrimEnd('\') +if ($Directory -eq [IO.Path]::GetPathRoot($Directory).TrimEnd('\')) { throw 'A volume root is not a customer state directory.' } +$expected=Join-Path ([Environment]::GetFolderPath('CommonApplicationData')) 'SwanRemoteSupport' +if ($Scope -eq 'canonical' -and $Directory -ine [IO.Path]::GetFullPath($expected)) { throw 'The customer MSI requires the canonical state directory.' } +$descriptor=[Security.AccessControl.DirectorySecurity]::new() +$descriptor.SetOwner([Security.Principal.SecurityIdentifier]::new('S-1-5-32-544')) +$descriptor.SetAccessRuleProtection($true,$false) +foreach($sid in @('S-1-5-18','S-1-5-32-544')) { + $descriptor.AddAccessRule([Security.AccessControl.FileSystemAccessRule]::new([Security.Principal.SecurityIdentifier]::new($sid),'FullControl','ContainerInherit,ObjectInherit','None','Allow')) +} +if(Test-Path -LiteralPath $Directory){ + $existing=Get-Item -LiteralPath $Directory -Force + if(-not $existing.PSIsContainer -or ($existing.Attributes -band [IO.FileAttributes]::ReparsePoint)){throw 'Customer state directory is unsafe.'} + Assert-CustomerAcl (Get-Acl -LiteralPath $Directory) +} +Add-Type -TypeDefinition @' +using System; +using System.Runtime.InteropServices; +using Microsoft.Win32.SafeHandles; +public static class SwanCustomerDirectoryLock { + [StructLayout(LayoutKind.Sequential)] + private struct SecurityAttributes { public int Length; public IntPtr Descriptor; [MarshalAs(UnmanagedType.Bool)] public bool Inherit; } + [DllImport("kernel32.dll", EntryPoint="CreateDirectoryW", CharSet=CharSet.Unicode, SetLastError=true)] + private static extern bool CreateDirectory(string path,ref SecurityAttributes security); + [DllImport("kernel32.dll", CharSet=CharSet.Unicode, SetLastError=true)] + private static extern SafeFileHandle CreateFile(string path,uint access,uint share,IntPtr security,uint creation,uint flags,IntPtr template); + public static int Create(string path,byte[] descriptor) { + GCHandle pinned=GCHandle.Alloc(descriptor,GCHandleType.Pinned); + try { + SecurityAttributes security=new SecurityAttributes { Length=Marshal.SizeOf(typeof(SecurityAttributes)), Descriptor=pinned.AddrOfPinnedObject(), Inherit=false }; + return CreateDirectory(path,ref security) ? 0 : Marshal.GetLastWin32Error(); + } finally { pinned.Free(); } + } + public static SafeFileHandle Open(string path) { return CreateFile(path,0x80000000,1,IntPtr.Zero,3,0x02200000,IntPtr.Zero); } +} +'@ +# CreateDirectoryW applies security atomically only to a new directory. An +# existing object is inspected, never adopted by changing its owner or ACL. +$created=[SwanCustomerDirectoryLock]::Create($Directory,$descriptor.GetSecurityDescriptorBinaryForm()) +if($created -notin @(0,183)){throw ([ComponentModel.Win32Exception]::new($created))} +# Sharing only read access rejects a pre-existing writable directory handle; +# do not race a user who opened the directory before permissions were secured. +$handle=[SwanCustomerDirectoryLock]::Open($Directory) +if ($handle.IsInvalid) { $handle.Dispose();throw 'Customer state directory is in use or cannot be inspected safely.' } +try { + $pending=[Collections.Generic.Stack[string]]::new();$pending.Push($Directory);$count=0 + while($pending.Count) { + $path=$pending.Pop();$item=Get-Item -LiteralPath $path -Force + if($path -eq $Directory -and -not $item.PSIsContainer){throw 'Customer state must be a directory.'} + if ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) { throw 'Customer state must not contain reparse points.' } + Assert-CustomerAcl (Get-Acl -LiteralPath $path) + $count++;if($count -gt 10000){throw 'Customer state inspection limit exceeded.'} + if($item.PSIsContainer){foreach($child in @(Get-ChildItem -LiteralPath $path -Force)){$pending.Push($child.FullName)}} + } +} finally { $handle.Dispose() } diff --git a/deployment/windows/generate-customer-msi.py b/deployment/windows/generate-customer-msi.py index b1dc850b042..2e258fac2e4 100644 --- a/deployment/windows/generate-customer-msi.py +++ b/deployment/windows/generate-customer-msi.py @@ -88,14 +88,17 @@ def author(source, agent, version): component = element(state, "Component", Id="ConfigurationAgent", Guid=str(uuid.uuid5(UPGRADE, "configuration-agent")).upper(), Bitness="always64") element(component, "File", Id="ConfigurationAgentExe", Name="swan-agent.exe", Source=str(agent), KeyPath="yes") element(feature, "ComponentRef", Id="ConfigurationAgent") - element(package, "CustomAction", Id="RemoveCompanyConfigurationTask", FileRef="ConfigurationAgentExe", - ExeCommand="remove-configuration-task", Execute="deferred", Impersonate="no", Return="check") - element(package, "CustomAction", Id="RestoreCompanyConfigurationTask", FileRef="ConfigurationAgentExe", - ExeCommand="restore-configuration-task", Execute="rollback", Impersonate="no", Return="check") element(package, "Binary", Id="UninstallCommitAgent", SourceFile=str(agent)) + element(package, "CustomAction", Id="PrepareCustomerState", BinaryRef="UninstallCommitAgent", + ExeCommand='prepare-customer-directory "[STATEFOLDER]."', Execute="deferred", Impersonate="no", Return="check") + element(package, "CustomAction", Id="RemoveCompanyConfigurationTask", BinaryRef="UninstallCommitAgent", + ExeCommand='remove-configuration-task "[STATEFOLDER]."', Execute="deferred", Impersonate="no", Return="check") + element(package, "CustomAction", Id="RestoreCompanyConfigurationTask", BinaryRef="UninstallCommitAgent", + ExeCommand='restore-configuration-task "[STATEFOLDER]."', Execute="rollback", Impersonate="no", Return="check") element(package, "CustomAction", Id="CommitCompanyUninstall", BinaryRef="UninstallCommitAgent", ExeCommand='commit-uninstall customer "[STATEFOLDER]."', Execute="commit", Impersonate="no", Return="check") sequence = element(package, "InstallExecuteSequence") + element(sequence, "Custom", Action="PrepareCustomerState", Before="CreateFolders", Condition='NOT (REMOVE = "ALL")') element(sequence, "Custom", Action="RestoreCompanyConfigurationTask", Before="RemoveCompanyConfigurationTask", Condition='REMOVE = "ALL" AND NOT UPGRADINGPRODUCTCODE AND NOT SWAN_RECOVERY') element(sequence, "Custom", Action="RemoveCompanyConfigurationTask", Before="RemoveFiles", diff --git a/deployment/windows/msi/Technician.wxs b/deployment/windows/msi/Technician.wxs index d8f6955503e..9283ce8ad10 100644 --- a/deployment/windows/msi/Technician.wxs +++ b/deployment/windows/msi/Technician.wxs @@ -2,9 +2,9 @@ - - + + diff --git a/deployment/windows/test_customer_directory.ps1 b/deployment/windows/test_customer_directory.ps1 new file mode 100644 index 00000000000..54e16ce9abb --- /dev/null +++ b/deployment/windows/test_customer_directory.ps1 @@ -0,0 +1,67 @@ +param([switch]$RequireAdministratorCases) +$ErrorActionPreference='Stop' +$script=Join-Path $PSScriptRoot 'Prepare-CustomerDirectory.ps1' +$tokens=$null;$errors=$null +$ast=[System.Management.Automation.Language.Parser]::ParseFile($script,[ref]$tokens,[ref]$errors) +if($errors.Count){throw 'Customer directory helper failed parsing.'} +$guard=$ast.Find({param($node)$node -is [System.Management.Automation.Language.FunctionDefinitionAst] -and $node.Name -eq 'Assert-CustomerAcl'},$true) +if($null -eq $guard){throw 'Customer ACL guard missing.'} +. ([ScriptBlock]::Create($guard.Extent.Text)) +function New-FixtureAcl([string]$owner){ + $acl=[Security.AccessControl.DirectorySecurity]::new() + $acl.SetOwner([Security.Principal.SecurityIdentifier]::new($owner));$acl.SetAccessRuleProtection($true,$false) + foreach($sid in @('S-1-5-18','S-1-5-32-544')){$acl.AddAccessRule([Security.AccessControl.FileSystemAccessRule]::new([Security.Principal.SecurityIdentifier]::new($sid),'FullControl','ContainerInherit,ObjectInherit','None','Allow'))} + return $acl +} +Assert-CustomerAcl (New-FixtureAcl 'S-1-5-18') +Assert-CustomerAcl (New-FixtureAcl 'S-1-5-32-544') +foreach($sid in @('S-1-5-32-545','S-1-1-0')){ + $acl=New-FixtureAcl 'S-1-5-18' + $acl.AddAccessRule([Security.AccessControl.FileSystemAccessRule]::new([Security.Principal.SecurityIdentifier]::new($sid),'ReadAndExecute,Write','ContainerInherit,ObjectInherit','None','Allow')) + $rejected=$false;try{Assert-CustomerAcl $acl}catch{$rejected=$true} + if(-not $rejected){throw 'Unprivileged state permissions accepted.'} +} +$rejected=$false;try{Assert-CustomerAcl (New-FixtureAcl 'S-1-5-32-545')}catch{$rejected=$true} +if(-not $rejected){throw 'Untrusted owner accepted.'} +$root=Join-Path ([IO.Path]::GetTempPath()) ('swan-directory-security-'+[Guid]::NewGuid().ToString('N')) +$native=Join-Path ([Environment]::GetFolderPath('System')) 'WindowsPowerShell/v1.0/powershell.exe' +function Invoke-DirectoryHelper([string]$Directory) { + # Windows PowerShell converts redirected native stderr into terminating + # errors under Stop. Observe the child exit code for intentional rejection. + $start=[Diagnostics.ProcessStartInfo]::new();$start.FileName=$native + $start.Arguments='-NoProfile -NonInteractive -ExecutionPolicy Bypass -File "'+$script+'" -Directory "'+$Directory+'" -Scope custom' + $start.UseShellExecute=$false;$start.CreateNoWindow=$true + $start.RedirectStandardOutput=$true;$start.RedirectStandardError=$true + $process=[Diagnostics.Process]::Start($start) + try { + $output=$process.StandardOutput.ReadToEndAsync();$errors=$process.StandardError.ReadToEndAsync() + if(-not $process.WaitForExit(30000)){$process.Kill();$process.WaitForExit();throw 'Customer directory fixture exceeded its deadline.'} + $null=$output.GetAwaiter().GetResult();$null=$errors.GetAwaiter().GetResult() + return $process.ExitCode + } finally {$process.Dispose()} +} +$identity=[Security.Principal.WindowsIdentity]::GetCurrent() +$admin=([Security.Principal.WindowsPrincipal]::new($identity)).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) +if($RequireAdministratorCases -and -not $admin){throw 'Administrator filesystem cases are required for this run.'} +if($admin){ + try{ + if((Invoke-DirectoryHelper $root) -ne 0){throw 'Private directory creation failed.'} + Assert-CustomerAcl (Get-Acl -LiteralPath $root) + if((Invoke-DirectoryHelper $root) -ne 0){throw 'Trusted existing directory rejected.'} + $child=Join-Path $root 'untrusted.txt';[IO.File]::WriteAllText($child,'untrusted fixture: preserve') + $fileAcl=[Security.AccessControl.FileSecurity]::new();$fileAcl.SetOwner($identity.User);$fileAcl.SetAccessRuleProtection($true,$false) + $fileAcl.AddAccessRule([Security.AccessControl.FileSystemAccessRule]::new($identity.User,'FullControl','Allow')) + Set-Acl -LiteralPath $child -AclObject $fileAcl + $before=(Get-Acl -LiteralPath $child).Sddl + if((Invoke-DirectoryHelper $root) -eq 0 -or (Get-Acl -LiteralPath $child).Sddl -cne $before -or [IO.File]::ReadAllText($child) -cne 'untrusted fixture: preserve'){throw 'Untrusted existing file was adopted or changed.'} + Remove-Item -LiteralPath $child + $broad=Get-Acl -LiteralPath $root + $broad.AddAccessRule([Security.AccessControl.FileSystemAccessRule]::new([Security.Principal.SecurityIdentifier]::new('S-1-5-32-545'),'Write','ContainerInherit,ObjectInherit','None','Allow')) + Set-Acl -LiteralPath $root -AclObject $broad;$before=(Get-Acl -LiteralPath $root).Sddl + if((Invoke-DirectoryHelper $root) -eq 0 -or (Get-Acl -LiteralPath $root).Sddl -cne $before){throw 'Writable existing state was silently adopted.'} + }finally{ + $child=Join-Path $root 'untrusted.txt';if(Test-Path -LiteralPath $child){Remove-Item -LiteralPath $child} + if(Test-Path -LiteralPath $root){Remove-Item -LiteralPath $root} + } +} +Write-Output ('Customer directory ACL checks passed; administrator filesystem cases executed: '+$admin) diff --git a/deployment/windows/test_customer_msi.py b/deployment/windows/test_customer_msi.py index 33640667580..6db6fdbd16a 100644 --- a/deployment/windows/test_customer_msi.py +++ b/deployment/windows/test_customer_msi.py @@ -44,7 +44,8 @@ def test_full_manifest_license_and_service_lifecycle(self): location = tree.find(".//w:SetProperty[@Id='ARPINSTALLLOCATION']", ns) self.assertEqual(location.attrib["Value"], "[INSTALLFOLDER]") cleanup = tree.find(".//w:CustomAction[@Id='RemoveCompanyConfigurationTask']", ns) - self.assertEqual(cleanup.attrib["FileRef"], "ConfigurationAgentExe") + self.assertEqual(cleanup.attrib["BinaryRef"], "UninstallCommitAgent") + self.assertEqual(cleanup.attrib["ExeCommand"], 'remove-configuration-task "[STATEFOLDER]."') self.assertEqual(cleanup.attrib["Execute"], "deferred") self.assertEqual(cleanup.attrib["Impersonate"], "no") scheduling = tree.find(".//w:InstallExecuteSequence/w:Custom[@Action='RemoveCompanyConfigurationTask']", ns) @@ -52,6 +53,8 @@ def test_full_manifest_license_and_service_lifecycle(self): self.assertEqual(scheduling.attrib["Condition"], 'REMOVE = "ALL" AND NOT UPGRADINGPRODUCTCODE AND NOT SWAN_RECOVERY') rollback = tree.find(".//w:CustomAction[@Id='RestoreCompanyConfigurationTask']", ns) self.assertEqual(rollback.attrib["Execute"], "rollback") + self.assertEqual(rollback.attrib["BinaryRef"], "UninstallCommitAgent") + self.assertEqual(rollback.attrib["ExeCommand"], 'restore-configuration-task "[STATEFOLDER]."') rollback_scheduling = tree.find(".//w:InstallExecuteSequence/w:Custom[@Action='RestoreCompanyConfigurationTask']", ns) self.assertEqual(rollback_scheduling.attrib["Before"], "RemoveCompanyConfigurationTask") embedded = tree.find(".//w:Binary[@Id='UninstallCommitAgent']", ns) @@ -65,6 +68,14 @@ def test_full_manifest_license_and_service_lifecycle(self): commit_scheduling = tree.find(".//w:InstallExecuteSequence/w:Custom[@Action='CommitCompanyUninstall']", ns) self.assertEqual(commit_scheduling.attrib["After"], "RemoveCompanyConfigurationTask") self.assertEqual(commit_scheduling.attrib["Condition"], scheduling.attrib["Condition"]) + preparation = tree.find(".//w:CustomAction[@Id='PrepareCustomerState']", ns) + self.assertEqual(preparation.attrib["BinaryRef"], "UninstallCommitAgent") + self.assertEqual(preparation.attrib["ExeCommand"], 'prepare-customer-directory "[STATEFOLDER]."') + self.assertEqual(preparation.attrib["Execute"], "deferred") + self.assertEqual(preparation.attrib["Impersonate"], "no") + preparation_sequence = tree.find(".//w:InstallExecuteSequence/w:Custom[@Action='PrepareCustomerState']", ns) + self.assertEqual(preparation_sequence.attrib["Before"], "CreateFolders") + self.assertEqual(preparation_sequence.attrib["Condition"], 'NOT (REMOVE = "ALL")') installed_files = tree.findall(".//w:File", ns) self.assertEqual(len(installed_files), len(files)+1) @@ -81,6 +92,11 @@ def test_technician_uninstall_commit_retains_user_context(self): sequence = tree.find(".//w:InstallExecuteSequence/w:Custom[@Action='CommitTechnicianUninstall']", ns) self.assertEqual(sequence.attrib["After"], "CancelUpdateRecovery") self.assertEqual(sequence.attrib["Condition"], 'REMOVE = "ALL" AND NOT UPGRADINGPRODUCTCODE AND NOT SWAN_RECOVERY') + for name, command in (("CancelUpdateRecovery", "cancel-update-recovery"), ("RestoreUpdateRecovery", "restore-update-recovery")): + action = tree.find(".//w:CustomAction[@Id='" + name + "']", ns) + self.assertEqual(action.attrib["BinaryRef"], "UninstallCommitAgent") + self.assertEqual(action.attrib["ExeCommand"], command + ' "[INSTALLFOLDER]."') + self.assertEqual(action.attrib["Impersonate"], "yes") def test_component_identity_survives_version_and_payload_changes(self): with tempfile.TemporaryDirectory() as folder: diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index e7c56a7d891..b234cf1b59f 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -3097,3 +3097,48 @@ existing guest after prior package tests, not a new clean snapshot. Company enrollment/identity preservation, standard-user technician installation, automatic updates, interrupted-removal rollback and native desktop sessions remain separate acceptance gates. + +### Actual MSI rollback and customer state permissions, 21 October + +Two private `1.5.10` fault-injection MSIs were built from native `c0bf4b4` and +component `053a52e` inputs. Their only injected behavior was an opt-in deferred +failure after `RemoveFiles`; the fixtures were lab-signed, not approved or +published as releases. Both editions passed installation and baseline repair, +the deliberately failing uninstall returned the expected 1603, full installed +payload/registration were restored, subsequent repair passed, and normal +uninstall succeeded. Independent raw-log checks verified actual deferred file +removal before `LabFailRemoval` returned 1, followed by rollback. Ten operations +matched their expected outcomes, including exactly two intentional failures. + +The customer test created an owned, disabled SYSTEM configuration watcher before +fault injection. Rollback restored its exact agent path, `watch` argument, +SYSTEM principal and disabled state. Both editions retained the prior uninstall +cancellation marker and cleared temporary rollback records after restoration; +the final normal removal committed. These are installer rollback results on +Server 2019 `17763.3650`, not management database backup/restore, migration, +enrollment or native-session results. + +A read-only post-uninstall ACL inspection found that the bare-MSI customer state +directory retained inherited standard-user write access. Earlier company setup +applied an ACL, but bare installation/removal also needs an independent trust +boundary. No unauthorized session or privilege escalation was demonstrated. +This is nevertheless a release-blocking permissions finding: cleanup must not +execute mutable scripts from a directory that another user can populate, and +setup must not adopt an untrusted existing state directory merely by changing +its ACL. + +The fix adds atomic private-directory creation and owner/ACL/reparse inspection +before MSI folder creation/service startup and before explicit setup reads saved +trust. It rejects unsafe existing objects unchanged and checks for existing +writable directory handles. Both MSIs use embedded agent copies for cleanup; +the agent executes its embedded PowerShell helpers directly instead of writing +and executing mutable helper files beside configuration data. All four MSI +authoring checks, five source-archive checks and entry-point/commit fixtures pass +locally. The directory helper's real administrator filesystem cases passed on +Server 2019, including private creation, trusted reuse, rejection of a foreign +file owner and rejection of a writable existing directory without changing it. +The first harness run correctly rejected unsafe state but Windows PowerShell +treated its redirected stderr as a terminating native error; the harness now +observes bounded child exit codes directly. Compilation/CI and new signed MSI +positive and standard-user negative tests remain pending. Existing company +services and their enrollment were not modified by these changes. diff --git a/product/agent/src/main.rs b/product/agent/src/main.rs index 3f47c5866da..a1fc4f8e3af 100644 --- a/product/agent/src/main.rs +++ b/product/agent/src/main.rs @@ -21,6 +21,11 @@ async fn main()->Result<()> { match command { "rollback-protocol"=>println!("1"), #[cfg(windows)] + "prepare-customer-directory"=>{ + let directory=PathBuf::from(args.get(2).context("Missing customer state directory")?); + swan_agent::update::prepare_customer_directory(&directory,true)?; + } + #[cfg(windows)] "commit-uninstall"=>{ // The signed MSI embeds a separate agent copy: its installed copy // has already been removed when the commit script executes. @@ -32,13 +37,11 @@ async fn main()->Result<()> { #[cfg(windows)] "cancel-update-recovery"|"restore-update-recovery"=>{ let executable=std::env::current_exe()?; - ensure!(executable.file_name().is_some_and(|name|name.to_string_lossy().eq_ignore_ascii_case("swan-agent.exe")),"Uninstall cancellation requires the installed agent"); - let installed_directory=executable.parent().context("Missing installed agent directory")?; - let script=installed_directory.join("Update-UninstallCancellation.ps1"); - std::fs::write(&script,include_str!("../../../deployment/windows/Update-UninstallCancellation.ps1"))?; - let status=swan_agent::update::powershell_command()?.args(["-NoLogo","-NoProfile","-NonInteractive","-ExecutionPolicy","Bypass","-File"]).arg(script).arg("-Directory").arg(installed_directory) - .arg("-Operation").arg(if command=="cancel-update-recovery" {"cancel"}else{"restore"}).status()?; - ensure!(status.success(),"Uninstall update cancellation failed"); + let directory=if let Some(path)=args.get(2){PathBuf::from(path)}else{ + ensure!(executable.file_name().is_some_and(|name|name.to_string_lossy().eq_ignore_ascii_case("swan-agent.exe")),"Uninstall cancellation requires the installed agent"); + executable.parent().context("Missing installed agent directory")?.to_owned() + }; + swan_agent::update::uninstall_cancellation(&directory,command=="restore-update-recovery",false)?; } #[cfg(windows)] "recover-update-task"=>{ @@ -47,32 +50,19 @@ async fn main()->Result<()> { #[cfg(windows)] "remove-configuration-task"|"restore-configuration-task"=>{ let executable=std::env::current_exe()?; - let installed_directory=executable.parent().context("Missing installed agent directory")?; - ensure!(executable.file_name().is_some_and(|name|name.to_string_lossy().eq_ignore_ascii_case("swan-agent.exe")),"Uninstall cleanup requires the installed configuration agent"); - let cancellation_script=installed_directory.join("Update-UninstallCancellation.ps1"); - std::fs::write(&cancellation_script,include_str!("../../../deployment/windows/Update-UninstallCancellation.ps1"))?; - let cancellation=|operation:&str|->Result<()> { - let status=swan_agent::update::powershell_command()?.args(["-NoLogo","-NoProfile","-NonInteractive","-ExecutionPolicy","Bypass","-File"]).arg(&cancellation_script).arg("-Directory").arg(installed_directory).arg("-Operation").arg(operation).status()?; - ensure!(status.success(),"Uninstall recovery cancellation failed");Ok(()) + let directory=if let Some(path)=args.get(2){PathBuf::from(path)}else{ + ensure!(executable.file_name().is_some_and(|name|name.to_string_lossy().eq_ignore_ascii_case("swan-agent.exe")),"Uninstall cleanup requires the installed configuration agent"); + executable.parent().context("Missing installed agent directory")?.to_owned() }; - if command=="remove-configuration-task" {cancellation("cancel")?;} - // MSI invokes the installed binary under SYSTEM before removing it. - // The script checks the exact task executable, arguments and owner. - let (name,contents)=if command=="remove-configuration-task" { - ("Remove-Configuration.ps1",include_str!("../../../deployment/windows/Remove-Configuration.ps1")) - }else{("Restore-Configuration.ps1",include_str!("../../../deployment/windows/Restore-Configuration.ps1"))}; - let script=installed_directory.join(name); - std::fs::write(&script,contents)?; - let status=swan_agent::update::powershell_command()?.args(["-NoLogo","-NoProfile","-NonInteractive","-ExecutionPolicy","Bypass","-File"]).arg(&script).arg("-Directory").arg(installed_directory).status()?; - std::fs::remove_file(&script)?; - ensure!(status.success(),"Company configuration task cleanup failed"); - if command=="restore-configuration-task" {cancellation("restore")?;} + swan_agent::update::configuration_task_removal(&directory,command=="restore-configuration-task")?; } "setup"=>{ let file=args.get(2).context("Usage: swan-agent setup bootstrap.json --accept-company")?; ensure!(args.iter().any(|a|a=="--accept-company"),"Review the company HTTPS domain and trust key; pass --accept-company only after confirming them"); - ensure!(!directory.join("managed-state.json").exists(),"Already configured; refuse to silently change company ownership"); let input:Bootstrap=serde_json::from_slice(&std::fs::read(file)?)?; + #[cfg(windows)] + if input.edition==Edition::Customer {swan_agent::update::prepare_customer_directory(&directory,std::env::var_os("SWAN_STATE_DIR").is_none())?;} + ensure!(!directory.join("managed-state.json").exists(),"Already configured; refuse to silently change company ownership"); println!("Connecting to company domain {}",input.management_url); bootstrap(input).await?.save(&directory)?; println!("Company verified and profile pinned."); diff --git a/product/agent/src/update.rs b/product/agent/src/update.rs index b0189bb1d62..aeeb6db9e6d 100644 --- a/product/agent/src/update.rs +++ b/product/agent/src/update.rs @@ -292,6 +292,43 @@ pub fn commit_uninstall(directory:&Path,edition:&str)->Result<()> { ensure!(status.success(),"Uninstall commit cleanup failed");Ok(()) } +#[cfg(windows)] +pub fn prepare_customer_directory(directory:&Path,canonical:bool)->Result<()> { + let status=embedded_powershell_command(include_str!("../../../deployment/windows/Prepare-CustomerDirectory.ps1"), + &[("Directory",directory.to_str().context("Customer state path is not valid Unicode")?),("Scope",if canonical{"canonical"}else{"custom"})])?.status()?; + ensure!(status.success(),"Customer state directory is not trusted or private");Ok(()) +} + +#[cfg(windows)] +fn validate_uninstall_directory(directory:&Path,customer:bool)->Result<()> { + let guard=r#"param([string]$Directory,[string]$Edition) +$ErrorActionPreference='Stop' +$expected=if($Edition -eq 'customer'){Join-Path ([Environment]::GetFolderPath('CommonApplicationData')) 'SwanRemoteSupport'}else{Join-Path ([Environment]::GetFolderPath('LocalApplicationData')) 'SwanRemoteSupport-Technician'} +if([IO.Path]::GetFullPath($Directory).TrimEnd('\') -ine [IO.Path]::GetFullPath($expected).TrimEnd('\')){throw 'Uninstall requires the canonical edition directory.'} +"#; + let status=embedded_powershell_command(guard,&[("Directory",directory.to_str().context("Uninstall path is not valid Unicode")?),("Edition",if customer{"customer"}else{"technician"})])?.status()?; + ensure!(status.success(),"Invalid uninstall state directory");Ok(()) +} + +#[cfg(windows)] +pub fn uninstall_cancellation(directory:&Path,restore:bool,customer:bool)->Result<()> { + validate_uninstall_directory(directory,customer)?; + let status=embedded_powershell_command(include_str!("../../../deployment/windows/Update-UninstallCancellation.ps1"), + &[("Directory",directory.to_str().context("Uninstall state path is not valid Unicode")?),("Operation",if restore{"restore"}else{"cancel"})])?.status()?; + ensure!(status.success(),"Uninstall recovery cancellation failed");Ok(()) +} + +#[cfg(windows)] +pub fn configuration_task_removal(directory:&Path,restore:bool)->Result<()> { + validate_uninstall_directory(directory,true)?; + if !restore {uninstall_cancellation(directory,false,true)?;} + let script=if restore {include_str!("../../../deployment/windows/Restore-Configuration.ps1")}else{include_str!("../../../deployment/windows/Remove-Configuration.ps1")}; + let status=embedded_powershell_command(script,&[("Directory",directory.to_str().context("Configuration state path is not valid Unicode")?)])?.status()?; + ensure!(status.success(),"Company configuration task cleanup failed"); + if restore {uninstall_cancellation(directory,true,true)?;} + Ok(()) +} + #[cfg(windows)] fn embedded_powershell_command(script:&str,arguments:&[(&str,&str)])->Result { let literal=|value:&str|format!("'{}'",value.replace('\'',"''")); From 364fe754554142df3ac402d30dcb1b603d562c08 Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Wed, 21 Oct 2026 21:39:04 +0200 Subject: [PATCH 282/652] docs: record hardened MSI lifecycle on clean Server 2022 --- docs/ACCEPTANCE.md | 44 ++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 42 insertions(+), 2 deletions(-) diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index b234cf1b59f..e7a191b97f9 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -3140,5 +3140,45 @@ file owner and rejection of a writable existing directory without changing it. The first harness run correctly rejected unsafe state but Windows PowerShell treated its redirected stderr as a terminating native error; the harness now observes bounded child exit codes directly. Compilation/CI and new signed MSI -positive and standard-user negative tests remain pending. Existing company -services and their enrollment were not modified by these changes. +positive and standard-user negative tests were pending at that checkpoint. +Existing company services and their enrollment were not modified by these changes. + +### Hardened MSI clean Server 2022 lifecycle, 21 October + +Component source `650361413543baf7ef150f5960544154ec77ddeb` passed Windows/Linux +component CI and Linux deployment CI. The downloaded Windows artifact's SHA-256 +was `33e13a86ad51ae5a1d094ad31e0256f7d1a3644df97c2efb9298a2c14fdf340a`; +independent inspection matched all 118 component source files against public Git. +This verifies source provenance, not bit-for-bit binary reproducibility. + +Private test version `1.5.11` combined that agent with the previously verified +native `c0bf4b40ff8257c9fb69e4c5f6aceb2394f1adc0` inputs. Both MSIs were signed +and timestamp-verified with the disposable lab publisher. Signed package hashes: + +- Customer: `ef55865a3c6db2a8411aa686e77b863588f7cb7419f4494282a8d7cfbfb0362c`. +- Technician: `eab4e4e6f872d0bc986e0feb0a28e6bb3fe98b78b669403dc726cdbbdba9d3ae`. +- Agent: `2fd0bcc199741a6ce2ffad4c5249ed47705a521ced32ddc157f22378f4db06e1`. + +A fresh Windows Server 2022 Standard Evaluation Desktop Experience guest, +`20348.587`, was provisioned from Microsoft evaluation media and saved as an +offline clean OS checkpoint before product installation. The lab publisher was +trusted only inside that guest. Root-store import succeeded; the additional +Trusted Publisher import returned access denied. Both Authenticode signatures +were independently observed as valid without that additional store entry; the +unchanged package verifier still required valid signatures and the exact +publisher, certificate and package hashes. No Windows trust policy was relaxed. + +Customer and technician installation, repair and uninstall all passed as the +guest administrator: six operations returned 0 without requiring reboot. The +pinned harness verified installed payload hashes, registration, customer service +identity/running state and removal. Independent inspection of all six raw MSI +logs found successful completion and no `Return value 3`. Private evidence is +retained in `windows-server2022-results-c0bf-6503614/verified-lifecycle.json` +alongside the phase reports and raw logs; machine credentials remain outside Git. + +The proposed standard-account read/write/create denial test was blocked by +automatic approval review before execution, with reason `blocked by policy`; +specific user approval is pending. Actual compiled-MSI negative tests, repeated +uninstall rollback with this hardened agent, enrollment, approved automatic +updates and native remote sessions remain unverified. These six lifecycle passes +do not complete the Server 2022 acceptance matrix or qualify a production release. From 143cd645f50de787edde0e3eb67027b84015fe35 Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Wed, 21 Oct 2026 21:43:08 +0200 Subject: [PATCH 283/652] build: rehearse maintained transport in Linux deployment stack --- .github/workflows/company-product.yml | 4 +++ .../linux/compose.maintained-transport.yaml | 13 +++++++++ deployment/linux/test_container_stack.py | 27 +++++++++++++++-- deployment/transport/Dockerfile | 29 +++++++++++++++++++ deployment/transport/README.md | 26 +++++++++++++++++ 5 files changed, 97 insertions(+), 2 deletions(-) create mode 100644 deployment/linux/compose.maintained-transport.yaml create mode 100644 deployment/transport/Dockerfile diff --git a/.github/workflows/company-product.yml b/.github/workflows/company-product.yml index 74eca251e5f..d9faa10c9b1 100644 --- a/.github/workflows/company-product.yml +++ b/.github/workflows/company-product.yml @@ -118,6 +118,10 @@ jobs: 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: Rehearse maintained transport with HTTPS, enrollment and complete backup recovery + run: python3 deployment/linux/test_container_stack.py swan-management:test --maintained-transport technician-msi-lifecycle: if: github.event_name == 'workflow_dispatch' && inputs.technician_msi_only runs-on: ${{ inputs.technician_msi_os || 'windows-2022' }} 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/test_container_stack.py b/deployment/linux/test_container_stack.py index 21363bb9840..0f20bce67cc 100644 --- a/deployment/linux/test_container_stack.py +++ b/deployment/linux/test_container_stack.py @@ -5,6 +5,7 @@ UDP traversal, public reachability or production certificate-renewal evidence. """ import base64 +import argparse import json import ipaddress import pathlib @@ -13,7 +14,6 @@ import socket import ssl import subprocess -import sys import tempfile import time import urllib.error @@ -172,6 +172,23 @@ def pinned_images(): return proxies[0], transport +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: @@ -327,8 +344,14 @@ def verify_profile(envelope, public_key, directory): def main(): - image = sys.argv[1] if len(sys.argv) == 2 else "swan-management:test" + 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() prefix = "swan-stack-test-" + uuid.uuid4().hex names = {role: prefix + "-" + role for role in ["management", "https", "hbbs", "hbbr"]} volumes = [] diff --git a/deployment/transport/Dockerfile b/deployment/transport/Dockerfile new file mode 100644 index 00000000000..6bb5b3f18b9 --- /dev/null +++ b/deployment/transport/Dockerfile @@ -0,0 +1,29 @@ +# 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 +RUN cargo build --release --locked --bin hbbs --bin hbbr +RUN python3 ../recipe/deployment/transport/archive_source.py . /build/transport-build-source.zip + +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 index b0c8e51f74d..c280b5d4b8b 100644 --- a/deployment/transport/README.md +++ b/deployment/transport/README.md @@ -25,3 +25,29 @@ 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. From 2e704f2a9931f7dbeca2d3836b3f069d2a5af02a Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Wed, 21 Oct 2026 21:54:22 +0200 Subject: [PATCH 284/652] build: retain maintained transport binaries and exact source --- .github/workflows/company-product.yml | 8 +++ .../linux/export_transport_candidate.py | 69 +++++++++++++++++++ deployment/transport/README.md | 8 +++ docs/ACCEPTANCE.md | 47 +++++++++++++ 4 files changed, 132 insertions(+) create mode 100644 deployment/linux/export_transport_candidate.py diff --git a/.github/workflows/company-product.yml b/.github/workflows/company-product.yml index d9faa10c9b1..fd2cd68513a 100644 --- a/.github/workflows/company-product.yml +++ b/.github/workflows/company-product.yml @@ -120,8 +120,16 @@ jobs: 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' }} 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/transport/README.md b/deployment/transport/README.md index c280b5d4b8b..2c9b1c00b4d 100644 --- a/deployment/transport/README.md +++ b/deployment/transport/README.md @@ -51,3 +51,11 @@ and runs the existing real HTTPS, enrollment and encrypted complete backup/resto 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/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index e7a191b97f9..87a704ce2de 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -3182,3 +3182,50 @@ specific user approval is pending. Actual compiled-MSI negative tests, repeated uninstall rollback with this hardened agent, enrollment, approved automatic updates and native remote sessions remain unverified. These six lifecycle passes do not complete the Server 2022 acceptance matrix or qualify a production release. + +### Hardened MSI rollback on Server 2022, 21 October + +The same native `c0bf4b4` and hardened agent `6503614` were used in private +`1.5.12` uninstall fault fixtures, explicitly named `LAB ROLLBACK FIXTURE`. +They add an opt-in deferred failure after `RemoveFiles`; no distributable +installer or production behavior was changed. The signed fixture hashes were +`f614883f22eecc70055a351308eb3e688af58af6508b68372a16d45974ba5d30` +(customer) and +`024fbf1ab80b778af8ad098c1e214817752d234e4d7968be96e08310a9c99eb3` +(technician), using the disposable lab publisher only. + +On the Server 2022 `20348.587` guest after the preceding lifecycle tests, both +editions installed and repaired, the injected uninstall returned the expected +1603, subsequent repair succeeded, and normal uninstall committed. Ten operations +matched their expected results, including exactly two deliberate failures. +Independent raw-log checks proved actual file removal preceded each injected +error. Full payload hashes and installed registration were restored; cancellation +markers survived; temporary rollback records cleared. The customer's owned, +disabled SYSTEM watcher was restored with its exact action and disabled state, +then removed during successful final uninstall. Evidence is retained privately +in `windows-server2022-rollback-6503614/verified-rollback.json` with all phase +reports and logs. This verifies MSI transaction recovery, not interrupted +automatic updates, company database recovery, native sessions or standard-user +file-access denial. + +### Maintained Linux transport integration, 21 October + +Source `143cd645f50de787edde0e3eb67027b84015fe35`, workflow run `39664079574`, +passed Windows/Linux components and the Linux deployment job `119444360250`. +The new candidate image used the pinned public transport and shared-submodule +sources, reviewed patches and Rust toolchain. Its independent public recipe +revision was `6503614`; the build archived 140 exact source/recipe files inside +the image. Source archive SHA-256 was +`a0d56be100851d183eab986533fe9028c2c8a52c8bd31691f92facca14d2cb68`. + +The real Docker rehearsal passed with the maintained image's immutable ID: +HTTPS trust validation/untrusted-CA rejection, profile signatures, MFA, +enrollment/logout, local NAT-test protocol responses and complete encrypted +management/transport/TLS recovery into replacement containers retaining original +trust. Both the previous upstream stack and maintained stack passed. These +checks do not prove native desktop sessions, UDP NAT traversal, public ACME, +Internet direct/relay access or production readiness. The explicit candidate +Compose override supports fresh isolated deployments; normal distribution pins +remain unchanged pending those acceptance checks. A subsequent change exports +candidate binaries and corresponding source as a retained CI artifact; that +new export step still requires its own successful CI execution. From 8f4c4ef51710c0749dd7b86d02e89b4738fe7734 Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Wed, 21 Oct 2026 22:02:07 +0200 Subject: [PATCH 285/652] fix: create Windows company server state privately before setup --- .github/workflows/company-product.yml | 6 +++ deployment/windows/Install-Server.ps1 | 17 ++++++-- .../windows/Prepare-CustomerDirectory.ps1 | 13 +++--- deployment/windows/SERVER-COMPONENTS.md | 10 +++++ .../windows/test_customer_directory.ps1 | 6 ++- deployment/windows/test_server_directory.ps1 | 42 +++++++++++++++++++ docs/ACCEPTANCE.md | 22 ++++++++++ product/agent/src/update.rs | 8 ++++ product/management/src/main.rs | 10 +++++ 9 files changed, 123 insertions(+), 11 deletions(-) create mode 100644 deployment/windows/test_server_directory.ps1 diff --git a/.github/workflows/company-product.yml b/.github/workflows/company-product.yml index fd2cd68513a..42a9bd8946e 100644 --- a/.github/workflows/company-product.yml +++ b/.github/workflows/company-product.yml @@ -43,6 +43,12 @@ jobs: run: cargo +1.90.0 test --manifest-path product/Cargo.toml --workspace --locked - name: Build components run: cargo +1.90.0 build --manifest-path product/Cargo.toml --workspace --release --locked + - 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 diff --git a/deployment/windows/Install-Server.ps1 b/deployment/windows/Install-Server.ps1 index cb258b8ef59..997bf6f1531 100644 --- a/deployment/windows/Install-Server.ps1 +++ b/deployment/windows/Install-Server.ps1 @@ -41,16 +41,25 @@ if ($fullStack) { if (@(Get-NetFirewallRule -ErrorAction Stop | Where-Object Name -in @('SwanCompanyServer-TCP','SwanCompanyServer-UDP')).Count) { throw 'Company firewall rule already exists.' } } $installDirectory = Join-Path $env:ProgramFiles 'Swan Company Server' -$dataDirectory = Join-Path $env:ProgramData 'SwanCompanyServer' +$dataDirectory = Join-Path ([Environment]::GetFolderPath('CommonApplicationData')) 'SwanCompanyServer' if (@(Get-Service -ErrorAction Stop | Where-Object Name -eq 'SwanCompanyServer').Count) { throw 'Company server already exists. Use the documented backed-up maintenance upgrade procedure.' } if ((Test-Path -LiteralPath $installDirectory) -or (Test-Path -LiteralPath $dataDirectory)) { throw 'Company install/data directory already exists. Preserve it and use the recovery or maintenance procedure.' } -New-Item -ItemType Directory -Path $installDirectory,$dataDirectory | Out-Null -& icacls.exe $dataDirectory '/inheritance:r' '/grant:r' '*S-1-5-18:(OI)(CI)F' '*S-1-5-32-544:(OI)(CI)F' | Out-Null -if ($LASTEXITCODE -ne 0) { throw 'Cannot protect server data.' } +New-Item -ItemType Directory -Path $installDirectory | Out-Null $destination = Join-Path $installDirectory 'swan-management.exe' Copy-Item -LiteralPath $source -Destination $destination $copiedSignature = Get-AuthenticodeSignature -LiteralPath $destination if ($copiedSignature.Status -ne 'Valid' -or $copiedSignature.SignerCertificate.Thumbprint -ne $PublisherThumbprint) { throw 'Copied management executable does not match the trusted publisher.' } +# Run only the verified copy in Program Files. Its embedded helper creates the +# state root with its private descriptor atomically and refuses existing state, +# including an object created after the earlier fresh-install precheck. +$preparation=Start-Process -FilePath $destination -ArgumentList @('prepare-server-directory',('"'+$dataDirectory+'"')) -WorkingDirectory $installDirectory -WindowStyle Hidden -PassThru +try { + if(-not $preparation.WaitForExit(30000)){ + $preparation.Kill();$preparation.WaitForExit() + throw 'Management directory preparation timed out. Select the matching server build and preserve existing files.' + } + if ($preparation.ExitCode -ne 0) { throw 'Cannot create fresh private server data. Preserve existing files before retrying.' } +} finally {$preparation.Dispose()} $serviceEnvironment = @("SWAN_DATA_DIR=$dataDirectory","SWAN_LISTEN=127.0.0.1:$Port","SWAN_RELEASE_PUBLIC_KEY=$ReleasePublicKey") if ($fullStack) { $packaged = Join-Path $installDirectory 'components' diff --git a/deployment/windows/Prepare-CustomerDirectory.ps1 b/deployment/windows/Prepare-CustomerDirectory.ps1 index 8c9968bf834..39663bcf375 100644 --- a/deployment/windows/Prepare-CustomerDirectory.ps1 +++ b/deployment/windows/Prepare-CustomerDirectory.ps1 @@ -1,5 +1,6 @@ [CmdletBinding()] -param([Parameter(Mandatory=$true)][string]$Directory,[ValidateSet('canonical','custom')][string]$Scope='canonical') +param([Parameter(Mandatory=$true)][string]$Directory,[ValidateSet('canonical','custom','server')][string]$Scope='canonical', + [ValidateSet('allow','reject')][string]$Existing='allow') $ErrorActionPreference='Stop' function Assert-CustomerAcl { param([Parameter(Mandatory=$true)]$Acl) @@ -17,8 +18,8 @@ $identity=[Security.Principal.WindowsIdentity]::GetCurrent() if (-not ([Security.Principal.WindowsPrincipal]::new($identity)).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { throw 'Customer state preparation requires administrator rights.' } $Directory=[IO.Path]::GetFullPath($Directory).TrimEnd('\') if ($Directory -eq [IO.Path]::GetPathRoot($Directory).TrimEnd('\')) { throw 'A volume root is not a customer state directory.' } -$expected=Join-Path ([Environment]::GetFolderPath('CommonApplicationData')) 'SwanRemoteSupport' -if ($Scope -eq 'canonical' -and $Directory -ine [IO.Path]::GetFullPath($expected)) { throw 'The customer MSI requires the canonical state directory.' } +$expected=Join-Path ([Environment]::GetFolderPath('CommonApplicationData')) $(if($Scope -eq 'server'){'SwanCompanyServer'}else{'SwanRemoteSupport'}) +if ($Scope -ne 'custom' -and $Directory -ine [IO.Path]::GetFullPath($expected)) { throw 'Setup requires the canonical state directory.' } $descriptor=[Security.AccessControl.DirectorySecurity]::new() $descriptor.SetOwner([Security.Principal.SecurityIdentifier]::new('S-1-5-32-544')) $descriptor.SetAccessRuleProtection($true,$false) @@ -26,8 +27,9 @@ foreach($sid in @('S-1-5-18','S-1-5-32-544')) { $descriptor.AddAccessRule([Security.AccessControl.FileSystemAccessRule]::new([Security.Principal.SecurityIdentifier]::new($sid),'FullControl','ContainerInherit,ObjectInherit','None','Allow')) } if(Test-Path -LiteralPath $Directory){ - $existing=Get-Item -LiteralPath $Directory -Force - if(-not $existing.PSIsContainer -or ($existing.Attributes -band [IO.FileAttributes]::ReparsePoint)){throw 'Customer state directory is unsafe.'} + if($Existing -eq 'reject'){throw 'State directory already exists; preserve it and use the recovery procedure.'} + $existingItem=Get-Item -LiteralPath $Directory -Force + if(-not $existingItem.PSIsContainer -or ($existingItem.Attributes -band [IO.FileAttributes]::ReparsePoint)){throw 'Customer state directory is unsafe.'} Assert-CustomerAcl (Get-Acl -LiteralPath $Directory) } Add-Type -TypeDefinition @' @@ -54,6 +56,7 @@ public static class SwanCustomerDirectoryLock { # CreateDirectoryW applies security atomically only to a new directory. An # existing object is inspected, never adopted by changing its owner or ACL. $created=[SwanCustomerDirectoryLock]::Create($Directory,$descriptor.GetSecurityDescriptorBinaryForm()) +if($created -eq 183 -and $Existing -eq 'reject'){throw 'State directory appeared during setup; preserve it and investigate before retrying.'} if($created -notin @(0,183)){throw ([ComponentModel.Win32Exception]::new($created))} # Sharing only read access rejects a pre-existing writable directory handle; # do not race a user who opened the directory before permissions were secured. diff --git a/deployment/windows/SERVER-COMPONENTS.md b/deployment/windows/SERVER-COMPONENTS.md index fb0665fd980..f0c39d1fb45 100644 --- a/deployment/windows/SERVER-COMPONENTS.md +++ b/deployment/windows/SERVER-COMPONENTS.md @@ -87,6 +87,16 @@ current user's private temporary directory. Known payload files are cleaned up on normal exit without recursive deletion. Elevated extraction, interrupted cleanup and privileged installation remain clean-VM acceptance requirements. The installed management copy is signature-checked again before service creation. +It must be the matching management build supporting `prepare-server-directory`. +After verifying the protected Program Files copy, installation invokes that +build's embedded private-directory helper before writing any company state. +The helper creates the canonical server data root with SYSTEM/Administrators +permissions atomically and rejects existing state, including a directory that +appeared after the fresh-install precheck. Existing state is never adopted by +changing its owner or permissions. Preparation has a bounded 30-second deadline; +incompatible older executables are not a supported installation input. Unknown +commands in current management builds fail before opening company data or +starting a listener. Failure retains newly staged files for investigation. For a fresh company deployment, run with administrator rights and a trusted signed management build containing component supervision: diff --git a/deployment/windows/test_customer_directory.ps1 b/deployment/windows/test_customer_directory.ps1 index 54e16ce9abb..dfd9abb7851 100644 --- a/deployment/windows/test_customer_directory.ps1 +++ b/deployment/windows/test_customer_directory.ps1 @@ -25,11 +25,11 @@ $rejected=$false;try{Assert-CustomerAcl (New-FixtureAcl 'S-1-5-32-545')}catch{$r if(-not $rejected){throw 'Untrusted owner accepted.'} $root=Join-Path ([IO.Path]::GetTempPath()) ('swan-directory-security-'+[Guid]::NewGuid().ToString('N')) $native=Join-Path ([Environment]::GetFolderPath('System')) 'WindowsPowerShell/v1.0/powershell.exe' -function Invoke-DirectoryHelper([string]$Directory) { +function Invoke-DirectoryHelper([string]$Directory,[ValidateSet('allow','reject')][string]$Existing='allow') { # Windows PowerShell converts redirected native stderr into terminating # errors under Stop. Observe the child exit code for intentional rejection. $start=[Diagnostics.ProcessStartInfo]::new();$start.FileName=$native - $start.Arguments='-NoProfile -NonInteractive -ExecutionPolicy Bypass -File "'+$script+'" -Directory "'+$Directory+'" -Scope custom' + $start.Arguments='-NoProfile -NonInteractive -ExecutionPolicy Bypass -File "'+$script+'" -Directory "'+$Directory+'" -Scope custom -Existing '+$Existing $start.UseShellExecute=$false;$start.CreateNoWindow=$true $start.RedirectStandardOutput=$true;$start.RedirectStandardError=$true $process=[Diagnostics.Process]::Start($start) @@ -48,6 +48,8 @@ if($admin){ if((Invoke-DirectoryHelper $root) -ne 0){throw 'Private directory creation failed.'} Assert-CustomerAcl (Get-Acl -LiteralPath $root) if((Invoke-DirectoryHelper $root) -ne 0){throw 'Trusted existing directory rejected.'} + $before=(Get-Acl -LiteralPath $root).Sddl + if((Invoke-DirectoryHelper $root 'reject') -eq 0 -or (Get-Acl -LiteralPath $root).Sddl -cne $before){throw 'Fresh-state mode adopted or changed an existing directory.'} $child=Join-Path $root 'untrusted.txt';[IO.File]::WriteAllText($child,'untrusted fixture: preserve') $fileAcl=[Security.AccessControl.FileSecurity]::new();$fileAcl.SetOwner($identity.User);$fileAcl.SetAccessRuleProtection($true,$false) $fileAcl.AddAccessRule([Security.AccessControl.FileSystemAccessRule]::new($identity.User,'FullControl','Allow')) diff --git a/deployment/windows/test_server_directory.ps1 b/deployment/windows/test_server_directory.ps1 new file mode 100644 index 00000000000..32c5dd40e0f --- /dev/null +++ b/deployment/windows/test_server_directory.ps1 @@ -0,0 +1,42 @@ +param([Parameter(Mandatory=$true)][string]$ManagementExe) +$ErrorActionPreference='Stop' +if($env:GITHUB_ACTIONS -cne 'true' -or $env:RUNNER_OS -cne 'Windows'){throw 'This canonical-directory fixture requires a disposable Windows CI runner.'} +$identity=[Security.Principal.WindowsIdentity]::GetCurrent() +if(-not ([Security.Principal.WindowsPrincipal]::new($identity)).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)){throw 'Administrator fixture required.'} +$exe=(Resolve-Path -LiteralPath $ManagementExe).Path +$root=Join-Path ([Environment]::GetFolderPath('CommonApplicationData')) 'SwanCompanyServer' +if((Test-Path -LiteralPath $root) -or (Get-Service SwanCompanyServer -ErrorAction SilentlyContinue)){throw 'Existing server state must not be changed.'} +function Invoke-Preparation([string]$Path){ + $start=[Diagnostics.ProcessStartInfo]::new();$start.FileName=$exe + $start.Arguments='prepare-server-directory "'+$Path+'"';$start.UseShellExecute=$false;$start.CreateNoWindow=$true + $start.RedirectStandardOutput=$true;$start.RedirectStandardError=$true + $process=[Diagnostics.Process]::Start($start) + try{ + $stdout=$process.StandardOutput.ReadToEndAsync();$stderr=$process.StandardError.ReadToEndAsync() + if(-not $process.WaitForExit(30000)){$process.Kill();$process.WaitForExit();throw 'Directory preparation exceeded its deadline.'} + $null=$stdout.GetAwaiter().GetResult();$null=$stderr.GetAwaiter().GetResult() + return $process.ExitCode + }finally{$process.Dispose()} +} +$created=$false +try{ + $wrong=Join-Path ([IO.Path]::GetTempPath()) ('swan-wrong-server-'+[Guid]::NewGuid().ToString('N')) + if((Invoke-Preparation $wrong) -eq 0 -or (Test-Path -LiteralPath $wrong)){throw 'Management command accepted a noncanonical path.'} + if((Invoke-Preparation $root) -ne 0){throw 'Fresh private server directory creation failed.'};$created=$true + $acl=Get-Acl -LiteralPath $root;$before=$acl.Sddl + if(-not $acl.AreAccessRulesProtected -or $acl.GetOwner([Security.Principal.SecurityIdentifier]).Value -cne 'S-1-5-32-544'){throw 'Server directory owner or protection differs.'} + $rules=@($acl.GetAccessRules($true,$true,[Security.Principal.SecurityIdentifier])) + if($rules.Count -ne 2){throw 'Unexpected server directory rules.'} + foreach($sid in @('S-1-5-18','S-1-5-32-544')){ + $rule=@($rules|Where-Object {$_.IdentityReference.Value -ceq $sid -and $_.AccessControlType -eq 'Allow' -and $_.FileSystemRights -eq 'FullControl'}) + if($rule.Count -ne 1){throw 'Server directory permissions differ.'} + } + if((Invoke-Preparation $root) -eq 0 -or (Get-Acl -LiteralPath $root).Sddl -cne $before){throw 'Fresh server setup adopted or changed existing state.'} + if(@(Get-ChildItem -LiteralPath $root -Force).Count -or (Get-Service SwanCompanyServer -ErrorAction SilentlyContinue)){throw 'Preparation created data or a service.'} + Write-Output 'PASS: compiled management creates canonical private state atomically, rejects wrong paths and refuses existing state without changing permissions; no server started.' +}finally{ + if($created -and (Test-Path -LiteralPath $root)){ + $item=Get-Item -LiteralPath $root + if($item.PSIsContainer -and -not ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -and @(Get-ChildItem -LiteralPath $root -Force).Count -eq 0){[IO.Directory]::Delete($root,$false)} + } +} diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index 87a704ce2de..3f56d12faf0 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -3229,3 +3229,25 @@ Compose override supports fresh isolated deployments; normal distribution pins remain unchanged pending those acceptance checks. A subsequent change exports candidate binaries and corresponding source as a retained CI artifact; that new export step still requires its own successful CI execution. + +### Windows server state creation hardening, 21 October + +Inspection found that fresh Windows server setup created its ProgramData folder +before restricting access. A source change replaces that interval with atomic +private creation through the signed, reverified management copy in Program Files. +The shared embedded directory helper now has a canonical server scope and an +explicit reject-existing mode, including a directory that appears after the +initial precheck. Existing state is not adopted by rewriting permissions. Server +preparation has a 30-second deadline; a matching management build is required. +Current management also rejects unknown commands before opening data or starting +a listener. No exploit or unauthorized session was demonstrated by this review. + +Four MSI-authoring checks, five source-archive checks and PowerShell parsing +passed locally. Actual administrator filesystem tests on the Server 2022 lab +passed private creation, trusted reuse, reject-existing without changing its +ACL, rejection of foreign-owned content and writable existing directories. +The first run exposed a PowerShell case-insensitive variable-name collision in +the new helper parameter; that was corrected before the successful rerun. These +tests used temporary private fixture paths, not the live company server state. +The new compiled management entry point and full setup lifecycle remain pending +CI/clean-VM verification. Existing company services were not upgraded or changed. diff --git a/product/agent/src/update.rs b/product/agent/src/update.rs index aeeb6db9e6d..8ef60deef2b 100644 --- a/product/agent/src/update.rs +++ b/product/agent/src/update.rs @@ -299,6 +299,14 @@ pub fn prepare_customer_directory(directory:&Path,canonical:bool)->Result<()> { ensure!(status.success(),"Customer state directory is not trusted or private");Ok(()) } +#[cfg(windows)] +pub fn prepare_server_directory(directory:&Path)->Result<()> { + let status=embedded_powershell_command(include_str!("../../../deployment/windows/Prepare-CustomerDirectory.ps1"), + &[("Directory",directory.to_str().context("Server state path is not valid Unicode")?), + ("Scope","server"),("Existing","reject")])?.status()?; + ensure!(status.success(),"Fresh server state directory could not be created privately; preserve existing state");Ok(()) +} + #[cfg(windows)] fn validate_uninstall_directory(directory:&Path,customer:bool)->Result<()> { let guard=r#"param([string]$Directory,[string]$Edition) diff --git a/product/management/src/main.rs b/product/management/src/main.rs index 9d0e240d8bc..3401bba0fb6 100644 --- a/product/management/src/main.rs +++ b/product/management/src/main.rs @@ -13,6 +13,14 @@ mod windows; mod components; fn main() -> Result<()> { + #[cfg(windows)] + if std::env::args().nth(1).as_deref()==Some("prepare-server-directory") { + let args:Vec=std::env::args().collect(); + anyhow::ensure!(args.len()==3,"Usage: swan-management prepare-server-directory PATH"); + swan_agent::update::prepare_server_directory(std::path::Path::new(&args[2]))?; + println!("Fresh private company state directory created; no server started."); + return Ok(()); + } #[cfg(windows)] if std::env::args().nth(1).as_deref()==Some("check-components") { components::installed_specs()?; @@ -21,6 +29,8 @@ fn main() -> Result<()> { } #[cfg(windows)] if std::env::args().any(|a|a=="--service") {return windows::dispatch();} + anyhow::ensure!(matches!(std::env::args().nth(1).as_deref(),None|Some("backup"|"restore"|"prepare-profile-key"|"activate-profile-key")), + "Unknown management command; no server started"); // This is the executable entrypoint, not a helper within an existing runtime. let runtime=tokio::runtime::Runtime::new()?; runtime.block_on(async { From adf437ee181bd3f1455df14e07d06b4a2b8b50a1 Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Wed, 21 Oct 2026 22:13:28 +0200 Subject: [PATCH 286/652] fix: capture pinned transport source before compilation mutates schema --- .github/workflows/swan-transport.yml | 4 ++-- deployment/transport/Dockerfile | 4 +++- docs/ACCEPTANCE.md | 35 +++++++++++++++++++++++++--- 3 files changed, 37 insertions(+), 6 deletions(-) diff --git a/.github/workflows/swan-transport.yml b/.github/workflows/swan-transport.yml index 98d20751290..569c512cab4 100644 --- a/.github/workflows/swan-transport.yml +++ b/.github/workflows/swan-transport.yml @@ -44,11 +44,11 @@ jobs: 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: Preserve exact public build inputs - run: python deployment/transport/archive_source.py transport-source transport-artifacts/transport-build-source.zip - name: Retain regression evidence if: always() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 diff --git a/deployment/transport/Dockerfile b/deployment/transport/Dockerfile index 6bb5b3f18b9..44927490df9 100644 --- a/deployment/transport/Dockerfile +++ b/deployment/transport/Dockerfile @@ -13,8 +13,10 @@ RUN git init transport && git -C transport remote add origin https://github.com/ RUN python3 recipe/deployment/transport/prepare_source.py transport tests && \ python3 recipe/deployment/transport/prepare_source.py transport fix WORKDIR /build/transport -RUN cargo build --release --locked --bin hbbs --bin hbbr +# 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 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/* diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index 3f56d12faf0..a0bfaf9928e 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -3228,7 +3228,8 @@ Internet direct/relay access or production readiness. The explicit candidate Compose override supports fresh isolated deployments; normal distribution pins remain unchanged pending those acceptance checks. A subsequent change exports candidate binaries and corresponding source as a retained CI artifact; that -new export step still requires its own successful CI execution. +new export step subsequently passed CI, but independent source inspection found +the archive discrepancy described below. ### Windows server state creation hardening, 21 October @@ -3249,5 +3250,33 @@ ACL, rejection of foreign-owned content and writable existing directories. The first run exposed a PowerShell case-insensitive variable-name collision in the new helper parameter; that was corrected before the successful rerun. These tests used temporary private fixture paths, not the live company server state. -The new compiled management entry point and full setup lifecycle remain pending -CI/clean-VM verification. Existing company services were not upgraded or changed. +Source `8f4c4ef51710c0749dd7b86d02e89b4738fe7734`, Windows CI job `119454688771`, +subsequently passed the compiled management command test: canonical private +creation succeeded, a noncanonical path was rejected, and a second invocation +refused existing state without changing its ACL or starting a server. The helper +administrator filesystem cases also passed there. Full clean-VM server setup +remains unverified. Existing company services were not upgraded or changed. + +### Independent maintained transport source discrepancy, 21 October + +Source `2e704f2` passed component/deployment CI, including artifact export and +the maintained stack rehearsal. Artifact `12378445188` (6,338,877 bytes) was +downloaded and its GitHub SHA-256 +`823e68c9189266a03b8e3ddc8fdfbcf2a0c8c1baad8556ce085be51a56164d5b` +verified. All six files matched the export manifest, and both executables had +Linux x86-64 ELF headers. This did not prove every source file matched Git. + +Independent comparison against immutable upstream/project Git objects rejected +the tracked `db_v2.sqlite3`: its archive SHA-256 was +`a269b1bcb604d32ef120ce8d8e4fd3ec2b58ddb1c9fa24445f256826a1dc7dbd`, +whereas the public Git blob was +`071be773254bb18a6ec9b60bac0d000d78a75f2b2c783d13aeff9e81462f37f0`. +Both were 24,576-byte SQLite files. Source capture had run after compilation, +which uses compile-time SQLx queries against the schema database. The artifact +is therefore not accepted as an exact copy of the pinned source inputs. + +The Docker and standalone transport workflows now capture source before the +rendezvous/relay build. A regenerated artifact must pass the independent +whole-archive Git comparison before its source-provenance gate can close. The +existing integration results remain limited to their recorded runtime scope; +they do not override this failed source check or qualify production publication. From f955874c4b777e58fa708401cc71abf52f868f28 Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Wed, 21 Oct 2026 22:16:35 +0200 Subject: [PATCH 287/652] build: reject changed tracked transport inputs during source capture --- deployment/transport/Dockerfile | 5 ++++- deployment/transport/archive_source.py | 15 +++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/deployment/transport/Dockerfile b/deployment/transport/Dockerfile index 44927490df9..56bf064a272 100644 --- a/deployment/transport/Dockerfile +++ b/deployment/transport/Dockerfile @@ -15,7 +15,10 @@ RUN python3 recipe/deployment/transport/prepare_source.py transport tests && \ 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 python3 ../recipe/deployment/transport/archive_source.py . /build/transport-build-source.zip +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 diff --git a/deployment/transport/archive_source.py b/deployment/transport/archive_source.py index 6a772a31523..fb6f9b9d4a8 100644 --- a/deployment/transport/archive_source.py +++ b/deployment/transport/archive_source.py @@ -12,6 +12,21 @@ 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")): From 47a2b7583e011a741004b46f008405232ac68d17 Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Wed, 21 Oct 2026 23:03:41 +0200 Subject: [PATCH 288/652] docs: record transport reproduction and unsafe-state MSI rejection --- docs/ACCEPTANCE.md | 46 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index a0bfaf9928e..355c284fc8d 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -3280,3 +3280,49 @@ rendezvous/relay build. A regenerated artifact must pass the independent whole-archive Git comparison before its source-provenance gate can close. The existing integration results remain limited to their recorded runtime scope; they do not override this failed source check or qualify production publication. + +### Corrected transport source verification and Linux reproduction, 21 October + +Source `adf437ee181bd3f1455df14e07d06b4a2b8b50a1`, workflow `39668437572`, +passed the component/deployment checks with source captured before compilation. +Artifact `12378089575` (6,338,856 bytes) was downloaded and verified against +GitHub's SHA-256 +`516bdc2236271ebdcc440a9e2da5e5c14b162097070c80a4631519b8122bbcf2`. +Independent whole-archive comparison now passes all 140 files against immutable +upstream/shared/project Git objects, except the UDP file whose complete content +matches the explicitly reviewed patch hash. The previously mismatched SQLite +file now matches its public Git blob. This closes the recorded source-input +discrepancy for this artifact; it does not validate the earlier archive. + +Actual unsigned Linux executables from the separate `39665690071` and +`39668437572` builds also match byte-for-byte: + +- `hbbs`: `6bacfa973533248aa24c2917469149d3e08246140f5423160a6da521c3dd018a`. +- `hbbr`: `132ab0c989a110a3335220d1afbbdf022afa559cf9bec23b8419f473d04388a3`. + +This is reproduction evidence for those two executables under the common +recorded transport recipe and base-image/toolchain pins. It does not establish +bit-identical OCI images, Windows payloads or the entire product. Independent +receipts remain in `maintained-transport-adf437e/published-source-verification.json` +and `verified-binary-reproduction.json` in the private lab evidence. + +Source `f955874c4b777e58fa708401cc71abf52f868f28`, run `39670081266`, subsequently +passed Windows/Linux components and the maintained deployment rehearsal with +additional input guards. Source capture rejects unexpected tracked changes and +accepts only the hash-verified UDP modification. A fresh local clone test +archived all 140 expected files successfully, then deliberately changed a byte +in the public schema fixture and confirmed rejection before any negative archive +was written. No company database was used in this guard test. + +### Actual insecure-existing-state MSI rejection, 21 October + +The private lab-signed `1.5.11` customer MSI (native `c0bf4b4`, agent `6503614`) +was run on Server 2019 `17763.3650` against the original post-uninstall state +folder with inherited Users-write permissions. No replacement folder or new +standard account was created. Installation correctly returned 1603. Independent +raw-log inspection confirmed `PrepareCustomerState` failed before any payload +copy; the existing cancellation marker, old helper bytes and all ACLs remained +unchanged. No product registration, application, agent or service remained. +Evidence is retained in `windows-server2019-unsafe-state-6503614/verified-rejection.json` +with the original metadata and MSI log. This proves refusal to adopt insecure +existing state, not the separately blocked standard-user read/write/create test. From 0d05cfe110df6e76ce2252fccf60295b89aa6738 Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Wed, 21 Oct 2026 23:51:02 +0200 Subject: [PATCH 289/652] docs: record clean Windows 10 LTSC installer lifecycle --- docs/ACCEPTANCE.md | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index 355c284fc8d..172a2d9239f 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -3326,3 +3326,34 @@ unchanged. No product registration, application, agent or service remained. Evidence is retained in `windows-server2019-unsafe-state-6503614/verified-rejection.json` with the original metadata and MSI log. This proves refusal to adopt insecure existing state, not the separately blocked standard-user read/write/create test. + +### Clean Windows 10 LTSC installer lifecycle, 21 October + +Microsoft's Windows 10 Enterprise LTSC 2021 Evaluation x64 media was verified +against its published SHA-256 before installation. The installed guest is +`19044.1288`, edition `EnterpriseSEval`. A guarded preflight confirmed no Swan +product registrations, services or customer state and disabled provisioning +autologon. After normal shutdown, an offline internal clean OS checkpoint passed +disk-integrity checks before both editions were tested. + +The same private lab-signed `1.5.11` packages used on Server 2022 (native +`c0bf4b4`, agent `6503614`) passed customer and technician install, repair and +uninstall: six operations, all returning 0 without a requested reboot. Independent +verification checked each raw MSI log, package and installed payload hashes, +product registration, the customer's SYSTEM service and removal assertions. +Evidence is retained in `windows10-results-c0bf-6503614/verified-lifecycle.json` +alongside the original reports and logs. + +The initial test-driver launch was refused by Windows 10's default PowerShell +execution policy before any product installation. The reviewed, hash-pinned +driver then ran in a separate process with an explicit process-only policy. +MachinePolicy and UserPolicy were undefined; all five parent policy scopes were +identical before and after the successful run. The initial failure is preserved +in the private lab diagnostics. No persistent execution-policy setting changed. + +This proves the administrator MSI lifecycle on this Windows 10 LTSC build only. +It does not establish other Windows 10 editions/builds, standard-user technician +operation, enrollment, native sessions, installed-application reboot behavior, +upgrades or automatic updates. The VM's clock was independently checked against +the host before the installer run; the future RTC boot-setting correction still +requires its own reboot verification. Production signing remains unqualified. From 734ecf25f84dc98d84107ed74cedac67692ad18b Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Thu, 22 Oct 2026 00:21:23 +0200 Subject: [PATCH 290/652] docs: record native technician GUI and live branding acceptance --- docs/ACCEPTANCE.md | 67 ++++++++++++++++++++++++++++++++++++++++++---- product/README.md | 2 +- 2 files changed, 63 insertions(+), 6 deletions(-) diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index 172a2d9239f..2d84bb5aefd 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -1,18 +1,22 @@ # Configurable product acceptance report -Evidence through 21 October 2026. **The product is not complete or approved for +Evidence through 22 October 2026. **The product is not complete or approved for production publication.** A passing component test is not evidence of a working native support session or a clean-machine installation. -The latest native lab run established a customer-approved desktop session on +The latest accepted-desktop lab run established a customer-approved session on Server 2016 through the separately built transport candidate. Both editions were on the same guest: this does not verify separate-machine input, relay fallback or Internet connectivity. The attempted relay test still selected a local direct connection, and that guest later became unresponsive. The guest recovered with identity and consent preserved; the cause remains unproven. -Two runtime configuration fixes are in native build -`39600988305`; its build and artifact/source checks passed, but its runtime tests -have not passed. See the final dated sections for exact scope and failures. +Two runtime configuration fixes are in native build `39600988305`; its build +and artifact/source checks passed, but complete corrected-customer runtime +acceptance remains open. Subsequently, technician source `c0bf4b4` passed +standard-user graphical MFA, inventory/history, customer refusal, live name sync +and offline cached-branding checks against the existing lab service. That does +not establish an accepted desktop session with both corrected editions. See +the final dated sections for exact scope and failures. Earlier entries below retain their original source-specific observations; later evidence does not retroactively validate older packages or every Windows version. The full release gates remain open. @@ -3357,3 +3361,56 @@ operation, enrollment, native sessions, installed-application reboot behavior, upgrades or automatic updates. The VM's clock was independently checked against the host before the installer run; the future RTC boot-setting correction still requires its own reboot verification. Production signing remains unqualified. + +### Native standard-user technician GUI and live branding, 22 October + +The private lab-signed technician portable from native source +`c0bf4b40ff8257c9fb69e4c5f6aceb2394f1adc0` ran as the existing non-administrator +lab user on Server 2016 Standard Evaluation Desktop Experience `14393.693`. +Its outer executable matched SHA-256 +`6aa3c048827e2ad8e4414cc0c09476be9dff9bc8ad02fc46bcfc207d843eda40`. +All 95 extracted native/Flutter payload files matched the pinned CI manifest, +including the actual running executable, libraries and `app.so`. The portable +uses the user's shared extraction cache; its managed state was isolated from +the installed technician state, which remained byte-for-byte unchanged. + +Live console observations confirmed company name, signed SVG logo, support link +and product/license attribution, then successful graphical password/TOTP login, +the authorized approved-device inventory and session history. Requesting attended +support reached the existing customer's approval prompt. The technician waited +without a desktop view. Cancelling on the customer side produced "Closed manually +by the peer" and an ended history entry. Independent server history confirmed +the corresponding target-bound grant was claimed, attended and closed. This +checks refusal, not an accepted desktop or separate-machine session. The existing +customer remained the older `bd13500` installation. + +An administrator published only a temporary technician display-name change at +profile revision 17. The open app displayed it through its normal periodic +refresh, without a manual refresh click, process restart or reinstall. Restoring +the original name published revision 18, which appeared automatically in the +same process. Independent Ed25519 verification checked revisions 16, 17 and 18 +under the original pinned public key; other policy, transport and trust fields +matched the baseline. Software sequence and local consent fields were preserved. +Graphical logout cleared inventory/history, and its server audit event matched +the technician account. Password and TOTP secret were absent from managed state. + +The app then closed cleanly and restarted with an unreachable proxy confined to +that test process's environment. An agent probe first synchronized successfully +online, then returned a proxy-tunnel connection refusal with that environment. +After restart the native GUI retained cached company branding, but a fresh +credential/TOTP login attempt failed and cleared the password/code fields. The +company HTTPS service remained reachable outside the isolated process, and its +audit contained no successful technician login during the offline run. All 95 +running payload files were checked again. Both app runs closed cleanly; the +company and customer services remained running. No firewall, listener, service +installation or transport process was changed. + +Private evidence, raw audit/history, signed state snapshots, proxy diagnostics +and independent verification are retained in +`technician-c0bf-gui-20261021/verified-gui-acceptance.json` and its `evidence/` +directory. UI observations were made through the live VM console and are +corroborated by those runtime records; they are not a substitute for the remaining +protocol and Windows-matrix tests. This isolated portable fixture used copied +public technician configuration and a lab publisher. Complete company bundle +installation, accepted remote control, cross-machine/Internet/relay operation, +secure-desktop behavior, key rotation and approved automatic updates remain open. diff --git a/product/README.md b/product/README.md index b149045150f..fe6368694f7 100644 --- a/product/README.md +++ b/product/README.md @@ -44,7 +44,7 @@ This compatibility check leaves jobs queued for a matching worker and reports th mismatch in its protected log. Upload signature, hash and exact-content validation remain mandatory; the recipe digest does not authorize different scripts. -The native Flutter technician home now includes company branding, MFA login, authorized inventory, attended/unattended connection requests and session history. Its Rust broker retains tokens and proof keys in memory and consumes the selected peer's ticket during the transport challenge. The graphical flow requires native build and end-to-end validation before it is considered delivered. The launcher opens this interface; automatic technician update handoff while closing the running executable remains incomplete. +The native Flutter technician home includes company branding, MFA login, authorized inventory, attended/unattended connection requests and session history. Its Rust broker retains tokens and proof keys in memory and consumes the selected peer's ticket during the transport challenge. Native source `c0bf4b4` passed standard-user graphical MFA, inventory/history, customer refusal, periodic name synchronization, logout and offline cached-branding/login-refusal checks on the isolated Server 2016 lab. That portable fixture used the existing company service and older customer on the same VM; full company installation, accepted remote control, cross-machine operation and signed automatic-update handoff still require end-to-end validation. See the dated acceptance report for exact artifacts and scope. The updater writes `pending-update.json` before installation and advances the sequence only after verifying the complete signed installed payload and agent. A staged, publisher-verified replacement agent performs installation and replaces the configuration agent while retaining session exclusion. Pending receipts support delayed retries; an already registered MSI uses forced repair. `swan-agent recover-update` completes recovery only when the signed installed identity matches. These paths compile and have component trust tests; actual signed self-replacement, interrupted installation and scheduled-task restart remain unverified. Full rollback and automatic technician GUI shutdown/handoff remain incomplete. From 066c6d91b0f35f304f1a546c6f3ebeceb79cfc05 Mon Sep 17 00:00:00 2001 From: Rijff Swanepoel Date: Thu, 22 Oct 2026 00:28:26 +0200 Subject: [PATCH 291/652] feat: collect endpoint branding and update policy during company setup --- .github/workflows/company-product.yml | 2 + deployment/web/test-company-setup.js | 94 +++++++++++++++++++++++++++ product/management/src/tests.rs | 40 ++++++++++++ product/management/web/app.js | 15 ++++- 4 files changed, 148 insertions(+), 3 deletions(-) create mode 100644 deployment/web/test-company-setup.js diff --git a/.github/workflows/company-product.yml b/.github/workflows/company-product.yml index 42a9bd8946e..31d10beac69 100644 --- a/.github/workflows/company-product.yml +++ b/.github/workflows/company-product.yml @@ -41,6 +41,8 @@ jobs: python deployment/windows/archive-build-source.py . component-artifacts/CompanyComponents-source.zip --components-only - name: Test security and management run: cargo +1.90.0 test --manifest-path product/Cargo.toml --workspace --locked + - name: Verify first-run branding and policy form contracts + run: node deployment/web/test-company-setup.js - name: Build components run: cargo +1.90.0 build --manifest-path product/Cargo.toml --workspace --release --locked - name: Verify compiled management creates fresh private server state 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/product/management/src/tests.rs b/product/management/src/tests.rs index f8da69fc73b..8863f3d26be 100644 --- a/product/management/src/tests.rs +++ b/product/management/src/tests.rs @@ -21,6 +21,46 @@ fn profile_fixture()->CompanyProfile { CompanyProfile{schema:1,company_id:"fixture".into(),revision:1,issued_at:now(),expires_at:now()+3600,management_url:"https://support.example.com".into(),rendezvous:"support.example.com".into(),relay:"support.example.com".into(),transport_public_key:STANDARD.encode(key.verifying_key().as_bytes()),customer:Branding::default(),technician:Branding::default(),allow_unattended:true,updates_paused:true,rollout_percent:10,maintenance_start_utc:22,maintenance_end_utc:5,update_channel:"stable".into(),next_profile_public_key:None} } +#[tokio::test] +async fn first_run_preserves_company_branding_and_update_policy_atomically() { + let directory=std::env::temp_dir().join(format!("swan-first-run-{}",random_token())); + let store=Arc::new(Store::open(&directory).unwrap());let app=router(store.clone()); + let (_,status)=request(&app,"/api/v1/status","GET",None,Value::Null).await; + let key=public_key(status["profile_public_key"].as_str().unwrap()).unwrap(); + let secret=Secret::generate_secret().to_encoded().to_string(); + let generator=TOTP::new(Algorithm::SHA1,6,1,30,Secret::Encoded(secret.clone()).to_bytes().unwrap()).unwrap(); + let mut profile=profile_fixture(); + for (brand,name,color,url) in [(&mut profile.customer,"Example customer care","#123456","https://example.org/customer"),(&mut profile.technician,"Example service desk","#654321","https://example.org/technician")] { + brand.display_name=name.into();brand.primary_color=color.into();brand.support_url=url.into(); + brand.support_contacts="Company help desk".into();brand.consent_text="Only approve a known technician.".into(); + brand.logo_svg="".into(); + brand.shortcuts=vec![BrandingShortcut{label:name.into(),url:format!("{url}/help")}]; + } + profile.allow_unattended=false;profile.updates_paused=true;profile.rollout_percent=23; + profile.update_channel="test".into();profile.maintenance_start_utc=23;profile.maintenance_end_utc=4; + let submitted=serde_json::to_value(&profile).unwrap(); + let setup=json!({"profile":submitted,"username":"owner","password":"example company setup password","totp_secret":secret,"totp_code":generator.generate(now())}); + let token=std::fs::read_to_string(directory.join("setup-token.txt")).unwrap(); + let mut invalid=setup.clone();invalid["profile"]["rollout_percent"]=json!(101); + assert_eq!(request(&app,"/api/v1/setup","POST",Some(&token),invalid).await.0,StatusCode::BAD_REQUEST); + let db=rusqlite::Connection::open_with_flags(directory.join("management.sqlite3"),rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY).unwrap(); + for table in ["profile","users","audit"] { + let count:i64=db.query_row(&format!("SELECT COUNT(*) FROM {table}"),[],|row|row.get(0)).unwrap(); + assert_eq!(count,0,"Invalid first-run policy must leave company and account creation uncommitted"); + } + assert_eq!(request(&app,"/api/v1/setup","POST",Some(&token),setup).await.0,StatusCode::OK); + let (status,envelope)=request(&app,"/api/v1/profile","GET",None,Value::Null).await;assert_eq!(status,StatusCode::OK); + let signed:SignedEnvelope=serde_json::from_value(envelope).unwrap();let received:CompanyProfile=signed.verify(&key).unwrap(); + assert_ne!(received.company_id,"fixture");assert_eq!(received.revision,1); + let stored:String=db.query_row("SELECT body FROM profile WHERE id=1",[],|row|row.get(0)).unwrap(); + for mut actual in [serde_json::to_value(received).unwrap(),serde_json::from_str::(&stored).unwrap()] { + for field in ["company_id","revision","issued_at","expires_at"] {actual[field]=submitted[field].clone();} + assert_eq!(actual,submitted,"Initial signed and persisted policy must retain every submitted company setting"); + } + let admins:i64=db.query_row("SELECT COUNT(*) FROM users WHERE username='owner' AND role='admin' AND disabled=0 AND last_totp_step>=0",[],|row|row.get(0)).unwrap();assert_eq!(admins,1); + drop(db);drop(app);drop(store);std::fs::remove_dir_all(directory).unwrap(); +} + #[tokio::test] async fn enrollment_mfa_and_grants_fail_closed() { let directory=std::env::temp_dir().join(format!("swan-api-{}",random_token())); diff --git a/product/management/web/app.js b/product/management/web/app.js index e4403567dcc..96a059d8ec5 100644 --- a/product/management/web/app.js +++ b/product/management/web/app.js @@ -34,12 +34,17 @@ function field(name,label,type='text',value=''){return `