Add self-hosted RustDesk bootstrap management

This commit is contained in:
2026-09-07 17:29:25 -06:00
parent b3ec649199
commit 675db6bc1e
14 changed files with 1068 additions and 8 deletions
+1
View File
@@ -51,6 +51,7 @@ Operational documentation:
- [Windows domain join and remote-access onboarding](docs/windows-client-onboarding.md)
- [Required Credential Provider client enrollment](docs/client-enrollment.md)
- [Linux client enrollment with realmd and SSSD](docs/linux-client-enrollment.md)
- [Self-hosted RustDesk server and managed Windows remote access](docs/rustdesk-operations.md)
- [Domain monitoring, usage reports, and six-month retention](docs/monitoring.md)
- [Decision: do not persist password verifiers in Redis](docs/decisions/0001-no-password-cache.md)
+11
View File
@@ -55,6 +55,8 @@ El proceso crea o configura de forma idempotente:
- RDP con NLA, WinRM/PowerShell Remoting y reglas administrativas limitadas a
la subred privada indicada, incluso si Windows tarda en reconocer el perfil
Domain después de la promoción;
- servidor RustDesk OSS autoalojado (`hbbs` y `hbbr`) y su cliente administrado
en el propio DC, con puertos de administración limitados a la subred privada;
- pantalla, suspensión e hibernación en Nunca.
En un servidor con dos NIC, el bootstrap desactiva el registro DNS de la NIC de
@@ -111,6 +113,9 @@ equipos. La contraseña existe sólo en memoria. El bootstrap:
8. sólo entonces ejecuta `Add-Computer` dentro de `OU=Laboratorio` y reinicia;
9. al arrancar, activa RDP/NLA, WinRM y las reglas Domain, y vuelve a validar el
enrolamiento.
10. instala RustDesk desde el MSI oficial comprobado, lo apunta al servidor
`rustdesk.lci.lasalle.mx` y registra el ID del dispositivo en el inventario
protegido del DC.
Para elegir adaptador o nombre del equipo explícitamente:
@@ -126,6 +131,12 @@ La IP del argumento es siempre la IP fija **actual del servidor**, no una IP que
queda compilada en el Credential Provider. El proveedor usa después el nombre
DNS `sgu-auth.lci.lasalle.mx`, que el bootstrap del servidor actualiza.
La administración gráfica autoalojada se documenta en
[rustdesk-operations.md](rustdesk-operations.md). Durante la primera instalación
el servidor y los clientes necesitan salida HTTPS para obtener los instaladores
RustDesk verificados; el tráfico de soporte posterior permanece dentro de la
subred privada del laboratorio.
Un administrador del dominio todavía puede ignorar deliberadamente este flujo y
ejecutar `Add-Computer` a mano; ninguna GPO puede impedir a un administrador del
bosque modificar el dominio. Para la operación soportada, el script aplica una
+95
View File
@@ -0,0 +1,95 @@
# RustDesk autoalojado y acceso remoto administrado
El bootstrap del controlador de dominio instala un servidor RustDesk OSS
autoalojado y el bootstrap de cada cliente Windows inscrito instala el agente
RustDesk administrado. Esto permite administrar el propio controlador y cada
cliente del laboratorio sin depender de los servidores públicos de RustDesk.
## Componentes y red
El controlador inicia ambos componentes bajo `SYSTEM` mediante las tareas
programadas `SGU-RustDesk-hbbs` y `SGU-RustDesk-hbbr`:
| Componente | Función | Puerto entrante |
| --- | --- | --- |
| `hbbs` | ID/rendezvous y prueba NAT | TCP 21115-21116 y UDP 21116 |
| `hbbr` | Relay para sesiones que no pueden ser directas | TCP 21117 |
Las reglas se llaman **SGU RustDesk** y sólo aceptan la subred privada que se
indicó al bootstrap del servidor. No se habilitan el servidor web ni los puertos
21118/21119. Los clientes y el servidor necesitan salida HTTPS a GitHub sólo
durante una primera instalación o actualización, para descargar el binario
verificado por SHA-256.
El nombre interno usado por los clientes es `rustdesk.lci.lasalle.mx`; el
bootstrap del DC mantiene su registro A en DNS apuntando a la IP fija del
controlador.
## Alta automática de un equipo Windows
Al finalizar `Invoke-SguClientBootstrap.ps1`, antes de considerar válido el
enrolamiento, el flujo:
1. lee la clave pública del servidor a través de la sesión autenticada con el
DC;
2. instala RustDesk desde el MSI oficial, comprobando el SHA-256 fijado;
3. instala y arranca el servicio `RustDesk` como automático;
4. configura ID, relay y clave pública del servidor privado en el contexto del
servicio y para perfiles nuevos;
5. crea una contraseña única de acceso desatendido, cifrada con DPAPI local y
protegida por ACL para `SYSTEM` y administradores locales;
6. registra únicamente el ID y la contraseña cifrada en el inventario del DC.
La contraseña no se imprime, no se pone en el manifiesto y no se añade a los
logs. El inventario del servidor usa DPAPI de la máquina y está limitado por ACL
a `SYSTEM` y administradores del servidor.
El mismo flujo se aplica al DC, por lo que también se puede administrar de forma
remota. Reejecutar el bootstrap mantiene el ID y la contraseña existentes, y
vuelve a validar configuración, tareas, servicio y conectividad sin crear otro
registro.
## Verificación y operación
En el DC, como administrador:
```powershell
Get-ScheduledTask SGU-RustDesk-hbbs,SGU-RustDesk-hbbr |
Select-Object TaskName,State
Get-NetTCPConnection -State Listen -LocalPort 21116,21117
Get-Content C:\ProgramData\SGU\RustDesk\server.json
& C:\ProgramData\SGU\RustDesk\Get-SguRustDeskDevice.ps1
```
El último comando muestra los nombres, IDs y fecha de alta, sin contraseñas. Si
un administrador necesita recuperar una contraseña para conectarse desde el
cliente controlador de RustDesk, puede hacerlo explícitamente en la consola del
DC:
```powershell
& C:\ProgramData\SGU\RustDesk\Get-SguRustDeskDevice.ps1 `
-ComputerName LCI-01 -RevealPassword
```
Trata esa salida como una credencial administrativa: no la pegues en tickets,
capturas ni registros. En RustDesk, conecta usando el ID inventariado y el modo
de autenticación por contraseña permanente.
En un cliente, los indicadores locales son:
```powershell
Get-Service RustDesk
Get-Content C:\ProgramData\SGU\RustDesk\Client\device.json
Test-NetConnection rustdesk.lci.lasalle.mx -Port 21116
```
Si un agente deja de funcionar, se puede repetir el bootstrap del cliente. El
guardián de enrolamiento también repara la configuración de RustDesk al inicio
cuando la información del servidor sigue presente en su estado de enrolamiento.
## Límites operativos
Este alcance automatiza el cliente Windows entregado por
`Invoke-SguClientBootstrap.ps1`. Los clientes Linux conservan su bootstrap PAM/
SSSD independiente; su integración con RustDesk requiere un flujo de inventario
de credenciales propio y no reutiliza ni expone las contraseñas de Windows.
+36 -7
View File
@@ -24,6 +24,8 @@ param(
[string[]]$DomainDnsServerAddresses = @('192.168.50.10'),
[string]$RemoteDesktopPrincipal = 'LCI\SG-Laboratorio-Usuarios-RDP',
[string]$DotNetRuntimeInstallerPath,
[string]$RustDeskServerAddress,
[string]$RustDeskServerPublicKey,
[switch]$SkipRestart
)
@@ -40,7 +42,8 @@ foreach ($scriptName in @(
'Test-SguClientEnrollment.ps1',
'Repair-SguClientEnrollment.ps1',
'Enable-LabRemoteAccess.ps1',
'Enable-SguClientMonitoring.ps1')) {
'Enable-SguClientMonitoring.ps1',
'Install-SguRustDeskClient.ps1')) {
if (-not (Test-Path -LiteralPath (Join-Path $PSScriptRoot $scriptName) -PathType Leaf)) {
throw "$scriptName must be beside Enroll-SguDomainClient.ps1."
}
@@ -73,8 +76,16 @@ $guardParams = @{
TimeoutSeconds = 90
RemoteDesktopPrincipal = $RemoteDesktopPrincipal
DotNetRuntimeInstallerPath = $DotNetRuntimeInstallerPath
RustDeskServerAddress = $RustDeskServerAddress
RustDeskServerPublicKey = $RustDeskServerPublicKey
}
if ([string]::IsNullOrWhiteSpace($RustDeskServerAddress) -xor
[string]::IsNullOrWhiteSpace($RustDeskServerPublicKey)) {
throw 'RustDeskServerAddress and RustDeskServerPublicKey must be supplied together.'
}
$rustDeskResult = $null
if ($PSCmdlet.ShouldProcess($env:COMPUTERNAME, 'Install and verify SGU before joining the domain')) {
# The broker uses a domain DNS name even before the machine joins the
# domain. Point at AD DNS first so the provider-first health check works on
@@ -85,10 +96,19 @@ if ($PSCmdlet.ShouldProcess($env:COMPUTERNAME, 'Install and verify SGU before jo
Resolve-DnsName -Type SRV "_ldap._tcp.dc._msdcs.$DomainName" -ErrorAction Stop | Out-Null
& (Join-Path $PSScriptRoot 'Install-CredentialProvider.ps1') @installParams | Out-Null
if ($RustDeskServerAddress) {
$rustDeskResult = & (Join-Path $PSScriptRoot 'Install-SguRustDeskClient.ps1') `
-ServerAddress $RustDeskServerAddress `
-ServerPublicKey $RustDeskServerPublicKey
}
& (Join-Path $PSScriptRoot 'Install-SguEnrollmentGuard.ps1') @guardParams | Out-Null
$preJoin = & (Join-Path $PSScriptRoot 'Test-SguClientEnrollment.ps1') `
-RequireBrokerHealth
$testParameters = @{ RequireBrokerHealth = $true }
if ($RustDeskServerAddress) {
$testParameters.RequireRustDesk = $true
$testParameters.RustDeskServerAddress = $RustDeskServerAddress
}
$preJoin = & (Join-Path $PSScriptRoot 'Test-SguClientEnrollment.ps1') @testParameters
if (-not $preJoin.IsValid) {
throw "Domain join refused because SGU enrollment is invalid: $($preJoin.Issues -join ' ')"
}
@@ -98,10 +118,18 @@ if ($PSCmdlet.ShouldProcess($env:COMPUTERNAME, 'Install and verify SGU before jo
-RemoteDesktopPrincipal $RemoteDesktopPrincipal `
-EnableAdministrativeFirewallGroups | Out-Null
& (Join-Path $PSScriptRoot 'Enable-SguClientMonitoring.ps1') | Out-Null
return & (Join-Path $PSScriptRoot 'Test-SguClientEnrollment.ps1') `
-RequireDomainJoined `
-RequireRemoteAccess `
-RemoteDesktopPrincipal $RemoteDesktopPrincipal
$postJoinParameters = @{
RequireDomainJoined = $true
RequireRemoteAccess = $true
RemoteDesktopPrincipal = $RemoteDesktopPrincipal
}
if ($RustDeskServerAddress) {
$postJoinParameters.RequireRustDesk = $true
$postJoinParameters.RustDeskServerAddress = $RustDeskServerAddress
}
$postJoin = & (Join-Path $PSScriptRoot 'Test-SguClientEnrollment.ps1') @postJoinParameters
$postJoin | Add-Member -NotePropertyName RustDesk -NotePropertyValue $rustDeskResult
return $postJoin
}
if (-not $DomainCredential) {
@@ -132,5 +160,6 @@ if ($PSCmdlet.ShouldProcess($env:COMPUTERNAME, 'Install and verify SGU before jo
ComputerName = if ($NewComputerName) { $NewComputerName } else { $env:COMPUTERNAME }
DomainName = $DomainName
ProviderValidatedBeforeJoin = $true
RustDesk = $rustDeskResult
RestartRequired = [bool]$SkipRestart
}
+71
View File
@@ -0,0 +1,71 @@
[CmdletBinding()]
param(
[string]$ComputerName,
[switch]$RevealPassword,
[string]$InventoryRoot = "$env:ProgramData\SGU\RustDesk\Devices"
)
$ErrorActionPreference = 'Stop'
function Assert-Administrator {
$identity = [Security.Principal.WindowsIdentity]::GetCurrent()
$principal = [Security.Principal.WindowsPrincipal]::new($identity)
if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {
throw 'Only a local administrator can read the RustDesk device inventory.'
}
}
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 entropy, 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 entropy, 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
}
}
Assert-Administrator
Initialize-DataProtection
if (-not (Test-Path -LiteralPath $InventoryRoot -PathType Container)) {
return @()
}
$entries = @(Get-ChildItem -LiteralPath $InventoryRoot -Filter '*.json' -File |
ForEach-Object {
$metadata = Get-Content -LiteralPath $_.FullName -Raw | ConvertFrom-Json
if ($ComputerName -and -not $metadata.ComputerName.Equals($ComputerName, [StringComparison]::OrdinalIgnoreCase)) {
return
}
$result = [ordered]@{
ComputerName = [string]$metadata.ComputerName
RustDeskId = [string]$metadata.RustDeskId
RegisteredAt = [datetime]$metadata.RegisteredAt
}
if ($RevealPassword) {
$secretPath = [string]$metadata.SecretPath
if (-not (Test-Path -LiteralPath $secretPath -PathType Leaf)) {
throw "The protected RustDesk credential for $($metadata.ComputerName) is missing."
}
$result.AccessPassword = [Text.Encoding]::UTF8.GetString(
[SguRustDeskDataProtection]::Unprotect(
[IO.File]::ReadAllBytes($secretPath)))
}
[pscustomobject]$result
})
$entries | Sort-Object ComputerName
@@ -10,6 +10,7 @@ param(
[string]$DomainName = 'lci.lasalle.mx',
[string]$DomainNetbios = 'LCI',
[string]$BrokerRecordName = 'sgu-auth',
[string]$RustDeskRecordName = 'rustdesk',
[string]$PackageSharePath = 'C:\Packages',
[securestring]$SafeModeAdministratorPassword,
[switch]$SkipRestart,
@@ -313,6 +314,7 @@ if ($Resume -or (-not $ServerIPv4Address -and $existingState)) {
$DomainName = [string]$existingState.DomainName
$DomainNetbios = [string]$existingState.DomainNetbios
$BrokerRecordName = [string]$existingState.BrokerRecordName
$RustDeskRecordName = if ($existingState.RustDeskRecordName) { [string]$existingState.RustDeskRecordName } else { $RustDeskRecordName }
$PackageSharePath = [string]$existingState.PackageSharePath
}
@@ -336,6 +338,7 @@ else {
$NetworkInterfaceAlias = Resolve-PrivateInterfaceAlias -RequestedAlias $NetworkInterfaceAlias
$baseDn = Get-DomainBaseDn -DnsDomainName $DomainName
$brokerDnsName = "$BrokerRecordName.$DomainName"
$rustDeskDnsName = "$RustDeskRecordName.$DomainName"
$stagedScriptPath = Join-Path $bootstrapRoot 'Initialize-SguDomainController.ps1'
$scriptsRoot = Join-Path $bootstrapRoot 'payload\scripts'
$brokerPublishPath = Join-Path $bootstrapRoot 'payload\broker'
@@ -349,9 +352,13 @@ foreach ($requiredPath in @(
(Join-Path $scriptsRoot 'Set-SguDomainUserPolicies.ps1'),
(Join-Path $scriptsRoot 'Enable-SguServerRemoteManagement.ps1'),
(Join-Path $scriptsRoot 'Install-SguDomainMonitoring.ps1'),
(Join-Path $scriptsRoot 'Install-SguRustDeskClient.ps1'),
(Join-Path $scriptsRoot 'Install-SguRustDeskServer.ps1'),
(Join-Path $scriptsRoot 'Invoke-SguMonitoringMaintenance.ps1'),
(Join-Path $scriptsRoot 'Get-SguRustDeskDevice.ps1'),
(Join-Path $scriptsRoot 'Get-SguUsageReport.ps1'),
(Join-Path $scriptsRoot 'Get-SguBrokerLog.ps1'),
(Join-Path $scriptsRoot 'Register-SguRustDeskDevice.ps1'),
(Join-Path $brokerPublishPath 'SGU.AuthBroker.exe'))) {
if (-not (Test-Path -LiteralPath $requiredPath -PathType Leaf)) {
throw "The server bootstrap package is incomplete: $requiredPath"
@@ -378,6 +385,7 @@ if (-not $existingState) {
DomainName = $DomainName
DomainNetbios = $DomainNetbios
BrokerRecordName = $BrokerRecordName
RustDeskRecordName = $RustDeskRecordName
PackageSharePath = $PackageSharePath
}
[IO.File]::WriteAllText(
@@ -516,6 +524,10 @@ if (-not $remoteDesktopGroup) {
-RecordName $BrokerRecordName `
-IPv4Address $ServerIPv4Address `
-ExternalForwarders $DnsForwarders | Out-Null
& (Join-Path $scriptsRoot 'Set-LabBrokerDns.ps1') `
-ZoneName $DomainName `
-RecordName $RustDeskRecordName `
-IPv4Address $ServerIPv4Address | Out-Null
$certificateDirectory = Join-Path $bootstrapRoot 'certificates'
$serverCertificate = Get-ChildItem Cert:\LocalMachine\My |
@@ -626,6 +638,32 @@ $userPolicyParameters = @{
}
& (Join-Path $scriptsRoot 'Set-SguDomainUserPolicies.ps1') @userPolicyParameters | Out-Null
$rustDeskServer = & (Join-Path $scriptsRoot 'Install-SguRustDeskServer.ps1') `
-ServerAddress $rustDeskDnsName `
-FirewallRemoteAddress $privateSubnet
$rustDeskManagementRoot = Join-Path $env:ProgramData 'SGU\RustDesk'
New-Item -ItemType Directory -Path $rustDeskManagementRoot -Force | Out-Null
foreach ($scriptName in @('Register-SguRustDeskDevice.ps1', 'Get-SguRustDeskDevice.ps1')) {
Copy-Item -LiteralPath (Join-Path $scriptsRoot $scriptName) `
-Destination (Join-Path $rustDeskManagementRoot $scriptName) -Force
}
$rustDeskServerClient = & (Join-Path $scriptsRoot 'Install-SguRustDeskClient.ps1') `
-ServerAddress $rustDeskDnsName `
-ServerPublicKey $rustDeskServer.PublicKey
$rustDeskPasswordPointer = [Runtime.InteropServices.Marshal]::SecureStringToBSTR(
$rustDeskServerClient.AccessPassword)
try {
$rustDeskPassword = [Runtime.InteropServices.Marshal]::PtrToStringBSTR($rustDeskPasswordPointer)
& (Join-Path $rustDeskManagementRoot 'Register-SguRustDeskDevice.ps1') `
-ComputerName $env:COMPUTERNAME `
-RustDeskId $rustDeskServerClient.RustDeskId `
-AccessPassword $rustDeskPassword | Out-Null
}
finally {
[Runtime.InteropServices.Marshal]::ZeroFreeBSTR($rustDeskPasswordPointer)
$rustDeskPassword = $null
}
$validation = [ordered]@{
CompletedAt = (Get-Date).ToString('o')
ComputerName = $env:COMPUTERNAME
@@ -637,6 +675,12 @@ $validation = [ordered]@{
BrokerPortListening = [bool](Get-NetTCPConnection -LocalPort 8443 -State Listen -ErrorAction SilentlyContinue)
WinRM = (Get-Service WinRM).Status.ToString()
RemoteDesktop = (Get-Service TermService).Status.ToString()
RustDeskServerAddress = $rustDeskServer.ServerAddress
RustDeskHbbsTask = $rustDeskServer.HbbsTask
RustDeskHbbrTask = $rustDeskServer.HbbrTask
RustDeskHbbsListening = $rustDeskServer.HbbsListening
RustDeskHbbrListening = $rustDeskServer.HbbrListening
RustDeskServerClientId = $rustDeskServerClient.RustDeskId
EventCollector = (Get-Service Wecsvc).Status.ToString()
EventSubscription = @(& wecutil.exe enum-subscription) -contains 'SGU-Lab-Monitoring'
MonitoringRetentionDays = 183
@@ -652,6 +696,10 @@ if ($validation.BrokerService -ne 'Running' -or
-not $validation.BrokerPortListening -or
$validation.WinRM -ne 'Running' -or
$validation.RemoteDesktop -ne 'Running' -or
$validation.RustDeskHbbsTask -ne 'Running' -or
$validation.RustDeskHbbrTask -ne 'Running' -or
-not $validation.RustDeskHbbsListening -or
-not $validation.RustDeskHbbrListening -or
$validation.EventCollector -ne 'Running' -or
-not $validation.EventSubscription) {
throw 'Server finalization did not pass service validation. Review bootstrap.log and re-run the bootstrap.'
+10 -1
View File
@@ -19,7 +19,9 @@ param(
[ValidateRange(2, 90)]
[int]$TimeoutSeconds = 90,
[string]$RemoteDesktopPrincipal = 'LCI\SG-Laboratorio-Usuarios-RDP',
[string]$DotNetRuntimeInstallerPath
[string]$DotNetRuntimeInstallerPath,
[string]$RustDeskServerAddress,
[string]$RustDeskServerPublicKey
)
$ErrorActionPreference = 'Stop'
@@ -29,6 +31,7 @@ $sourceScripts = @(
'Install-CredentialProvider.ps1',
'Enable-LabRemoteAccess.ps1',
'Enable-SguClientMonitoring.ps1',
'Install-SguRustDeskClient.ps1',
'Test-SguClientEnrollment.ps1',
'Repair-SguClientEnrollment.ps1'
)
@@ -54,6 +57,10 @@ if ($DotNetRuntimeInstallerPath -and
-not (Test-Path -LiteralPath $DotNetRuntimeInstallerPath -PathType Leaf)) {
throw 'DotNetRuntimeInstallerPath does not exist.'
}
if ([string]::IsNullOrWhiteSpace($RustDeskServerAddress) -xor
[string]::IsNullOrWhiteSpace($RustDeskServerPublicKey)) {
throw 'RustDeskServerAddress and RustDeskServerPublicKey must be supplied together.'
}
if ($PSCmdlet.ShouldProcess($enrollmentRoot, 'Install the SGU enrollment repair guard')) {
New-Item -ItemType Directory -Path $enrollmentRoot -Force | Out-Null
@@ -88,6 +95,8 @@ if ($PSCmdlet.ShouldProcess($enrollmentRoot, 'Install the SGU enrollment repair
TimeoutSeconds = $TimeoutSeconds
RemoteDesktopPrincipal = $RemoteDesktopPrincipal
DotNetRuntimeInstallerPath = $guardRuntimeInstaller
RustDeskServerAddress = $RustDeskServerAddress
RustDeskServerPublicKey = $RustDeskServerPublicKey
}
$configurationPath = Join-Path $enrollmentRoot 'enrollment.json'
[IO.File]::WriteAllText(
+356
View File
@@ -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
}
+240
View File
@@ -0,0 +1,240 @@
[CmdletBinding(SupportsShouldProcess)]
param(
[Parameter(Mandatory)]
[ValidatePattern('^[A-Za-z0-9][A-Za-z0-9.-]*$')]
[string]$ServerAddress,
[string]$InstallRoot = "$env:ProgramFiles\SGU\RustDeskServer",
[string]$DataRoot = "$env:ProgramData\SGU\RustDesk\Server",
[string]$FirewallRemoteAddress = '192.168.50.0/24',
[uri]$DownloadUri = 'https://github.com/rustdesk/rustdesk-server/releases/download/1.1.16/rustdesk-server-windows-x86_64-unsigned.zip',
[ValidatePattern('^[A-Fa-f0-9]{64}$')]
[string]$ExpectedSha256 = 'B865A3A62FC8755B45480C508F1C4871C3338590408DDA8C58C7E9C373B7ADB0'
)
$ErrorActionPreference = 'Stop'
$ProgressPreference = 'SilentlyContinue'
$hbbsTaskName = 'SGU-RustDesk-hbbs'
$hbbrTaskName = 'SGU-RustDesk-hbbr'
$downloadRoot = Join-Path $env:ProgramData 'SGU\RustDesk\Downloads'
$archivePath = Join-Path $downloadRoot 'rustdesk-server-windows-x86_64-1.1.16.zip'
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 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 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 Copy-IfDifferent {
param(
[Parameter(Mandatory)][string]$Source,
[Parameter(Mandatory)][string]$Destination
)
if (-not (Test-Path -LiteralPath $Destination -PathType Leaf) -or
(Get-FileHash -LiteralPath $Source -Algorithm SHA256).Hash -ne
(Get-FileHash -LiteralPath $Destination -Algorithm SHA256).Hash) {
Copy-Item -LiteralPath $Source -Destination $Destination -Force
return $true
}
return $false
}
function Set-RustDeskFirewallRule {
param(
[Parameter(Mandatory)][string]$Name,
[Parameter(Mandatory)][ValidateSet('TCP', 'UDP')][string]$Protocol,
[Parameter(Mandatory)][string]$LocalPort
)
$rule = Get-NetFirewallRule -DisplayName $Name -ErrorAction SilentlyContinue
if (-not $rule) {
$rule = New-NetFirewallRule -DisplayName $Name -Group 'SGU RustDesk' `
-Direction Inbound -Action Allow -Protocol $Protocol -LocalPort $LocalPort `
-RemoteAddress $FirewallRemoteAddress -Profile Domain -Enabled True
}
else {
$rule | Set-NetFirewallRule -Enabled True -Profile Domain -Action Allow | Out-Null
$rule | Get-NetFirewallPortFilter | Set-NetFirewallPortFilter `
-Protocol $Protocol -LocalPort $LocalPort | Out-Null
$rule | Get-NetFirewallAddressFilter | Set-NetFirewallAddressFilter `
-RemoteAddress $FirewallRemoteAddress | Out-Null
}
}
function Stop-RustDeskTasks {
foreach ($taskName in @($hbbsTaskName, $hbbrTaskName)) {
$task = Get-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue
if ($task -and $task.State -eq 'Running') {
Stop-ScheduledTask -TaskName $taskName
}
}
Start-Sleep -Seconds 1
}
function Register-RustDeskServerTask {
param(
[Parameter(Mandatory)][string]$TaskName,
[Parameter(Mandatory)][string]$Executable,
[string]$Arguments
)
# New-ScheduledTaskAction rejects an empty -Argument value. hbbr has no
# command-line arguments, whereas hbbs needs the relay endpoint, so add
# the parameter only when it is meaningful.
$actionParameters = @{
Execute = $Executable
WorkingDirectory = $DataRoot
}
if (-not [string]::IsNullOrWhiteSpace($Arguments)) {
$actionParameters.Argument = $Arguments
}
$action = New-ScheduledTaskAction @actionParameters
$trigger = New-ScheduledTaskTrigger -AtStartup
$trigger.Delay = 'PT30S'
$settings = New-ScheduledTaskSettingsSet -StartWhenAvailable `
-AllowStartIfOnBatteries -DontStopIfGoingOnBatteries `
-ExecutionTimeLimit ([TimeSpan]::Zero) `
-RestartCount 3 -RestartInterval (New-TimeSpan -Minutes 1)
Register-ScheduledTask -TaskName $TaskName -Action $action -Trigger $trigger `
-Settings $settings -User 'SYSTEM' -RunLevel Highest -Force | Out-Null
$registeredTask = Get-ScheduledTask -TaskName $TaskName -ErrorAction Stop
if ($registeredTask.State -ne 'Running') {
Start-ScheduledTask -TaskName $TaskName
}
}
function Wait-ForRustDeskServer {
for ($attempt = 1; $attempt -le 30; $attempt++) {
$hbbsListening = [bool](Get-NetTCPConnection -LocalPort 21116 -State Listen `
-ErrorAction SilentlyContinue)
$hbbrListening = [bool](Get-NetTCPConnection -LocalPort 21117 -State Listen `
-ErrorAction SilentlyContinue)
$publicKeyReady = Test-Path -LiteralPath (Join-Path $DataRoot 'id_ed25519.pub') -PathType Leaf
if ($hbbsListening -and $hbbrListening -and $publicKeyReady) {
return
}
Start-Sleep -Seconds 2
}
throw 'RustDesk hbbs/hbbr did not become ready within 60 seconds.'
}
Assert-Administrator
if (-not $PSCmdlet.ShouldProcess($env:COMPUTERNAME, 'Install and configure the RustDesk OSS rendezvous and relay server')) {
return
}
Set-PrivateDirectoryAcl -Path $DataRoot
$managementRoot = Split-Path $DataRoot -Parent
Set-PrivateDirectoryAcl -Path $managementRoot
New-Item -ItemType Directory -Path $InstallRoot,$downloadRoot -Force | Out-Null
if (-not (Test-Path -LiteralPath $archivePath -PathType Leaf) -or
(Get-FileHash -LiteralPath $archivePath -Algorithm SHA256).Hash -ne $ExpectedSha256) {
Invoke-WebRequest -Uri $DownloadUri -OutFile $archivePath -UseBasicParsing
}
Assert-FileHash -Path $archivePath -ExpectedHash $ExpectedSha256
$stagingRoot = Join-Path $env:TEMP ('sgu-rustdesk-server-' + [Guid]::NewGuid().ToString('N'))
try {
Expand-Archive -LiteralPath $archivePath -DestinationPath $stagingRoot -Force
$payloadRoot = Join-Path $stagingRoot 'x86_64'
$sourceHbbs = Join-Path $payloadRoot 'hbbs.exe'
$sourceHbbr = Join-Path $payloadRoot 'hbbr.exe'
foreach ($required in @($sourceHbbs, $sourceHbbr)) {
if (-not (Test-Path -LiteralPath $required -PathType Leaf)) {
throw "The verified RustDesk archive is missing $required."
}
}
$targetHbbs = Join-Path $InstallRoot 'hbbs.exe'
$targetHbbr = Join-Path $InstallRoot 'hbbr.exe'
$requiresBinaryUpdate =
-not (Test-Path -LiteralPath $targetHbbs) -or
-not (Test-Path -LiteralPath $targetHbbr) -or
(Get-FileHash -LiteralPath $sourceHbbs -Algorithm SHA256).Hash -ne
(Get-FileHash -LiteralPath $targetHbbs -Algorithm SHA256).Hash -or
(Get-FileHash -LiteralPath $sourceHbbr -Algorithm SHA256).Hash -ne
(Get-FileHash -LiteralPath $targetHbbr -Algorithm SHA256).Hash
if ($requiresBinaryUpdate) {
Stop-RustDeskTasks
Copy-IfDifferent -Source $sourceHbbs -Destination $targetHbbs | Out-Null
Copy-IfDifferent -Source $sourceHbbr -Destination $targetHbbr | Out-Null
}
}
finally {
if (Test-Path -LiteralPath $stagingRoot) {
Remove-Item -LiteralPath $stagingRoot -Recurse -Force
}
}
Set-RustDeskFirewallRule -Name 'SGU RustDesk hbbs (TCP)' -Protocol TCP -LocalPort '21115-21116'
Set-RustDeskFirewallRule -Name 'SGU RustDesk hbbr (TCP)' -Protocol TCP -LocalPort '21117'
Set-RustDeskFirewallRule -Name 'SGU RustDesk hbbs (UDP)' -Protocol UDP -LocalPort '21116'
Register-RustDeskServerTask -TaskName $hbbrTaskName -Executable (Join-Path $InstallRoot 'hbbr.exe')
Register-RustDeskServerTask -TaskName $hbbsTaskName -Executable (Join-Path $InstallRoot 'hbbs.exe') `
-Arguments "-r $ServerAddress`:21117"
Wait-ForRustDeskServer
$publicKey = (Get-Content -LiteralPath (Join-Path $DataRoot 'id_ed25519.pub') -Raw).Trim()
if ([string]::IsNullOrWhiteSpace($publicKey)) {
throw 'RustDesk generated an empty public key.'
}
$statusPath = Join-Path (Split-Path $DataRoot -Parent) 'server.json'
$status = [ordered]@{
ServerAddress = $ServerAddress
PublicKey = $publicKey
PublicKeySha256 = ([Security.Cryptography.SHA256]::Create().ComputeHash(
[Text.Encoding]::UTF8.GetBytes($publicKey)) | ForEach-Object ToString x2) -join ''
HbbsTaskName = $hbbsTaskName
HbbrTaskName = $hbbrTaskName
HbbsTcpPort = 21116
HbbrTcpPort = 21117
InstalledAt = (Get-Date).ToString('o')
}
[IO.File]::WriteAllText($statusPath, ($status | ConvertTo-Json), [Text.UTF8Encoding]::new($false))
[pscustomobject]@{
ServerAddress = $ServerAddress
PublicKey = $publicKey
PublicKeySha256 = $status.PublicKeySha256
HbbsTask = (Get-ScheduledTask -TaskName $hbbsTaskName).State.ToString()
HbbrTask = (Get-ScheduledTask -TaskName $hbbrTaskName).State.ToString()
HbbsListening = [bool](Get-NetTCPConnection -LocalPort 21116 -State Listen -ErrorAction SilentlyContinue)
HbbrListening = [bool](Get-NetTCPConnection -LocalPort 21117 -State Listen -ErrorAction SilentlyContinue)
StatusPath = $statusPath
}
+48
View File
@@ -124,6 +124,7 @@ $runtimeInstaller = Get-ChildItem (Join-Path $packageRoot 'payload\prerequisites
Select-Object -First 1
foreach ($requiredPath in @(
(Join-Path $scriptsRoot 'Enroll-SguDomainClient.ps1'),
(Join-Path $scriptsRoot 'Install-SguRustDeskClient.ps1'),
(Join-Path $scriptsRoot 'Register-SguClientCertificate.ps1'),
(Join-Path $providerPublishPath 'SGU.CredentialProvider.comhost.dll'))) {
if (-not (Test-Path -LiteralPath $requiredPath -PathType Leaf)) {
@@ -177,10 +178,23 @@ try {
$serverIdentity = Invoke-Command -Session $session -ScriptBlock {
$computer = Get-CimInstance Win32_ComputerSystem
$brokerService = Get-Service SGUAuthBroker -ErrorAction SilentlyContinue
$rustDeskStatusPath = Join-Path $env:ProgramData 'SGU\RustDesk\server.json'
$rustDeskStatus = if (Test-Path -LiteralPath $rustDeskStatusPath -PathType Leaf) {
Get-Content -LiteralPath $rustDeskStatusPath -Raw | ConvertFrom-Json
}
else {
$null
}
$hbbsTask = Get-ScheduledTask -TaskName 'SGU-RustDesk-hbbs' -ErrorAction SilentlyContinue
$hbbrTask = Get-ScheduledTask -TaskName 'SGU-RustDesk-hbbr' -ErrorAction SilentlyContinue
[pscustomobject]@{
ComputerName = $env:COMPUTERNAME
Domain = $computer.Domain
BrokerService = if ($brokerService) { $brokerService.Status.ToString() } else { 'Missing' }
RustDeskServerAddress = if ($rustDeskStatus) { [string]$rustDeskStatus.ServerAddress } else { $null }
RustDeskPublicKey = if ($rustDeskStatus) { [string]$rustDeskStatus.PublicKey } else { $null }
RustDeskHbbsTask = if ($hbbsTask) { $hbbsTask.State.ToString() } else { 'Missing' }
RustDeskHbbrTask = if ($hbbrTask) { $hbbrTask.State.ToString() } else { 'Missing' }
}
}
if (-not $serverIdentity.Domain -or
@@ -190,6 +204,12 @@ try {
if ($serverIdentity.BrokerService -ne 'Running') {
throw "The SGU Authentication Broker is not running on $($serverIdentity.ComputerName)."
}
if ([string]::IsNullOrWhiteSpace($serverIdentity.RustDeskServerAddress) -or
[string]::IsNullOrWhiteSpace($serverIdentity.RustDeskPublicKey) -or
$serverIdentity.RustDeskHbbsTask -ne 'Running' -or
$serverIdentity.RustDeskHbbrTask -ne 'Running') {
throw "The RustDesk server is not ready on $($serverIdentity.ComputerName). Run the current server bootstrap first."
}
$certificateSubject = "CN=SGU Credential Provider Client $env:COMPUTERNAME"
$clientCertificate = Get-ChildItem Cert:\LocalMachine\My |
@@ -274,6 +294,8 @@ try {
DomainDnsServerAddresses = @($DomainControllerIPv4Address.IPAddressToString)
RemoteDesktopPrincipal = "$DomainNetbios\SG-Laboratorio-Usuarios-RDP"
DotNetRuntimeInstallerPath = $runtimeInstaller.FullName
RustDeskServerAddress = $serverIdentity.RustDeskServerAddress
RustDeskServerPublicKey = $serverIdentity.RustDeskPublicKey
SkipRestart = $true
}
if ($NewComputerName) {
@@ -281,6 +303,31 @@ try {
}
$result = & (Join-Path $scriptsRoot 'Enroll-SguDomainClient.ps1') @enrollmentParameters
$rustDeskEnrollment = $result.RustDesk
if (-not $rustDeskEnrollment -or -not $rustDeskEnrollment.RustDeskId -or
-not $rustDeskEnrollment.AccessPassword) {
throw 'The client RustDesk enrollment did not provide an ID and protected access credential.'
}
$rustDeskPasswordPointer = [Runtime.InteropServices.Marshal]::SecureStringToBSTR(
$rustDeskEnrollment.AccessPassword)
try {
$rustDeskPassword = [Runtime.InteropServices.Marshal]::PtrToStringBSTR($rustDeskPasswordPointer)
Invoke-Command -Session $session -ScriptBlock {
param($ComputerName, $RustDeskId, $AccessPassword)
$registrationScript = Join-Path $env:ProgramData 'SGU\RustDesk\Register-SguRustDeskDevice.ps1'
if (-not (Test-Path -LiteralPath $registrationScript -PathType Leaf)) {
throw 'The RustDesk device-registration script is missing on the domain controller.'
}
& $registrationScript -ComputerName $ComputerName -RustDeskId $RustDeskId `
-AccessPassword $AccessPassword | Out-Null
} -ArgumentList $env:COMPUTERNAME,$rustDeskEnrollment.RustDeskId,$rustDeskPassword
}
finally {
[Runtime.InteropServices.Marshal]::ZeroFreeBSTR($rustDeskPasswordPointer)
$rustDeskPassword = $null
}
$rustDeskEnrollment.PSObject.Properties.Remove('AccessPassword')
}
finally {
if ($session) {
@@ -319,6 +366,7 @@ if ($SkipRestart) {
ClientCertificateRegistered = $true
BrokerEndpoint = $brokerEndpoint
RestartRequired = $true
RustDesk = if ($result) { $result.RustDesk } else { $null }
EnrollmentResult = $result
}
return
+5
View File
@@ -109,6 +109,7 @@ $clientScripts = @(
'Enroll-SguDomainClient.ps1',
'Install-CredentialProvider.ps1',
'Install-SguEnrollmentGuard.ps1',
'Install-SguRustDeskClient.ps1',
'Register-SguClientCertificate.ps1',
'Repair-SguClientEnrollment.ps1',
'Test-SguClientEnrollment.ps1'
@@ -166,9 +167,13 @@ $serverScripts = @(
'Get-SguUsageReport.ps1',
'Get-SguBrokerLog.ps1',
'Install-SguDomainMonitoring.ps1',
'Install-SguRustDeskClient.ps1',
'Install-SguRustDeskServer.ps1',
'Invoke-SguMonitoringMaintenance.ps1',
'New-LabCertificate.ps1',
'Get-SguRustDeskDevice.ps1',
'Register-SguClientCertificate.ps1',
'Register-SguRustDeskDevice.ps1',
'Set-LabBrokerDns.ps1',
'Set-SguDomainComputerPolicies.ps1',
'Set-SguDomainUserPolicies.ps1'
+97
View File
@@ -0,0 +1,97 @@
[CmdletBinding(SupportsShouldProcess)]
param(
[Parameter(Mandatory)]
[ValidatePattern('^[A-Za-z0-9][A-Za-z0-9-]{0,62}$')]
[string]$ComputerName,
[Parameter(Mandatory)]
[ValidatePattern('^\d+$')]
[string]$RustDeskId,
[Parameter(Mandatory)]
[ValidateLength(12, 256)]
[string]$AccessPassword,
[string]$InventoryRoot = "$env:ProgramData\SGU\RustDesk\Devices"
)
$ErrorActionPreference = 'Stop'
function Assert-Administrator {
$identity = [Security.Principal.WindowsIdentity]::GetCurrent()
$principal = [Security.Principal.WindowsPrincipal]::new($identity)
if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {
throw 'Only a local administrator can register a RustDesk device credential.'
}
}
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 entropy, 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 entropy, 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
}
Assert-Administrator
Initialize-DataProtection
if (-not $PSCmdlet.ShouldProcess($ComputerName, 'Register the protected RustDesk management credential')) {
return
}
Set-PrivateDirectoryAcl -Path $InventoryRoot
$normalizedName = $ComputerName.ToUpperInvariant()
$secretPath = Join-Path $InventoryRoot "$normalizedName.secret"
$metadataPath = Join-Path $InventoryRoot "$normalizedName.json"
$protectedPassword = [SguRustDeskDataProtection]::Protect(
[Text.Encoding]::UTF8.GetBytes($AccessPassword))
[IO.File]::WriteAllBytes($secretPath, $protectedPassword)
$metadata = [ordered]@{
ComputerName = $normalizedName
RustDeskId = $RustDeskId
RegisteredAt = (Get-Date).ToString('o')
SecretPath = $secretPath
}
[IO.File]::WriteAllText($metadataPath, ($metadata | ConvertTo-Json), [Text.UTF8Encoding]::new($false))
[pscustomobject]@{
ComputerName = $normalizedName
RustDeskId = $RustDeskId
Registered = $true
MetadataPath = $metadataPath
}
+10
View File
@@ -10,6 +10,7 @@ $testScript = Join-Path $enrollmentRoot 'Test-SguClientEnrollment.ps1'
$installScript = Join-Path $enrollmentRoot 'Install-CredentialProvider.ps1'
$remoteAccessScript = Join-Path $enrollmentRoot 'Enable-LabRemoteAccess.ps1'
$monitoringScript = Join-Path $enrollmentRoot 'Enable-SguClientMonitoring.ps1'
$rustDeskScript = Join-Path $enrollmentRoot 'Install-SguRustDeskClient.ps1'
$before = & $testScript
if (-not $before.IsValid) {
@@ -36,12 +37,21 @@ if ($computer.PartOfDomain) {
& $monitoringScript | Out-Null
}
if ($configuration.RustDeskServerAddress -and $configuration.RustDeskServerPublicKey) {
& $rustDeskScript -ServerAddress ([string]$configuration.RustDeskServerAddress) `
-ServerPublicKey ([string]$configuration.RustDeskServerPublicKey) | Out-Null
}
$verificationParams = @{}
if ($computer.PartOfDomain) {
$verificationParams.RequireDomainJoined = $true
$verificationParams.RequireRemoteAccess = $true
$verificationParams.RemoteDesktopPrincipal = [string]$configuration.RemoteDesktopPrincipal
}
if ($configuration.RustDeskServerAddress) {
$verificationParams.RequireRustDesk = $true
$verificationParams.RustDeskServerAddress = [string]$configuration.RustDeskServerAddress
}
$after = & $testScript @verificationParams
$after
if (-not $after.IsValid) {
+40
View File
@@ -4,6 +4,8 @@ param(
[switch]$RequireRemoteAccess,
[switch]$RequireBrokerHealth,
[string]$RemoteDesktopPrincipal = 'LCI\SG-Laboratorio-Usuarios-RDP',
[switch]$RequireRustDesk,
[string]$RustDeskServerAddress,
[switch]$Enforce
)
@@ -158,6 +160,42 @@ if ($RequireRemoteAccess) {
}
}
$rustDeskReady = $null
$rustDeskId = $null
if ($RequireRustDesk) {
if ([string]::IsNullOrWhiteSpace($RustDeskServerAddress)) {
$issues.Add('RustDesk validation requires RustDeskServerAddress.')
}
$rustDeskService = Get-Service -Name 'RustDesk' -ErrorAction SilentlyContinue
$rustDeskStatePath = Join-Path $env:ProgramData 'SGU\RustDesk\Client\device.json'
$rustDeskSecretPath = Join-Path $env:ProgramData 'SGU\RustDesk\Client\access.secret'
$rustDeskConfigPath = Join-Path $env:WINDIR `
'ServiceProfiles\LocalService\AppData\Roaming\RustDesk\config\RustDesk2.toml'
$rustDeskConfig = if (Test-Path -LiteralPath $rustDeskConfigPath -PathType Leaf) {
Get-Content -LiteralPath $rustDeskConfigPath -Raw
}
else {
''
}
$rustDeskState = $null
try {
$rustDeskState = Get-Content -LiteralPath $rustDeskStatePath -Raw | ConvertFrom-Json
$rustDeskId = [string]$rustDeskState.RustDeskId
}
catch {
# The checks below report the missing or invalid state as one enrollment issue.
}
$rustDeskReady =
$rustDeskService -and $rustDeskService.Status -eq 'Running' -and
(Test-Path -LiteralPath $rustDeskSecretPath -PathType Leaf) -and
$rustDeskState -and $rustDeskState.ServerAddress -eq $RustDeskServerAddress -and
$rustDeskId -match '^\d+$' -and
$rustDeskConfig -match [regex]::Escape("rendezvous_server = '$RustDeskServerAddress`:21116'")
if (-not $rustDeskReady) {
$issues.Add('RustDesk is not installed, running, or configured for the expected self-hosted server.')
}
}
$result = [pscustomobject]@{
ComputerName = $env:COMPUTERNAME
Domain = $computer.Domain
@@ -175,6 +213,8 @@ $result = [pscustomobject]@{
DotNetRuntimePresent = $dotNetRuntimePresent
BrokerHealth = $brokerHealth
RemoteAccessReady = $remoteAccessReady
RustDeskReady = $rustDeskReady
RustDeskId = $rustDeskId
IsValid = $issues.Count -eq 0
Issues = $issues.ToArray()
}