Add self-hosted RustDesk bootstrap management
This commit is contained in:
@@ -0,0 +1,356 @@
|
||||
[CmdletBinding(SupportsShouldProcess)]
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[ValidatePattern('^[A-Za-z0-9][A-Za-z0-9.-]*$')]
|
||||
[string]$ServerAddress,
|
||||
|
||||
[Parameter(Mandatory)]
|
||||
[ValidatePattern('^[A-Za-z0-9+/=]+$')]
|
||||
[string]$ServerPublicKey,
|
||||
|
||||
[securestring]$AccessPassword,
|
||||
[string]$InstallRoot = "$env:ProgramFiles\RustDesk",
|
||||
[string]$StateRoot = "$env:ProgramData\SGU\RustDesk\Client",
|
||||
[string]$ClientVersion = '1.4.9',
|
||||
[uri]$DownloadUri = 'https://github.com/rustdesk/rustdesk/releases/download/1.4.9/rustdesk-1.4.9-x86_64.msi',
|
||||
[ValidatePattern('^[A-Fa-f0-9]{64}$')]
|
||||
[string]$ExpectedSha256 = 'C87D2F4CEF2A5ACD6003B6507DCFBF5D5168A256DB082CD90B54D35193224AAA'
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$ProgressPreference = 'SilentlyContinue'
|
||||
$downloadRoot = Join-Path $env:ProgramData 'SGU\RustDesk\Downloads'
|
||||
$installerPath = Join-Path $downloadRoot "rustdesk-$ClientVersion-x86_64.msi"
|
||||
$installerLogPath = Join-Path $downloadRoot "rustdesk-$ClientVersion-install.log"
|
||||
$secretPath = Join-Path $StateRoot 'access.secret'
|
||||
$devicePath = Join-Path $StateRoot 'device.json'
|
||||
|
||||
function Assert-Administrator {
|
||||
$identity = [Security.Principal.WindowsIdentity]::GetCurrent()
|
||||
$principal = [Security.Principal.WindowsPrincipal]::new($identity)
|
||||
if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {
|
||||
throw 'Run this script from an elevated Windows PowerShell session.'
|
||||
}
|
||||
}
|
||||
|
||||
function Initialize-DataProtection {
|
||||
if (-not ('SguRustDeskDataProtection' -as [type])) {
|
||||
Add-Type -TypeDefinition @'
|
||||
using System;
|
||||
using System.ComponentModel;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
public static class SguRustDeskDataProtection {
|
||||
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
|
||||
private struct DataBlob { public int cbData; public IntPtr pbData; }
|
||||
|
||||
[DllImport("crypt32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
|
||||
private static extern bool CryptProtectData(ref DataBlob input, string description,
|
||||
IntPtr optionalEntropy, IntPtr reserved, IntPtr prompt, int flags, out DataBlob output);
|
||||
|
||||
[DllImport("crypt32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
|
||||
private static extern bool CryptUnprotectData(ref DataBlob input, IntPtr description,
|
||||
IntPtr optionalEntropy, IntPtr reserved, IntPtr prompt, int flags, out DataBlob output);
|
||||
|
||||
[DllImport("kernel32.dll", SetLastError = true)]
|
||||
private static extern IntPtr LocalFree(IntPtr memory);
|
||||
|
||||
private const int CryptProtectLocalMachine = 0x4;
|
||||
|
||||
private static DataBlob ToBlob(byte[] value) {
|
||||
var blob = new DataBlob { cbData = value.Length, pbData = IntPtr.Zero };
|
||||
if (value.Length > 0) {
|
||||
blob.pbData = Marshal.AllocHGlobal(value.Length);
|
||||
Marshal.Copy(value, 0, blob.pbData, value.Length);
|
||||
}
|
||||
return blob;
|
||||
}
|
||||
|
||||
private static byte[] FromBlob(DataBlob blob) {
|
||||
var value = new byte[blob.cbData];
|
||||
if (blob.cbData > 0) Marshal.Copy(blob.pbData, value, 0, blob.cbData);
|
||||
return value;
|
||||
}
|
||||
|
||||
public static byte[] Protect(byte[] value) {
|
||||
var input = ToBlob(value); var output = new DataBlob();
|
||||
try {
|
||||
if (!CryptProtectData(ref input, null, IntPtr.Zero, IntPtr.Zero, IntPtr.Zero,
|
||||
CryptProtectLocalMachine, out output)) {
|
||||
throw new Win32Exception(Marshal.GetLastWin32Error());
|
||||
}
|
||||
return FromBlob(output);
|
||||
} finally {
|
||||
if (input.pbData != IntPtr.Zero) Marshal.FreeHGlobal(input.pbData);
|
||||
if (output.pbData != IntPtr.Zero) LocalFree(output.pbData);
|
||||
}
|
||||
}
|
||||
|
||||
public static byte[] Unprotect(byte[] value) {
|
||||
var input = ToBlob(value); var output = new DataBlob();
|
||||
try {
|
||||
if (!CryptUnprotectData(ref input, IntPtr.Zero, IntPtr.Zero, IntPtr.Zero, IntPtr.Zero,
|
||||
0, out output)) {
|
||||
throw new Win32Exception(Marshal.GetLastWin32Error());
|
||||
}
|
||||
return FromBlob(output);
|
||||
} finally {
|
||||
if (input.pbData != IntPtr.Zero) Marshal.FreeHGlobal(input.pbData);
|
||||
if (output.pbData != IntPtr.Zero) LocalFree(output.pbData);
|
||||
}
|
||||
}
|
||||
}
|
||||
'@ -ErrorAction Stop
|
||||
}
|
||||
}
|
||||
|
||||
function Set-PrivateDirectoryAcl {
|
||||
param([Parameter(Mandatory)][string]$Path)
|
||||
|
||||
New-Item -ItemType Directory -Path $Path -Force | Out-Null
|
||||
$acl = New-Object Security.AccessControl.DirectorySecurity
|
||||
$acl.SetAccessRuleProtection($true, $false)
|
||||
$inheritance = [Security.AccessControl.InheritanceFlags]'ContainerInherit,ObjectInherit'
|
||||
$allow = [Security.AccessControl.AccessControlType]::Allow
|
||||
foreach ($sid in @('S-1-5-18', 'S-1-5-32-544')) {
|
||||
$acl.AddAccessRule([Security.AccessControl.FileSystemAccessRule]::new(
|
||||
[Security.Principal.SecurityIdentifier]::new($sid),
|
||||
[Security.AccessControl.FileSystemRights]::FullControl,
|
||||
$inheritance,
|
||||
[Security.AccessControl.PropagationFlags]::None,
|
||||
$allow))
|
||||
}
|
||||
Set-Acl -LiteralPath $Path -AclObject $acl
|
||||
}
|
||||
|
||||
function ConvertTo-PlainText {
|
||||
param([Parameter(Mandatory)][securestring]$SecureString)
|
||||
|
||||
$pointer = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($SecureString)
|
||||
try {
|
||||
return [Runtime.InteropServices.Marshal]::PtrToStringBSTR($pointer)
|
||||
}
|
||||
finally {
|
||||
[Runtime.InteropServices.Marshal]::ZeroFreeBSTR($pointer)
|
||||
}
|
||||
}
|
||||
|
||||
function New-RandomAccessPassword {
|
||||
$characters = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789!@#$%*+-_'.ToCharArray()
|
||||
$bytes = New-Object byte[] 24
|
||||
$rng = [Security.Cryptography.RandomNumberGenerator]::Create()
|
||||
try {
|
||||
$rng.GetBytes($bytes)
|
||||
}
|
||||
finally {
|
||||
$rng.Dispose()
|
||||
}
|
||||
$value = -join ($bytes | ForEach-Object { $characters[$_ % $characters.Length] })
|
||||
return (ConvertTo-SecureString -String $value -AsPlainText -Force)
|
||||
}
|
||||
|
||||
function Save-AccessPassword {
|
||||
param([Parameter(Mandatory)][securestring]$Password)
|
||||
|
||||
$plainText = ConvertTo-PlainText -SecureString $Password
|
||||
try {
|
||||
$cipherText = [SguRustDeskDataProtection]::Protect(
|
||||
[Text.Encoding]::UTF8.GetBytes($plainText))
|
||||
[IO.File]::WriteAllBytes($secretPath, $cipherText)
|
||||
}
|
||||
finally {
|
||||
$plainText = $null
|
||||
}
|
||||
}
|
||||
|
||||
function Get-SavedAccessPassword {
|
||||
if (-not (Test-Path -LiteralPath $secretPath -PathType Leaf)) {
|
||||
return $null
|
||||
}
|
||||
$plainText = [Text.Encoding]::UTF8.GetString(
|
||||
[SguRustDeskDataProtection]::Unprotect(
|
||||
[IO.File]::ReadAllBytes($secretPath)))
|
||||
try {
|
||||
return (ConvertTo-SecureString -String $plainText -AsPlainText -Force)
|
||||
}
|
||||
finally {
|
||||
$plainText = $null
|
||||
}
|
||||
}
|
||||
|
||||
function Assert-FileHash {
|
||||
param(
|
||||
[Parameter(Mandatory)][string]$Path,
|
||||
[Parameter(Mandatory)][string]$ExpectedHash
|
||||
)
|
||||
|
||||
$actualHash = (Get-FileHash -LiteralPath $Path -Algorithm SHA256).Hash
|
||||
if (-not $actualHash.Equals($ExpectedHash, [StringComparison]::OrdinalIgnoreCase)) {
|
||||
throw "SHA-256 verification failed for $Path."
|
||||
}
|
||||
}
|
||||
|
||||
function Test-TcpConnection {
|
||||
param([Parameter(Mandatory)][string]$HostName, [Parameter(Mandatory)][int]$Port)
|
||||
|
||||
$client = [Net.Sockets.TcpClient]::new()
|
||||
try {
|
||||
$connect = $client.BeginConnect($HostName, $Port, $null, $null)
|
||||
if (-not $connect.AsyncWaitHandle.WaitOne(5000)) {
|
||||
return $false
|
||||
}
|
||||
$client.EndConnect($connect)
|
||||
return $true
|
||||
}
|
||||
catch {
|
||||
return $false
|
||||
}
|
||||
finally {
|
||||
$client.Dispose()
|
||||
}
|
||||
}
|
||||
|
||||
Assert-Administrator
|
||||
Initialize-DataProtection
|
||||
if (-not $PSCmdlet.ShouldProcess($env:COMPUTERNAME, 'Install and configure the managed RustDesk client')) {
|
||||
return
|
||||
}
|
||||
|
||||
Set-PrivateDirectoryAcl -Path $StateRoot
|
||||
New-Item -ItemType Directory -Path $downloadRoot -Force | Out-Null
|
||||
$rustDeskExecutable = Join-Path $InstallRoot 'RustDesk.exe'
|
||||
$installedVersion = if (Test-Path -LiteralPath $rustDeskExecutable -PathType Leaf) {
|
||||
[string](Get-Item -LiteralPath $rustDeskExecutable).VersionInfo.ProductVersion
|
||||
}
|
||||
else {
|
||||
''
|
||||
}
|
||||
if (-not $installedVersion.StartsWith($ClientVersion, [StringComparison]::OrdinalIgnoreCase)) {
|
||||
if (-not (Test-Path -LiteralPath $installerPath -PathType Leaf) -or
|
||||
(Get-FileHash -LiteralPath $installerPath -Algorithm SHA256).Hash -ne $ExpectedSha256) {
|
||||
Invoke-WebRequest -Uri $DownloadUri -OutFile $installerPath -UseBasicParsing
|
||||
}
|
||||
Assert-FileHash -Path $installerPath -ExpectedHash $ExpectedSha256
|
||||
# The vendor's MSI is the supported path for managed, silent Windows
|
||||
# deployment. Unlike the GUI-oriented EXE it does not require an
|
||||
# interactive desktop, which matters for startup/bootstrap execution.
|
||||
$msiArguments = "/i `"$installerPath`" /qn /norestart " +
|
||||
"INSTALLFOLDER=`"$InstallRoot`" CREATESTARTMENUSHORTCUTS=`"N`" " +
|
||||
"CREATEDESKTOPSHORTCUTS=`"N`" INSTALLPRINTER=`"N`" /l*v `"$installerLogPath`""
|
||||
$installer = Start-Process -FilePath (Join-Path $env:WINDIR 'System32\msiexec.exe') `
|
||||
-ArgumentList $msiArguments -Wait -PassThru
|
||||
if ($installer.ExitCode -notin @(0, 3010)) {
|
||||
throw "RustDesk MSI installation failed with exit code $($installer.ExitCode). See $installerLogPath."
|
||||
}
|
||||
}
|
||||
|
||||
if (-not (Test-Path -LiteralPath $rustDeskExecutable -PathType Leaf)) {
|
||||
throw "RustDesk installation did not create $rustDeskExecutable."
|
||||
}
|
||||
|
||||
$rustDeskService = Get-Service -Name 'RustDesk' -ErrorAction SilentlyContinue
|
||||
if (-not $rustDeskService) {
|
||||
$serviceInstaller = Start-Process -FilePath $rustDeskExecutable -ArgumentList '--install-service' `
|
||||
-Wait -PassThru
|
||||
if ($serviceInstaller.ExitCode -ne 0) {
|
||||
throw "RustDesk service installation failed with exit code $($serviceInstaller.ExitCode)."
|
||||
}
|
||||
Start-Sleep -Seconds 2
|
||||
$rustDeskService = Get-Service -Name 'RustDesk' -ErrorAction SilentlyContinue
|
||||
}
|
||||
if (-not $rustDeskService) {
|
||||
throw 'RustDesk did not register its Windows service.'
|
||||
}
|
||||
|
||||
Set-Service -Name $rustDeskService.Name -StartupType Automatic
|
||||
if ($rustDeskService.Status -ne 'Stopped') {
|
||||
Stop-Service -Name $rustDeskService.Name -Force
|
||||
$rustDeskService.WaitForStatus('Stopped', (New-TimeSpan -Seconds 20))
|
||||
}
|
||||
|
||||
$rendezvousAddress = "$ServerAddress`:21116"
|
||||
$relayAddress = "$ServerAddress`:21117"
|
||||
$configuration = @"
|
||||
rendezvous_server = '$rendezvousAddress'
|
||||
nat_type = 1
|
||||
serial = 0
|
||||
|
||||
[options]
|
||||
custom-rendezvous-server = '$rendezvousAddress'
|
||||
relay-server = '$relayAddress'
|
||||
key = '$ServerPublicKey'
|
||||
"@
|
||||
$configurationPaths = @(
|
||||
(Join-Path $env:ProgramData 'RustDesk\config\RustDesk2.toml'),
|
||||
(Join-Path $env:WINDIR 'ServiceProfiles\LocalService\AppData\Roaming\RustDesk\config\RustDesk2.toml'),
|
||||
(Join-Path $env:WINDIR 'System32\config\systemprofile\AppData\Roaming\RustDesk\config\RustDesk2.toml'),
|
||||
(Join-Path $env:SystemDrive 'Users\Default\AppData\Roaming\RustDesk\config\RustDesk2.toml')
|
||||
)
|
||||
foreach ($configurationPath in $configurationPaths) {
|
||||
New-Item -ItemType Directory -Path (Split-Path $configurationPath -Parent) -Force | Out-Null
|
||||
[IO.File]::WriteAllText($configurationPath, $configuration, [Text.UTF8Encoding]::new($false))
|
||||
}
|
||||
|
||||
$existingPassword = Get-SavedAccessPassword
|
||||
if ($AccessPassword) {
|
||||
$managedPassword = $AccessPassword
|
||||
Save-AccessPassword -Password $managedPassword
|
||||
$passwordWasGenerated = $false
|
||||
}
|
||||
elseif ($existingPassword) {
|
||||
$managedPassword = $existingPassword
|
||||
$passwordWasGenerated = $false
|
||||
}
|
||||
else {
|
||||
$managedPassword = New-RandomAccessPassword
|
||||
Save-AccessPassword -Password $managedPassword
|
||||
$passwordWasGenerated = $true
|
||||
}
|
||||
|
||||
Start-Service -Name $rustDeskService.Name
|
||||
$rustDeskService = Get-Service -Name $rustDeskService.Name
|
||||
$rustDeskService.WaitForStatus('Running', (New-TimeSpan -Seconds 20))
|
||||
|
||||
$plainPassword = ConvertTo-PlainText -SecureString $managedPassword
|
||||
try {
|
||||
# RustDesk on Windows only reliably treats its CLI output path as a command
|
||||
# invocation when stdout is consumed. Without the pipeline it can attach
|
||||
# to the GUI instance and leave a non-interactive bootstrap waiting.
|
||||
$null = & $rustDeskExecutable --password $plainPassword | Out-String
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "RustDesk could not set the managed access password (exit code $LASTEXITCODE)."
|
||||
}
|
||||
}
|
||||
finally {
|
||||
$plainPassword = $null
|
||||
}
|
||||
|
||||
$rustDeskId = ((& $rustDeskExecutable --get-id | Out-String).Trim() -split "`r?`n" |
|
||||
Select-Object -Last 1).Trim()
|
||||
if ($rustDeskId -notmatch '^\d+$') {
|
||||
throw "RustDesk returned an invalid device ID: $rustDeskId"
|
||||
}
|
||||
if (-not (Test-TcpConnection -HostName $ServerAddress -Port 21116)) {
|
||||
throw "The RustDesk rendezvous server $rendezvousAddress is not reachable from this client."
|
||||
}
|
||||
|
||||
$device = [ordered]@{
|
||||
ComputerName = $env:COMPUTERNAME
|
||||
RustDeskId = $rustDeskId
|
||||
ServerAddress = $ServerAddress
|
||||
ServerPublicKeySha256 = ([Security.Cryptography.SHA256]::Create().ComputeHash(
|
||||
[Text.Encoding]::UTF8.GetBytes($ServerPublicKey)) | ForEach-Object ToString x2) -join ''
|
||||
ConfiguredAt = (Get-Date).ToString('o')
|
||||
}
|
||||
[IO.File]::WriteAllText($devicePath, ($device | ConvertTo-Json), [Text.UTF8Encoding]::new($false))
|
||||
|
||||
[pscustomobject]@{
|
||||
RustDeskId = $rustDeskId
|
||||
ServerAddress = $ServerAddress
|
||||
ServiceName = $rustDeskService.Name
|
||||
ServiceStatus = (Get-Service -Name $rustDeskService.Name).Status.ToString()
|
||||
RendezvousReachable = $true
|
||||
AccessPassword = $managedPassword
|
||||
AccessPasswordWasGenerated = $passwordWasGenerated
|
||||
DevicePath = $devicePath
|
||||
}
|
||||
Reference in New Issue
Block a user