From 675db6bc1e88022fdb00f7827db623d7e023a7c1 Mon Sep 17 00:00:00 2001 From: Alejandro Rosales Date: Mon, 7 Sep 2026 17:29:25 -0600 Subject: [PATCH] Add self-hosted RustDesk bootstrap management --- README.md | 1 + docs/bootstrap-recovery.md | 11 + docs/rustdesk-operations.md | 95 ++++++ scripts/Enroll-SguDomainClient.ps1 | 43 ++- scripts/Get-SguRustDeskDevice.ps1 | 71 ++++ scripts/Initialize-SguDomainController.ps1 | 48 +++ scripts/Install-SguEnrollmentGuard.ps1 | 11 +- scripts/Install-SguRustDeskClient.ps1 | 356 +++++++++++++++++++++ scripts/Install-SguRustDeskServer.ps1 | 240 ++++++++++++++ scripts/Invoke-SguClientBootstrap.ps1 | 48 +++ scripts/New-SguBootstrapPackages.ps1 | 5 + scripts/Register-SguRustDeskDevice.ps1 | 97 ++++++ scripts/Repair-SguClientEnrollment.ps1 | 10 + scripts/Test-SguClientEnrollment.ps1 | 40 +++ 14 files changed, 1068 insertions(+), 8 deletions(-) create mode 100644 docs/rustdesk-operations.md create mode 100644 scripts/Get-SguRustDeskDevice.ps1 create mode 100644 scripts/Install-SguRustDeskClient.ps1 create mode 100644 scripts/Install-SguRustDeskServer.ps1 create mode 100644 scripts/Register-SguRustDeskDevice.ps1 diff --git a/README.md b/README.md index 6ea6eb5..38ef8c8 100644 --- a/README.md +++ b/README.md @@ -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) diff --git a/docs/bootstrap-recovery.md b/docs/bootstrap-recovery.md index d330e9c..2593964 100644 --- a/docs/bootstrap-recovery.md +++ b/docs/bootstrap-recovery.md @@ -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 diff --git a/docs/rustdesk-operations.md b/docs/rustdesk-operations.md new file mode 100644 index 0000000..f8fc0d6 --- /dev/null +++ b/docs/rustdesk-operations.md @@ -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. diff --git a/scripts/Enroll-SguDomainClient.ps1 b/scripts/Enroll-SguDomainClient.ps1 index 91609f6..5479178 100644 --- a/scripts/Enroll-SguDomainClient.ps1 +++ b/scripts/Enroll-SguDomainClient.ps1 @@ -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 } diff --git a/scripts/Get-SguRustDeskDevice.ps1 b/scripts/Get-SguRustDeskDevice.ps1 new file mode 100644 index 0000000..c24cd12 --- /dev/null +++ b/scripts/Get-SguRustDeskDevice.ps1 @@ -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 diff --git a/scripts/Initialize-SguDomainController.ps1 b/scripts/Initialize-SguDomainController.ps1 index 4a3c296..9c5f688 100644 --- a/scripts/Initialize-SguDomainController.ps1 +++ b/scripts/Initialize-SguDomainController.ps1 @@ -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.' diff --git a/scripts/Install-SguEnrollmentGuard.ps1 b/scripts/Install-SguEnrollmentGuard.ps1 index 45fbc31..252bcc1 100644 --- a/scripts/Install-SguEnrollmentGuard.ps1 +++ b/scripts/Install-SguEnrollmentGuard.ps1 @@ -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( diff --git a/scripts/Install-SguRustDeskClient.ps1 b/scripts/Install-SguRustDeskClient.ps1 new file mode 100644 index 0000000..284f843 --- /dev/null +++ b/scripts/Install-SguRustDeskClient.ps1 @@ -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 +} diff --git a/scripts/Install-SguRustDeskServer.ps1 b/scripts/Install-SguRustDeskServer.ps1 new file mode 100644 index 0000000..e255aa7 --- /dev/null +++ b/scripts/Install-SguRustDeskServer.ps1 @@ -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 +} diff --git a/scripts/Invoke-SguClientBootstrap.ps1 b/scripts/Invoke-SguClientBootstrap.ps1 index dd310f8..8771f0b 100644 --- a/scripts/Invoke-SguClientBootstrap.ps1 +++ b/scripts/Invoke-SguClientBootstrap.ps1 @@ -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 diff --git a/scripts/New-SguBootstrapPackages.ps1 b/scripts/New-SguBootstrapPackages.ps1 index cea3db4..2ae0151 100644 --- a/scripts/New-SguBootstrapPackages.ps1 +++ b/scripts/New-SguBootstrapPackages.ps1 @@ -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' diff --git a/scripts/Register-SguRustDeskDevice.ps1 b/scripts/Register-SguRustDeskDevice.ps1 new file mode 100644 index 0000000..67220d8 --- /dev/null +++ b/scripts/Register-SguRustDeskDevice.ps1 @@ -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 +} diff --git a/scripts/Repair-SguClientEnrollment.ps1 b/scripts/Repair-SguClientEnrollment.ps1 index 6197621..c163c99 100644 --- a/scripts/Repair-SguClientEnrollment.ps1 +++ b/scripts/Repair-SguClientEnrollment.ps1 @@ -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) { diff --git a/scripts/Test-SguClientEnrollment.ps1 b/scripts/Test-SguClientEnrollment.ps1 index 443ccae..58d03ac 100644 --- a/scripts/Test-SguClientEnrollment.ps1 +++ b/scripts/Test-SguClientEnrollment.ps1 @@ -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() }