<# .SYNOPSIS Deploy and configure RustDesk on a Windows endpoint, using RustDesk's own supported install path. Intended to be run by an RMM, GPO or by hand. .DESCRIPTION This is the supported install path for our fleet. The NSIS wrapper that previously repackaged RustDesk was retired in bj-support#9. RustDesk documents three Windows deployment routes: the official EXE with --silent-install, the official MSI with msiexec, or a script driven by an RMM. Repackaging the signed binary inside a third-party installer is not among them -- it carries its own AV/EDR reputation, obscures the publisher chain, and has to be re-validated on every upstream release. Sequence, and why each step is here: 1. Download the official EXE (pinned version, hash-checked if provided). 2. --silent-install, waiting for the PROCESS to exit. 3. Wait for the executable AND the service to actually be Running. Process exit does not mean the service exists yet, and everything after this point is IPC into that service. 4. Write RustDesk2.toml to the LocalService profile and to every real user profile. This is the technique RustDesk uses in its own Tactical RMM integration. The service reads the LocalService copy; the GUI reads the per-user copy, so a stale per-user file shows up as "key mismatch" for an interactive operator even when the service is fine. 4b. Restart the service so it re-reads the config, and VERIFY it reached Running rather than trusting the exit code. 5. Optionally set the permanent password (-DevicePassword). 6. Read the RustDesk ID back. .NOTES Passwords: this script accepts an ALREADY-DERIVED, single-machine password. It never accepts or embeds a fleet-wide derivation secret -- anything shipped to an endpoint should be assumed readable. Derive with itsm/scripts/show_rustdesk_device_password.py on the admin side. A password passed on the command line is visible in the endpoint's process list while this runs, and RMM tools commonly log the command they executed. That exposes ONE machine's password, not the fleet secret. Prefer -DevicePasswordFile where your RMM supports file delivery. .EXAMPLE .\Deploy-RustDesk.ps1 -Verbose .EXAMPLE .\Deploy-RustDesk.ps1 -DevicePassword 'abc123!D2' -ExpectedSha256 'd67c...' #> [CmdletBinding()] param( # RustDesk version to install. Keep in step with rustdesk.version in # config/branding.yml so the fleet does not drift. [string] $Version = '1.4.9', [string] $IdServer = 'rustdesk.bjbusiness.ca', [string] $RelayServer = 'rustdesk.bjbusiness.ca', [string] $ApiServer = 'https://rustdesk.bjbusiness.ca', # Server public key. Not a secret -- every client embeds it -- but it MUST # match the running hbbs or every connection fails with "key mismatch". # Source of truth is the server: docker logs itsm-rustdesk-hbbs | grep Key: [Parameter(Mandatory)] [string] $PublicKey, # Already-derived permanent password for THIS machine. Optional: without # it the endpoint keeps RustDesk's own random password. [string] $DevicePassword, # Safer alternative to -DevicePassword: a file containing the password, # deleted after use. Keeps it out of the process list. [string] $DevicePasswordFile, # Optional integrity check on the downloaded installer. [string] $ExpectedSha256, [string] $DownloadUrl, [int] $ServiceTimeoutSeconds = 120 ) $ErrorActionPreference = 'Stop' Set-StrictMode -Version Latest function Write-Step { param([string]$Message) Write-Host "==> $Message" } # --------------------------------------------------------------------------- # Preconditions # --------------------------------------------------------------------------- $identity = [Security.Principal.WindowsIdentity]::GetCurrent() $principal = New-Object Security.Principal.WindowsPrincipal($identity) if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { throw "Administrator rights are required: --silent-install and --password both need them." } if ($DevicePasswordFile) { if (-not (Test-Path $DevicePasswordFile)) { throw "DevicePasswordFile not found: $DevicePasswordFile" } $DevicePassword = (Get-Content $DevicePasswordFile -Raw).Trim() Remove-Item $DevicePasswordFile -Force -ErrorAction SilentlyContinue } if (-not $DownloadUrl) { $DownloadUrl = "https://github.com/rustdesk/rustdesk/releases/download/$Version/rustdesk-$Version-x86_64.exe" } $installDir = Join-Path $env:ProgramFiles 'RustDesk' $exe = Join-Path $installDir 'rustdesk.exe' # --------------------------------------------------------------------------- # 1. Download # --------------------------------------------------------------------------- Write-Step "Downloading RustDesk $Version" $installer = Join-Path $env:TEMP "rustdesk-$Version-x86_64.exe" Remove-Item $installer -Force -ErrorAction SilentlyContinue Invoke-WebRequest -Uri $DownloadUrl -OutFile $installer -UseBasicParsing $sizeMb = [math]::Round((Get-Item $installer).Length / 1MB, 2) Write-Host " downloaded $sizeMb MB" if ($sizeMb -lt 10) { throw "Downloaded file is only $sizeMb MB - expected ~23 MB." } if ($ExpectedSha256) { $actual = (Get-FileHash $installer -Algorithm SHA256).Hash.ToLower() if ($actual -ne $ExpectedSha256.ToLower()) { Remove-Item $installer -Force -ErrorAction SilentlyContinue throw "SHA256 mismatch. expected=$ExpectedSha256 actual=$actual" } Write-Host " sha256 verified" } # --------------------------------------------------------------------------- # 2 + 3. Install, then wait for the SERVICE - not just the process # --------------------------------------------------------------------------- Write-Step "Installing (--silent-install)" # Deliberately NOT -Wait. # # --silent-install spawns the long-lived RustDesk service and tray processes. # Start-Process -Wait blocks until the whole process tree is gone, so it never # returns -- it looks exactly like a hung installer. (Observed: 450s and still # "installing", while the service had in fact been Running for minutes.) # # Completion is established by the service check below instead, which is the # condition we actually care about. RustDesk's own deployment script takes the # same shape: fire the install, then poll for the service. Start-Process -FilePath $installer -ArgumentList '--silent-install' | Out-Null Write-Step "Waiting for the RustDesk service" $deadline = (Get-Date).AddSeconds($ServiceTimeoutSeconds) do { Start-Sleep -Seconds 3 $svc = Get-Service -Name 'RustDesk' -ErrorAction SilentlyContinue $ready = (Test-Path $exe) -and $svc -and $svc.Status -eq 'Running' } until ($ready -or (Get-Date) -gt $deadline) if (-not $ready) { throw ("RustDesk did not become ready within ${ServiceTimeoutSeconds}s " + "(exe present: $(Test-Path $exe); service: $(if ($svc) { $svc.Status } else { 'absent' })). " + "Everything after this point is IPC into that service, so continuing would fail silently.") } Write-Host " service is Running" # --------------------------------------------------------------------------- # 4. Configuration # --------------------------------------------------------------------------- $configBody = @" rendezvous_server = '$IdServer:21116' nat_type = 1 serial = 0 [options] custom-rendezvous-server = '$IdServer' relay-server = '$RelayServer' api-server = '$ApiServer' key = '$PublicKey' direct-server = 'Y' "@ function Write-RustDeskConfig { param([string] $ProfileRoot) $dir = Join-Path $ProfileRoot 'AppData\Roaming\RustDesk\config' try { New-Item -ItemType Directory -Force -Path $dir | Out-Null # Replace, never merge: a surviving key line is a stale server key. Set-Content -Path (Join-Path $dir 'RustDesk2.toml') -Value $configBody -Encoding ASCII -Force Write-Host " wrote $dir\RustDesk2.toml" return $true } catch { Write-Warning " could not write $dir : $_" return $false } } Write-Step "Writing configuration" # The service account. This is the copy that actually enrols with the ID server. [void](Write-RustDeskConfig 'C:\Windows\ServiceProfiles\LocalService') # Every real user profile. The interactive GUI reads the per-user copy, so # skipping these leaves an operator staring at "key mismatch" even though the # service is connected. System SIDs are excluded deliberately. $profileList = 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\ProfileList' Get-ChildItem $profileList -ErrorAction SilentlyContinue | ForEach-Object { $sid = $_.PSChildName if ($sid -in @('S-1-5-18','S-1-5-19','S-1-5-20')) { return } $path = (Get-ItemProperty $_.PSPath -ErrorAction SilentlyContinue).ProfileImagePath if ($path -and (Test-Path $path)) { [void](Write-RustDeskConfig $path) } } # 4b. Restart so the service re-reads it, and confirm it came back. Write-Step "Restarting the service" Restart-Service -Name 'RustDesk' -Force -ErrorAction SilentlyContinue $deadline = (Get-Date).AddSeconds(60) do { Start-Sleep -Seconds 2 $svc = Get-Service -Name 'RustDesk' -ErrorAction SilentlyContinue } until (($svc -and $svc.Status -eq 'Running') -or (Get-Date) -gt $deadline) if (-not $svc -or $svc.Status -ne 'Running') { throw "RustDesk service did not return to Running after the config change." } Write-Host " service is Running" # --------------------------------------------------------------------------- # 5. Permanent password # --------------------------------------------------------------------------- if ($DevicePassword) { Write-Step "Setting the permanent password" # Call operator, not Start-Process: we need this process's own output, and # -PassThru would return a process object rather than what it printed. $out = & $exe --password $DevicePassword 2>&1 | Out-String if ($LASTEXITCODE -ne 0) { Write-Warning " --password exit code $LASTEXITCODE : $($out.Trim())" } else { Write-Host " set" } $DevicePassword = $null Remove-Variable out -ErrorAction SilentlyContinue } else { Write-Step "No -DevicePassword supplied; leaving RustDesk's own random password in place" } # --------------------------------------------------------------------------- # 6. Report the ID # --------------------------------------------------------------------------- Write-Step "Reading the RustDesk ID" # rustdesk.exe is a GUI-subsystem binary, so its output is easily lost. RustDesk's # own FAQ recommends `| Out-String` in PowerShell (or `| more` in cmd) for exactly # this. Poll: the ID is not available the instant the service starts. $rustdeskId = '' $deadline = (Get-Date).AddSeconds(60) do { $rustdeskId = (& $exe --get-id 2>$null | Out-String).Trim() if ($rustdeskId) { break } Start-Sleep -Seconds 3 } until ((Get-Date) -gt $deadline) if ($rustdeskId) { Write-Host " RustDesk ID: $rustdeskId" } else { Write-Warning (" ID not reported. Resolve it server-side instead: the rustdesk-api " + "'device' table maps hostname -> rustdesk_id.") } # Correlation marker for the RMM. SetRegView-equivalent: PowerShell here is # 64-bit, so this lands in the native view rather than WOW6432Node. New-Item -Path 'HKLM:\SOFTWARE\BJSupport' -Force | Out-Null Set-ItemProperty -Path 'HKLM:\SOFTWARE\BJSupport' -Name 'Hostname' -Value $env:COMPUTERNAME Set-ItemProperty -Path 'HKLM:\SOFTWARE\BJSupport' -Name 'InstalledVersion' -Value $Version Set-ItemProperty -Path 'HKLM:\SOFTWARE\BJSupport' -Name 'ConfiguredAt' -Value (Get-Date -Format o) if ($rustdeskId) { Set-ItemProperty -Path 'HKLM:\SOFTWARE\BJSupport' -Name 'RustDeskId' -Value $rustdeskId } Remove-Item $installer -Force -ErrorAction SilentlyContinue Write-Host "" Write-Step "Done" Write-Host " host : $env:COMPUTERNAME" Write-Host " version : $Version" Write-Host " id : $(if ($rustdeskId) { $rustdeskId } else { '(resolve server-side)' })"