Add one-command server and client bootstraps

This commit is contained in:
2026-09-03 16:34:21 -06:00
parent 742ae9c2b5
commit 3a10098239
19 changed files with 1569 additions and 37 deletions
+327
View File
@@ -0,0 +1,327 @@
#Requires -Version 5.1
[CmdletBinding(SupportsShouldProcess)]
param(
[ipaddress]$DomainControllerIPv4Address,
[string]$NetworkInterfaceAlias,
[PSCredential]$DomainCredential,
[string]$DomainName = 'lci.lasalle.mx',
[string]$DomainNetbios = 'LCI',
[string]$ComputerOuDn,
[string]$NewComputerName,
[switch]$SkipRestart
)
$ErrorActionPreference = 'Stop'
$brokerRecordName = 'sgu-auth'
$brokerDnsName = "$brokerRecordName.$DomainName"
$brokerEndpoint = "https://${brokerDnsName}:8443/v1/authenticate"
$temporaryRoot = Join-Path $env:ProgramData ("SGU\Bootstrap\Client-" + [Guid]::NewGuid().ToString('N'))
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)"
}
}
}
function Resolve-ClientInterfaceAlias {
param([string]$RequestedAlias)
if ($RequestedAlias) {
Get-NetAdapter -Name $RequestedAlias -ErrorAction Stop | Out-Null
return $RequestedAlias
}
$defaultRoute = Get-NetRoute -AddressFamily IPv4 -DestinationPrefix '0.0.0.0/0' `
-ErrorAction SilentlyContinue |
Sort-Object RouteMetric,InterfaceMetric |
Select-Object -First 1
if ($defaultRoute) {
return [string](Get-NetAdapter -InterfaceIndex $defaultRoute.InterfaceIndex).Name
}
$upAdapters = @(Get-NetAdapter | Where-Object Status -eq 'Up')
if ($upAdapters.Count -eq 1) {
return [string]$upAdapters[0].Name
}
$aliases = ($upAdapters.Name | Sort-Object) -join ', '
throw "Could not select a network adapter. Re-run with -NetworkInterfaceAlias. Available adapters: $aliases"
}
function Test-TcpPort {
param(
[Parameter(Mandatory)][ipaddress]$Address,
[Parameter(Mandatory)][int]$Port,
[int]$TimeoutMilliseconds = 5000
)
$client = [Net.Sockets.TcpClient]::new()
try {
$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()
}
}
Assert-Administrator
$operatingSystem = Get-CimInstance Win32_OperatingSystem
if ([int]$operatingSystem.ProductType -ne 1) {
throw 'The client bootstrap supports Windows 10/11 workstations. Use the server bootstrap on Windows Server.'
}
$edition = (Get-WindowsEdition -Online).Edition
if ($edition -match '^Core' -or $edition -match 'Home') {
throw "Windows edition '$edition' cannot join an on-premises Active Directory domain or host RDP. Upgrade to Pro, Enterprise, or Education, then run this same bootstrap again."
}
if (-not $DomainControllerIPv4Address) {
$DomainControllerIPv4Address = [ipaddress](Read-Host 'Fixed IPv4 address of the SGU domain controller')
}
if (-not $ComputerOuDn) {
$baseDn = (($DomainName -split '\.') | ForEach-Object { "DC=$_" }) -join ','
$ComputerOuDn = "OU=Laboratorio,$baseDn"
}
$packageRoot = $PSScriptRoot
Assert-PackageManifest -PackageRoot $packageRoot
$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 '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.'
}
$NetworkInterfaceAlias = Resolve-ClientInterfaceAlias -RequestedAlias $NetworkInterfaceAlias
Set-DnsClientServerAddress -InterfaceAlias $NetworkInterfaceAlias `
-ServerAddresses $DomainControllerIPv4Address.IPAddressToString
if (-not (Test-TcpPort -Address $DomainControllerIPv4Address -Port 5985)) {
throw "The domain controller at $DomainControllerIPv4Address is not accepting WinRM on TCP 5985. Run the server bootstrap first and verify the selected IP."
}
if (-not $DomainCredential) {
$DomainCredential = Get-Credential `
-UserName "$DomainNetbios\Administrator" `
-Message "Credential permitted to enroll this computer in $DomainName"
}
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
$brokerService = Get-Service SGUAuthBroker -ErrorAction SilentlyContinue
[pscustomobject]@{
ComputerName = $env:COMPUTERNAME
Domain = $computer.Domain
BrokerService = if ($brokerService) { $brokerService.Status.ToString() } else { 'Missing' }
}
}
if (-not $serverIdentity.Domain -or
-not $serverIdentity.Domain.Equals($DomainName, [StringComparison]::OrdinalIgnoreCase)) {
throw "The server at $DomainControllerIPv4Address belongs to $($serverIdentity.Domain), not $DomainName."
}
if ($serverIdentity.BrokerService -ne 'Running') {
throw "The SGU Authentication Broker is not running on $($serverIdentity.ComputerName)."
}
$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)
RemoteDesktopPrincipal = "$DomainNetbios\SG-Laboratorio-Usuarios-RDP"
DotNetRuntimeInstallerPath = $runtimeInstaller.FullName
SkipRestart = $true
}
if ($NewComputerName) {
$enrollmentParameters.NewComputerName = $NewComputerName
}
$result = & (Join-Path $scriptsRoot 'Enroll-SguDomainClient.ps1') @enrollmentParameters
}
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 -ErrorAction SilentlyContinue
}
Remove-Item -LiteralPath $temporaryRoot -Recurse -Force -ErrorAction SilentlyContinue
$DomainCredential = $null
}
if ($SkipRestart) {
[pscustomobject]@{
ComputerName = if ($NewComputerName) { $NewComputerName } else { $env:COMPUTERNAME }
DomainName = $DomainName
ProviderInstalled = $true
ClientCertificateRegistered = $true
BrokerEndpoint = $brokerEndpoint
RestartRequired = $true
EnrollmentResult = $result
}
return
}
Restart-Computer -Force