#Requires -Version 5.1 [CmdletBinding(SupportsShouldProcess)] param( [ipaddress]$DomainControllerIPv4Address, [string]$NetworkInterfaceAlias, [ipaddress]$ClientIPv4Address, [ValidateRange(1, 32)] [int]$ClientPrefixLength = 24, [PSCredential]$DomainCredential, [string]$DomainName, [string]$DomainNetbios, [string]$ComputerOuDn, [string]$NewComputerName, [ValidateSet('Auto', 'Windows10Legacy', 'Windows11Modern')] [string]$CompatibilityProfile = 'Auto', [ValidateSet('Direct', 'AzureP2S')] [string]$ConnectivityMode = 'Direct', [string]$VpnConnectionName = 'SGU Azure P2S', [string]$VpnProfilePackagePath, [string]$VpnClientCertificatePfxPath, [securestring]$VpnClientCertificatePfxPassword, [string]$VpnClientRootCertificatePath, [string[]]$AzureNetworkPrefixes = @('10.77.0.0/16'), [switch]$PauseOnError, [switch]$SkipRestart ) $ErrorActionPreference = 'Stop' $brokerRecordName = 'sgu-auth' $temporaryRoot = Join-Path $env:ProgramData ("SGU\Bootstrap\Client-" + [Guid]::NewGuid().ToString('N')) $bootstrapLogRoot = Join-Path $env:ProgramData 'SGU\Bootstrap\Client' $bootstrapErrorLog = Join-Path $bootstrapLogRoot 'latest-error.log' trap { $failure = $_ $failureText = @( "SGU client enrollment failed at $((Get-Date).ToString('s')).", '', $failure.Exception.Message, '', $failure.ScriptStackTrace ) -join [Environment]::NewLine try { New-Item -ItemType Directory -Path $bootstrapLogRoot -Force | Out-Null [IO.File]::WriteAllText($bootstrapErrorLog, $failureText, [Text.UTF8Encoding]::new($false)) } catch { # Keep the original enrollment error when diagnostics cannot be written. } Write-Host '' Write-Host 'SGU client enrollment did not complete.' -ForegroundColor Red Write-Host $failure.Exception.Message -ForegroundColor Red Write-Host "Diagnostic log: $bootstrapErrorLog" -ForegroundColor Yellow if ($PauseOnError -and [Environment]::UserInteractive) { Read-Host 'Press ENTER to close this window' | Out-Null } exit 1 } 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 bootstrap from an elevated Windows PowerShell session.' } } function Assert-PackageManifest { param([Parameter(Mandatory)][string]$PackageRoot) $manifestPath = Join-Path $PackageRoot 'package-manifest.json' if (-not (Test-Path -LiteralPath $manifestPath -PathType Leaf)) { throw 'package-manifest.json is missing. Use the complete SGU client bootstrap release.' } $manifest = Get-Content -LiteralPath $manifestPath -Raw | ConvertFrom-Json foreach ($entry in $manifest.Files) { $path = Join-Path $PackageRoot ([string]$entry.Path) if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { throw "Bootstrap package file is missing: $($entry.Path)" } $actual = (Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash if ($actual -ne [string]$entry.Sha256) { throw "Bootstrap package integrity check failed: $($entry.Path)" } } return $manifest } function Resolve-ClientInterfaceAlias { param( [string]$RequestedAlias, [Parameter(Mandatory)][ipaddress]$DomainControllerAddress ) # IP interfaces include tunnel/PPP adapters that Get-NetAdapter can omit. $interfaces = @(Get-NetIPInterface -AddressFamily IPv4 | Where-Object { $_.ConnectionState -eq 'Connected' -and (-not $RequestedAlias -or $_.InterfaceAlias -eq $RequestedAlias) }) $preferred = @(Find-NetRoute -RemoteIPAddress $DomainControllerAddress.IPAddressToString ` -ErrorAction SilentlyContinue | Where-Object { $_.PSObject.Properties['IPAddress'] }) $attempts = @() $candidates = @(foreach ($interface in $interfaces) { $addresses = @(Get-NetIPAddress -InterfaceIndex $interface.InterfaceIndex -AddressFamily IPv4 ` -ErrorAction SilentlyContinue | Where-Object { $_.AddressState -eq 'Preferred' -and -not $_.SkipAsSource -and $_.IPAddress -notmatch '^(0\.|127\.|169\.254\.)' }) if ($addresses.Count -eq 0) { $attempts += "$($interface.InterfaceAlias): no usable IPv4 address (check DHCP/static configuration)" continue } $route = Get-NetRoute -InterfaceIndex $interface.InterfaceIndex -AddressFamily IPv4 ` -PolicyStore ActiveStore -ErrorAction SilentlyContinue | Where-Object { $parts = $_.DestinationPrefix -split '/' Test-IPv4AddressesSharePrefix -FirstAddress $DomainControllerAddress ` -SecondAddress ([ipaddress]$parts[0]) -PrefixLength ([int]$parts[1]) } | Sort-Object @{ Expression = { [int]($_.DestinationPrefix -split '/')[1] }; Descending = $true }, RouteMetric | Select-Object -First 1 if ($route) { foreach ($address in $addresses) { [pscustomobject]@{ InterfaceAlias = $interface.InterfaceAlias InterfaceIndex = [int]$interface.InterfaceIndex IPAddress = $address.IPAddress NextHop = $route.NextHop Preferred = @($preferred | Where-Object IPAddress -eq $address.IPAddress).Count -gt 0 PrefixLength = [int]($route.DestinationPrefix -split '/')[1] Metric = [int]$route.RouteMetric + [int]$interface.InterfaceMetric } } } else { $attempts += "$($interface.InterfaceAlias): no route to $DomainControllerAddress" } }) if ($interfaces.Count -eq 0) { $attempts += 'No matching connected IPv4 interface' } foreach ($candidate in ($candidates | Sort-Object @{ Expression = { $_.Preferred }; Descending = $true }, @{ Expression = { $_.PrefixLength }; Descending = $true }, Metric, InterfaceIndex, IPAddress)) { Write-Host "Checking $($candidate.InterfaceAlias) ($($candidate.IPAddress)) -> $DomainControllerAddress..." if (Test-TcpPort -Address $DomainControllerAddress -Port 5985 -TimeoutMilliseconds 2000 ` -SourceAddress ([ipaddress]$candidate.IPAddress) -InterfaceIndex $candidate.InterfaceIndex) { return $candidate } $attempts += "$($candidate.InterfaceAlias) [$($candidate.IPAddress), next hop $($candidate.NextHop)]: TCP 5985 unavailable" } throw "Cannot reach SGU server $DomainControllerAddress. $($attempts -join '; '). Check the LAN/VPN connection, DHCP or an administrator-assigned IP, routes and the server WinRM firewall. No client IP was changed." } function Test-IPv4AddressesSharePrefix { param( [Parameter(Mandatory)][ipaddress]$FirstAddress, [Parameter(Mandatory)][ipaddress]$SecondAddress, [Parameter(Mandatory)][ValidateRange(0, 32)][int]$PrefixLength ) if ($FirstAddress.AddressFamily -ne [Net.Sockets.AddressFamily]::InterNetwork -or $SecondAddress.AddressFamily -ne [Net.Sockets.AddressFamily]::InterNetwork) { return $false } $firstBytes = $FirstAddress.GetAddressBytes() $secondBytes = $SecondAddress.GetAddressBytes() $remainingBits = $PrefixLength for ($index = 0; $index -lt 4; $index++) { $bits = [Math]::Min(8, $remainingBits) $mask = if ($bits -eq 0) { 0 } elseif ($bits -eq 8) { 255 } else { 256 - [int][Math]::Pow(2, 8 - $bits) } if (($firstBytes[$index] -band $mask) -ne ($secondBytes[$index] -band $mask)) { return $false } $remainingBits -= $bits } return $true } function Wait-ClientInterface { param( [string]$RequestedAlias, [Parameter(Mandatory)][ipaddress]$DomainControllerAddress, [int]$TimeoutSeconds = 20 ) $deadline = (Get-Date).AddSeconds($TimeoutSeconds) do { try { return Resolve-ClientInterfaceAlias -RequestedAlias $RequestedAlias ` -DomainControllerAddress $DomainControllerAddress } catch { $lastFailure = $_ if ((Get-Date) -ge $deadline) { throw $lastFailure } Write-Host 'Waiting for DHCP, VPN routes or server connectivity to become ready...' Start-Sleep -Seconds 2 } } while ($true) } function Assert-UsableClientIPv4Address { param( [Parameter(Mandatory)][ipaddress]$Address, [Parameter(Mandatory)][ipaddress]$DomainControllerAddress, [Parameter(Mandatory)][ValidateRange(1, 32)][int]$PrefixLength ) if ($Address.AddressFamily -ne [Net.Sockets.AddressFamily]::InterNetwork) { throw "The SGU client address '$Address' must be IPv4." } if ($Address.IPAddressToString -eq $DomainControllerAddress.IPAddressToString) { throw 'The SGU client and domain controller cannot use the same IPv4 address.' } if ($Address.IPAddressToString -match '^(0\.|127\.|169\.254\.|22[4-9]\.|23\d\.)') { throw "The SGU client address '$Address' is not usable on the private domain network." } if (-not (Test-IPv4AddressesSharePrefix -FirstAddress $Address ` -SecondAddress $DomainControllerAddress -PrefixLength $PrefixLength)) { throw "The SGU client address '$Address/$PrefixLength' is not on the same network as domain controller $DomainControllerAddress." } } function Set-ClientDomainAddress { param( [Parameter(Mandatory)][string]$InterfaceAlias, [Parameter(Mandatory)][ipaddress]$DomainControllerAddress, [ipaddress]$RequestedAddress, [Parameter(Mandatory)][ValidateRange(1, 32)][int]$PrefixLength ) $adapter = Get-NetAdapter -Name $InterfaceAlias -ErrorAction Stop $matchingAddress = Get-NetIPAddress -InterfaceIndex $adapter.ifIndex -AddressFamily IPv4 ` -ErrorAction SilentlyContinue | Where-Object { $_.AddressState -eq 'Preferred' -and $_.IPAddress -notmatch '^(127\.|169\.254\.)' -and (Test-IPv4AddressesSharePrefix -FirstAddress ([ipaddress]$_.IPAddress) ` -SecondAddress $DomainControllerAddress -PrefixLength $PrefixLength) } | Select-Object -First 1 if (-not $RequestedAddress -and $matchingAddress) { return [ipaddress]$matchingAddress.IPAddress } if (-not $RequestedAddress) { throw 'Static addressing requires an explicit -ClientIPv4Address. Automatic enrollment preserves DHCP and existing addresses.' } Assert-UsableClientIPv4Address -Address $RequestedAddress ` -DomainControllerAddress $DomainControllerAddress -PrefixLength $PrefixLength Set-NetIPInterface -InterfaceIndex $adapter.ifIndex -AddressFamily IPv4 -Dhcp Disabled $existingAddresses = @(Get-NetIPAddress -InterfaceIndex $adapter.ifIndex -AddressFamily IPv4 ` -ErrorAction SilentlyContinue | Where-Object PrefixOrigin -ne 'WellKnown') foreach ($existingAddress in $existingAddresses) { if ($existingAddress.IPAddress -ne $RequestedAddress.IPAddressToString -or [int]$existingAddress.PrefixLength -ne $PrefixLength) { Remove-NetIPAddress -InputObject $existingAddress -Confirm:$false } } if (-not (Get-NetIPAddress -InterfaceIndex $adapter.ifIndex -AddressFamily IPv4 ` -IPAddress $RequestedAddress.IPAddressToString -ErrorAction SilentlyContinue)) { New-NetIPAddress -InterfaceIndex $adapter.ifIndex -AddressFamily IPv4 ` -IPAddress $RequestedAddress.IPAddressToString -PrefixLength $PrefixLength | Out-Null } $addressReadyDeadline = (Get-Date).AddSeconds(20) do { $configuredAddress = Get-NetIPAddress -InterfaceIndex $adapter.ifIndex ` -AddressFamily IPv4 -IPAddress $RequestedAddress.IPAddressToString ` -ErrorAction SilentlyContinue if ($configuredAddress -and $configuredAddress.AddressState -eq 'Preferred') { return $RequestedAddress } Start-Sleep -Milliseconds 500 } while ((Get-Date) -lt $addressReadyDeadline) $observedState = if ($configuredAddress) { $configuredAddress.AddressState } else { 'Missing' } throw "The SGU client address '$RequestedAddress' did not become ready on '$InterfaceAlias' within 20 seconds. Observed state: $observedState." } function Test-TcpPort { param( [Parameter(Mandatory)][ipaddress]$Address, [Parameter(Mandatory)][int]$Port, [int]$TimeoutMilliseconds = 5000, [ipaddress]$SourceAddress, [int]$InterfaceIndex ) $client = [Net.Sockets.TcpClient]::new([Net.Sockets.AddressFamily]::InterNetwork) $connect = $null try { if ($InterfaceIndex) { # IP_UNICAST_IF (31) expects the interface index in network byte order. $client.Client.SetSocketOption([Net.Sockets.SocketOptionLevel]::IP, [Net.Sockets.SocketOptionName]31, [Net.IPAddress]::HostToNetworkOrder($InterfaceIndex)) } if ($SourceAddress) { $client.Client.Bind([Net.IPEndPoint]::new($SourceAddress, 0)) } $connect = $client.BeginConnect($Address, $Port, $null, $null) if (-not $connect.AsyncWaitHandle.WaitOne($TimeoutMilliseconds)) { return $false } $client.EndConnect($connect) return $true } catch { return $false } finally { $client.Dispose() if ($connect) { $connect.AsyncWaitHandle.Close() } } } function Set-ClientServerRoute { param( [Parameter(Mandatory)]$SelectedInterface, [Parameter(Mandatory)][ipaddress]$DomainControllerAddress ) $current = @(Find-NetRoute -RemoteIPAddress $DomainControllerAddress.IPAddressToString | Where-Object { $_.PSObject.Properties['IPAddress'] }) if (@($current | Where-Object InterfaceIndex -eq $SelectedInterface.InterfaceIndex).Count -gt 0) { return } # Only pin this server when Windows currently chooses a different interface. # Do not replace default routes or change interface metrics used by Internet traffic. $route = New-NetRoute -DestinationPrefix "$DomainControllerAddress/32" ` -InterfaceIndex $SelectedInterface.InterfaceIndex -NextHop $SelectedInterface.NextHop ` -RouteMetric 1 $current = @(Find-NetRoute -RemoteIPAddress $DomainControllerAddress.IPAddressToString | Where-Object { $_.PSObject.Properties['IPAddress'] }) if (@($current | Where-Object InterfaceIndex -eq $SelectedInterface.InterfaceIndex).Count -eq 0) { $route | Remove-NetRoute -Confirm:$false throw "Windows still routes $DomainControllerAddress through another interface. Resolve conflicting host routes or VPN policies and retry." } } function Set-ClientDomainDns { param( [Parameter(Mandatory)][string]$DnsDomain, [Parameter(Mandatory)][ipaddress]$ServerAddress ) $displayName = "SGU domain DNS - $DnsDomain" $existing = @(Get-DnsClientNrptRule -ErrorAction Stop | Where-Object DisplayName -eq $displayName) if ($existing.Count -eq 1 -and @($existing[0].NameServers) -contains $ServerAddress.IPAddressToString -and @($existing[0].Namespace) -contains ".$DnsDomain" -and @($existing[0].Namespace) -contains $DnsDomain) { return } $existing | Remove-DnsClientNrptRule -Force Add-DnsClientNrptRule -Namespace @($DnsDomain, ".$DnsDomain") ` -NameServers $ServerAddress.IPAddressToString -DisplayName $displayName | Out-Null Clear-DnsClientCache } function Assert-ClientOperatingSystem { param( [Parameter(Mandatory)]$OperatingSystem, [Parameter(Mandatory)][string]$Edition, [Parameter(Mandatory)][string]$Architecture ) if ([int]$OperatingSystem.ProductType -ne 1) { throw 'The client bootstrap supports Windows 10/11 workstations. Use the server bootstrap on Windows Server.' } if ([int]$OperatingSystem.BuildNumber -lt 14393 -or $Architecture -ne 'AMD64') { throw 'This package requires Windows 10 1607 or later, or Windows 11, running x64 Windows PowerShell.' } if ($Edition -match '^Core' -or $Edition -match 'Home') { throw "Windows edition '$Edition' cannot join an Active Directory domain. Upgrade to Pro, Enterprise, or Education, then run this same bootstrap again." } } function Wait-TcpPort { param( [Parameter(Mandatory)][ipaddress]$Address, [Parameter(Mandatory)][int]$Port, [int]$TimeoutSeconds = 20 ) $deadline = (Get-Date).AddSeconds($TimeoutSeconds) do { if (Test-TcpPort -Address $Address -Port $Port -TimeoutMilliseconds 2000) { return $true } Start-Sleep -Milliseconds 750 } while ((Get-Date) -lt $deadline) return $false } function Connect-SguAzureP2s { param([Parameter(Mandatory)][string]$ConnectionName) $connection = Get-VpnConnection -Name $ConnectionName -AllUserConnection ` -ErrorAction SilentlyContinue if (-not $connection) { throw "The all-user VPN connection '$ConnectionName' is not installed. Run Install-SguAzureP2sClient.ps1 in this VM first." } if ($connection.TunnelType -notcontains 'Ikev2' -and $connection.TunnelType -ne 'Ikev2') { throw "The VPN connection '$ConnectionName' is not configured for IKEv2." } if ($connection.ConnectionStatus -ne 'Connected') { & "$env:SystemRoot\System32\rasdial.exe" $ConnectionName if ($LASTEXITCODE -ne 0) { throw "Could not connect the Azure P2S profile '$ConnectionName'. Verify the machine certificate and that UDP 500/4500 is permitted by the local network." } } $connection = Get-VpnConnection -Name $ConnectionName -AllUserConnection if ($connection.ConnectionStatus -ne 'Connected') { throw "The Azure P2S profile '$ConnectionName' did not reach Connected state." } return $connection } Assert-Administrator $operatingSystem = Get-CimInstance Win32_OperatingSystem $edition = (Get-WindowsEdition -Online).Edition Assert-ClientOperatingSystem -OperatingSystem $operatingSystem -Edition $edition ` -Architecture $env:PROCESSOR_ARCHITECTURE $windowsBuild = [int]$operatingSystem.BuildNumber $windowsName = if ($windowsBuild -ge 22000) { 'Windows 11' } else { 'Windows 10' } Write-Host "$windowsName (build $windowsBuild): unified SGU enrollment." if (-not $DomainControllerIPv4Address) { $DomainControllerIPv4Address = [ipaddress](Read-Host 'Fixed IPv4 address of the SGU domain controller') } if ($DomainControllerIPv4Address.AddressFamily -ne [Net.Sockets.AddressFamily]::InterNetwork -or $DomainControllerIPv4Address.IPAddressToString -match '^(0\.|127\.|169\.254\.|22[4-9]\.|23\d\.|24\d\.|25[0-5]\.)') { throw 'Enter a reachable unicast IPv4 address for the domain controller.' } $packageRoot = $PSScriptRoot $packageManifest = Assert-PackageManifest -PackageRoot $packageRoot # Retain the old parameter for existing automation; neither name restricts networking. if ($CompatibilityProfile -ne 'Auto') { Write-Warning 'CompatibilityProfile is deprecated. This package uses the same implementation on Windows 10 and 11.' } $CompatibilityProfile = 'Auto' $scriptsRoot = Join-Path $packageRoot 'payload\scripts' $providerPublishPath = Join-Path $packageRoot 'payload\credential-provider' $runtimeInstaller = Get-ChildItem (Join-Path $packageRoot 'payload\prerequisites') ` -Filter '*x64*.exe' -File -ErrorAction SilentlyContinue | Sort-Object Name -Descending | 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)) { throw "The client bootstrap package is incomplete: $requiredPath" } } if (-not $runtimeInstaller) { throw 'The offline Microsoft .NET 10 x64 runtime installer is missing from the client package.' } if (-not $PSCmdlet.ShouldProcess($env:COMPUTERNAME, "Enroll with SGU server $DomainControllerIPv4Address")) { return } if ($ClientIPv4Address) { if (-not $NetworkInterfaceAlias -or $ConnectivityMode -eq 'AzureP2S') { throw 'Explicit static IP setup requires -NetworkInterfaceAlias with Direct connectivity. Omit -ClientIPv4Address to preserve the current LAN/VPN configuration.' } $ClientIPv4Address = Set-ClientDomainAddress -InterfaceAlias $NetworkInterfaceAlias ` -DomainControllerAddress $DomainControllerIPv4Address ` -RequestedAddress $ClientIPv4Address -PrefixLength $ClientPrefixLength } if ($ConnectivityMode -eq 'AzureP2S') { $existingVpnConnection = Get-VpnConnection -Name $VpnConnectionName -AllUserConnection ` -ErrorAction SilentlyContinue if (-not $existingVpnConnection) { $installerPath = Join-Path $packageRoot 'Install-SguAzureP2sClient.ps1' if (-not (Test-Path -LiteralPath $installerPath -PathType Leaf)) { throw 'Install-SguAzureP2sClient.ps1 is missing from the client bootstrap package.' } foreach ($vpnInput in @( @{ Name = 'VpnProfilePackagePath'; Value = $VpnProfilePackagePath }, @{ Name = 'VpnClientCertificatePfxPath'; Value = $VpnClientCertificatePfxPath }, @{ Name = 'VpnClientRootCertificatePath'; Value = $VpnClientRootCertificatePath })) { if ([string]::IsNullOrWhiteSpace([string]$vpnInput.Value)) { throw "$($vpnInput.Name) is required the first time an Azure P2S client is enrolled." } } $vpnInstallParameters = @{ VpnProfilePackagePath = $VpnProfilePackagePath ClientCertificatePfxPath = $VpnClientCertificatePfxPath ClientRootCertificatePath = $VpnClientRootCertificatePath ConnectionName = $VpnConnectionName AzureNetworkPrefixes = $AzureNetworkPrefixes DomainControllerIPv4Address = $DomainControllerIPv4Address DomainName = $DomainName } if ($VpnClientCertificatePfxPassword) { $vpnInstallParameters.ClientCertificatePfxPassword = $VpnClientCertificatePfxPassword } & $installerPath @vpnInstallParameters | Out-Null } $vpnConnection = Connect-SguAzureP2s -ConnectionName $VpnConnectionName } $selectedInterface = Wait-ClientInterface -RequestedAlias $NetworkInterfaceAlias ` -DomainControllerAddress $DomainControllerIPv4Address $NetworkInterfaceAlias = $selectedInterface.InterfaceAlias $ClientIPv4Address = [ipaddress]$selectedInterface.IPAddress Set-ClientServerRoute -SelectedInterface $selectedInterface -DomainControllerAddress $DomainControllerIPv4Address Write-Host "Using $NetworkInterfaceAlias ($ClientIPv4Address)." if (-not (Wait-TcpPort -Address $DomainControllerIPv4Address -Port 5985 -TimeoutSeconds 20)) { throw "The domain controller at $DomainControllerIPv4Address did not accept WinRM on TCP 5985 after 20 seconds. Run the server bootstrap first and verify the selected IP." } if (-not $DomainCredential) { $suggestedUser = if ($DomainNetbios) { "$DomainNetbios\Administrator" } elseif ($DomainName) { "Administrator@$DomainName" } else { 'Administrator' } $DomainCredential = Get-Credential ` -UserName $suggestedUser ` -Message "Domain account permitted to enroll this computer (DOMAIN\user or user@domain)" } if (-not $DomainCredential) { throw 'Enrollment cancelled: no domain credential was provided.' } New-Item -ItemType Directory -Path $temporaryRoot -Force | Out-Null $clientCertificatePath = Join-Path $temporaryRoot 'client.cer' $serverCertificatePath = Join-Path $temporaryRoot 'server.cer' $remoteRegistrationScript = $null $remoteClientCertificate = $null $session = $null $winRmWasRunning = (Get-Service WinRM).Status -eq 'Running' $priorTrustedHosts = $null try { if (-not $winRmWasRunning) { Set-Service WinRM -StartupType Manual Start-Service WinRM } $priorTrustedHosts = [string](Get-Item WSMan:\localhost\Client\TrustedHosts).Value $trustedHostValues = @($priorTrustedHosts -split ',' | ForEach-Object Trim | Where-Object { $_ }) if ($trustedHostValues -notcontains $DomainControllerIPv4Address.IPAddressToString) { $trustedHostValues += $DomainControllerIPv4Address.IPAddressToString Set-Item WSMan:\localhost\Client\TrustedHosts -Value ($trustedHostValues -join ',') -Force } $session = New-PSSession ` -ComputerName $DomainControllerIPv4Address.IPAddressToString ` -Credential $DomainCredential ` -Authentication Negotiate $serverIdentity = Invoke-Command -Session $session -ScriptBlock { $computer = Get-CimInstance Win32_ComputerSystem if ([int]$computer.DomainRole -lt 4) { throw 'The supplied server is not an Active Directory domain controller.' } Import-Module ActiveDirectory -ErrorAction Stop $domain = Get-ADDomain -ErrorAction Stop $labOu = Get-ADOrganizationalUnit -LDAPFilter '(ou=Laboratorio)' ` -SearchBase $domain.DistinguishedName -SearchScope OneLevel -ErrorAction Stop | Select-Object -First 1 $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 DomainNetbios = $domain.NetBIOSName ComputerContainer = if ($labOu) { $labOu.DistinguishedName } else { $domain.ComputersContainer } 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 ($DomainName -and -not $serverIdentity.Domain.Equals($DomainName, [StringComparison]::OrdinalIgnoreCase))) { throw "The server at $DomainControllerIPv4Address belongs to $($serverIdentity.Domain), not $DomainName." } if ($DomainNetbios -and $DomainNetbios -ne $serverIdentity.DomainNetbios) { throw "The supplied NetBIOS domain '$DomainNetbios' does not match '$($serverIdentity.DomainNetbios)'." } $DomainName = [string]$serverIdentity.Domain $DomainNetbios = [string]$serverIdentity.DomainNetbios if (-not $ComputerOuDn) { $ComputerOuDn = [string]$serverIdentity.ComputerContainer } $brokerDnsName = "$brokerRecordName.$DomainName" $brokerEndpoint = "https://${brokerDnsName}:8443/v1/authenticate" Write-Host "Discovered domain: $DomainName ($DomainNetbios). Computer container: $ComputerOuDn" 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." } Set-ClientDomainDns -DnsDomain $DomainName -ServerAddress $DomainControllerIPv4Address foreach ($port in @(53, 88, 135, 389, 445, 8443)) { if (-not (Test-TcpPort -Address $DomainControllerIPv4Address -Port $port -TimeoutMilliseconds 2000)) { throw "Server $DomainControllerIPv4Address is reachable, but required TCP port $port is unavailable. Check AD/SGU services and the LAN/VPN firewall. Domain join has not started." } } $certificateSubject = "CN=SGU Credential Provider Client $env:COMPUTERNAME" $clientCertificate = Get-ChildItem Cert:\LocalMachine\My | Where-Object { $_.Subject -eq $certificateSubject -and $_.HasPrivateKey -and $_.NotAfter -gt (Get-Date).AddDays(30) } | Sort-Object NotAfter -Descending | Select-Object -First 1 if (-not $clientCertificate) { $clientCertificate = New-SelfSignedCertificate ` -Subject $certificateSubject ` -CertStoreLocation Cert:\LocalMachine\My ` -KeyAlgorithm RSA ` -KeyLength 3072 ` -HashAlgorithm SHA256 ` -KeyExportPolicy NonExportable ` -NotAfter (Get-Date).AddYears(2) ` -TextExtension @('2.5.29.37={text}1.3.6.1.5.5.7.3.2') } Export-Certificate -Cert $clientCertificate -FilePath $clientCertificatePath -Force | Out-Null if (-not (Get-ChildItem Cert:\LocalMachine\Root | Where-Object Thumbprint -eq $clientCertificate.Thumbprint)) { Import-Certificate -FilePath $clientCertificatePath ` -CertStoreLocation Cert:\LocalMachine\Root | Out-Null } $remoteTemporaryRoot = Invoke-Command -Session $session -ScriptBlock { $path = Join-Path $env:ProgramData ("SGU\Enrollment\Incoming-" + [Guid]::NewGuid().ToString('N')) New-Item -ItemType Directory -Path $path -Force | Out-Null $path } $remoteRegistrationScript = Join-Path $remoteTemporaryRoot 'Register-SguClientCertificate.ps1' $remoteClientCertificate = Join-Path $remoteTemporaryRoot 'client.cer' Copy-Item -LiteralPath (Join-Path $scriptsRoot 'Register-SguClientCertificate.ps1') ` -Destination $remoteRegistrationScript -ToSession $session Copy-Item -LiteralPath $clientCertificatePath ` -Destination $remoteClientCertificate -ToSession $session Invoke-Command -Session $session -ScriptBlock { param($RegistrationScript, $CertificatePath) & $RegistrationScript -CertificatePath $CertificatePath | Out-Null } -ArgumentList $remoteRegistrationScript,$remoteClientCertificate $serverCertificateBase64 = Invoke-Command -Session $session -ScriptBlock { param($ExpectedSubject) $certificate = Get-ChildItem Cert:\LocalMachine\My | Where-Object { $_.Subject -eq "CN=$ExpectedSubject" -and $_.HasPrivateKey -and $_.NotAfter -gt (Get-Date) } | Sort-Object NotAfter -Descending | Select-Object -First 1 if (-not $certificate) { throw "The broker certificate for $ExpectedSubject is missing." } [Convert]::ToBase64String($certificate.RawData) } -ArgumentList $brokerDnsName [IO.File]::WriteAllBytes($serverCertificatePath, [Convert]::FromBase64String($serverCertificateBase64)) $serverCertificate = [Security.Cryptography.X509Certificates.X509Certificate2]::new($serverCertificatePath) Import-Certificate -FilePath $serverCertificatePath ` -CertStoreLocation Cert:\LocalMachine\Root | Out-Null Resolve-DnsName -Type SRV "_ldap._tcp.dc._msdcs.$DomainName" -ErrorAction Stop | Out-Null $brokerAddress = Resolve-DnsName -Name $brokerDnsName -Type A -ErrorAction Stop | Where-Object IPAddress -eq $DomainControllerIPv4Address.IPAddressToString if (-not $brokerAddress) { throw "$brokerDnsName does not resolve to $DomainControllerIPv4Address." } $enrollmentParameters = @{ PublishPath = $providerPublishPath BrokerEndpoint = $brokerEndpoint ClientCertificateThumbprint = $clientCertificate.Thumbprint ServerCertificateThumbprint = $serverCertificate.Thumbprint DomainCredential = $DomainCredential DomainName = $DomainName DomainNetbios = $DomainNetbios ComputerOuDn = $ComputerOuDn NetworkInterfaceAlias = $NetworkInterfaceAlias DomainDnsServerAddresses = @($DomainControllerIPv4Address.IPAddressToString) DomainDnsConfigured = $true ConnectivityMode = $ConnectivityMode RemoteDesktopPrincipal = "$DomainNetbios\SG-Laboratorio-Usuarios-RDP" DotNetRuntimeInstallerPath = $runtimeInstaller.FullName RustDeskServerAddress = $serverIdentity.RustDeskServerAddress RustDeskServerPublicKey = $serverIdentity.RustDeskPublicKey SkipRestart = $true } if ($NewComputerName) { $enrollmentParameters.NewComputerName = $NewComputerName } $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) { if ($remoteRegistrationScript -or $remoteClientCertificate) { Invoke-Command -Session $session -ScriptBlock { param($Paths) foreach ($path in $Paths) { if ($path) { Remove-Item -LiteralPath $path -Force -ErrorAction SilentlyContinue } } if ($Paths.Count -gt 0 -and $Paths[0]) { Remove-Item -LiteralPath (Split-Path $Paths[0] -Parent) ` -Force -ErrorAction SilentlyContinue } } -ArgumentList (,@($remoteRegistrationScript,$remoteClientCertificate)) ` -ErrorAction SilentlyContinue } Remove-PSSession $session } if ($null -ne $priorTrustedHosts) { Set-Item WSMan:\localhost\Client\TrustedHosts -Value $priorTrustedHosts -Force } if (-not $winRmWasRunning) { Stop-Service WinRM -Force -NoWait -WarningAction SilentlyContinue ` -ErrorAction SilentlyContinue } Remove-Item -LiteralPath $temporaryRoot -Recurse -Force -ErrorAction SilentlyContinue $DomainCredential = $null $VpnClientCertificatePfxPassword = $null } if ($SkipRestart) { [pscustomobject]@{ ComputerName = if ($NewComputerName) { $NewComputerName } else { $env:COMPUTERNAME } DomainName = $DomainName ProviderInstalled = $true ClientCertificateRegistered = $true BrokerEndpoint = $brokerEndpoint ConnectivityMode = $ConnectivityMode CompatibilityProfile = $CompatibilityProfile VpnConnectionName = if ($ConnectivityMode -eq 'AzureP2S') { $VpnConnectionName } else { $null } RestartRequired = $true RustDesk = if ($result) { $result.RustDesk } else { $null } EnrollmentResult = $result } return } Restart-Computer -Force