Files
SGU-CredentialProvider/scripts/Initialize-SguDomainController.ps1
T

849 lines
37 KiB
PowerShell

#Requires -Version 5.1
[CmdletBinding(SupportsShouldProcess)]
param(
[ipaddress]$ServerIPv4Address,
[ValidateRange(1, 32)]
[int]$PrefixLength = 24,
[string]$NetworkInterfaceAlias,
[ipaddress]$DefaultGateway,
[ValidateSet('GuestStatic', 'PlatformManaged')]
[string]$NetworkConfigurationMode = 'GuestStatic',
[string[]]$TrustedClientNetworks = @(),
[ipaddress[]]$DnsForwarders = @(),
[string]$DomainName = 'lci.lasalle.mx',
[string]$DomainNetbios = 'LCI',
[string]$BrokerRecordName = 'sgu-auth',
[string]$RustDeskRecordName = 'rustdesk',
[string]$PackageSharePath = 'C:\Packages',
[securestring]$SafeModeAdministratorPassword,
[switch]$SkipRestart,
[switch]$Resume
)
$ErrorActionPreference = 'Stop'
$bootstrapRoot = Join-Path $env:ProgramData 'SGU\Bootstrap\Server'
$statePath = Join-Path $bootstrapRoot 'bootstrap-state.json'
$completionPath = Join-Path $bootstrapRoot 'bootstrap-complete.json'
$logPath = Join-Path $bootstrapRoot 'bootstrap.log'
$taskName = 'SGU-Complete-Domain-Controller-Bootstrap'
function Write-BootstrapLog {
param([Parameter(Mandatory)][string]$Message)
$line = '{0:u} {1}' -f (Get-Date), $Message
Write-Host $line
if (Test-Path -LiteralPath $bootstrapRoot) {
Add-Content -LiteralPath $logPath -Value $line -Encoding UTF8
}
}
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 server 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 Get-DomainBaseDn {
param([Parameter(Mandatory)][string]$DnsDomainName)
return (($DnsDomainName -split '\.') | ForEach-Object { "DC=$_" }) -join ','
}
function Test-PrivateIPv4Address {
param([Parameter(Mandatory)][ipaddress]$Address)
if ($Address.AddressFamily -ne [Net.Sockets.AddressFamily]::InterNetwork) {
return $false
}
$bytes = $Address.GetAddressBytes()
return $bytes[0] -eq 10 -or
($bytes[0] -eq 172 -and $bytes[1] -ge 16 -and $bytes[1] -le 31) -or
($bytes[0] -eq 192 -and $bytes[1] -eq 168)
}
function ConvertTo-NetworkCidr {
param(
[Parameter(Mandatory)][ipaddress]$Address,
[Parameter(Mandatory)][ValidateRange(1, 32)][int]$NetworkPrefixLength
)
if ($Address.AddressFamily -ne [Net.Sockets.AddressFamily]::InterNetwork) {
throw 'Only IPv4 networks are supported by the SGU bootstrap.'
}
$addressBytes = $Address.GetAddressBytes()
$networkBytes = [byte[]]::new(4)
$remainingBits = $NetworkPrefixLength
for ($index = 0; $index -lt 4; $index++) {
$mask = if ($remainingBits -ge 8) {
255
}
elseif ($remainingBits -le 0) {
0
}
else {
256 - [Math]::Pow(2, 8 - $remainingBits)
}
$networkBytes[$index] = [byte]($addressBytes[$index] -band [int]$mask)
$remainingBits -= 8
}
return "$(($networkBytes | ForEach-Object { [string]$_ }) -join '.')/$NetworkPrefixLength"
}
function ConvertTo-PrivateNetworkCidr {
param([Parameter(Mandatory)][string]$Cidr)
if ($Cidr -notmatch '^([^/]+)/(\d{1,2})$') {
throw "Trusted client network '$Cidr' must use IPv4 CIDR notation, for example 172.30.0.0/24."
}
$address = $null
if (-not [ipaddress]::TryParse($Matches[1], [ref]$address) -or
$address.AddressFamily -ne [Net.Sockets.AddressFamily]::InterNetwork) {
throw "Trusted client network '$Cidr' is not a valid IPv4 network."
}
$networkPrefixLength = [int]$Matches[2]
if ($networkPrefixLength -lt 1 -or $networkPrefixLength -gt 32) {
throw "Trusted client network '$Cidr' has an invalid prefix length."
}
if (-not (Test-PrivateIPv4Address -Address $address)) {
throw "Trusted client network '$Cidr' is not private RFC1918 space. The bootstrap never exposes AD services to public client addresses."
}
return ConvertTo-NetworkCidr -Address $address -NetworkPrefixLength $networkPrefixLength
}
function Resolve-PrivateInterfaceAlias {
param([string]$RequestedAlias)
if ($RequestedAlias) {
Get-NetAdapter -Name $RequestedAlias -ErrorAction Stop | Out-Null
return $RequestedAlias
}
$upAdapters = @(Get-NetAdapter | Where-Object Status -eq 'Up')
$withoutGateway = @($upAdapters | Where-Object {
-not (Get-NetIPConfiguration -InterfaceIndex $_.ifIndex).IPv4DefaultGateway
})
if ($withoutGateway.Count -eq 1) {
return [string]$withoutGateway[0].Name
}
if ($upAdapters.Count -eq 1) {
return [string]$upAdapters[0].Name
}
$aliases = ($upAdapters.Name | Sort-Object) -join ', '
throw "Could not select the private domain adapter unambiguously. Re-run with -NetworkInterfaceAlias. Available adapters: $aliases"
}
function Set-StaticDomainAddress {
param(
[Parameter(Mandatory)][string]$InterfaceAlias,
[Parameter(Mandatory)][ipaddress]$Address,
[Parameter(Mandatory)][int]$NetworkPrefixLength,
[ipaddress]$Gateway
)
$adapter = Get-NetAdapter -Name $InterfaceAlias -ErrorAction Stop
Set-NetIPInterface -InterfaceIndex $adapter.ifIndex -AddressFamily IPv4 -Dhcp Disabled
$addresses = @(Get-NetIPAddress -InterfaceIndex $adapter.ifIndex -AddressFamily IPv4 -ErrorAction SilentlyContinue |
Where-Object PrefixOrigin -ne 'WellKnown')
foreach ($existingAddress in $addresses) {
if ($existingAddress.IPAddress -ne $Address.IPAddressToString -or
[int]$existingAddress.PrefixLength -ne $NetworkPrefixLength) {
Remove-NetIPAddress -InputObject $existingAddress -Confirm:$false
}
}
$matchingAddress = Get-NetIPAddress -InterfaceIndex $adapter.ifIndex -AddressFamily IPv4 `
-IPAddress $Address.IPAddressToString -ErrorAction SilentlyContinue
if (-not $matchingAddress) {
$addressParameters = @{
InterfaceIndex = $adapter.ifIndex
IPAddress = $Address.IPAddressToString
PrefixLength = $NetworkPrefixLength
AddressFamily = 'IPv4'
}
if ($Gateway) {
$addressParameters.DefaultGateway = $Gateway.IPAddressToString
}
New-NetIPAddress @addressParameters | Out-Null
}
if ($Gateway) {
$defaultRoutes = @(Get-NetRoute -InterfaceIndex $adapter.ifIndex `
-AddressFamily IPv4 -DestinationPrefix '0.0.0.0/0' -ErrorAction SilentlyContinue)
foreach ($route in $defaultRoutes) {
if ($route.NextHop -ne $Gateway.IPAddressToString) {
Remove-NetRoute -InputObject $route -Confirm:$false
}
}
if (-not (Get-NetRoute -InterfaceIndex $adapter.ifIndex -AddressFamily IPv4 `
-DestinationPrefix '0.0.0.0/0' -ErrorAction SilentlyContinue |
Where-Object NextHop -eq $Gateway.IPAddressToString)) {
New-NetRoute -InterfaceIndex $adapter.ifIndex -AddressFamily IPv4 `
-DestinationPrefix '0.0.0.0/0' -NextHop $Gateway.IPAddressToString | Out-Null
}
}
Set-DnsClientServerAddress -InterfaceIndex $adapter.ifIndex `
-ServerAddresses $Address.IPAddressToString
}
function Assert-PlatformManagedDomainAddress {
param(
[Parameter(Mandatory)][string]$InterfaceAlias,
[Parameter(Mandatory)][ipaddress]$Address,
[Parameter(Mandatory)][int]$NetworkPrefixLength
)
$adapter = Get-NetAdapter -Name $InterfaceAlias -ErrorAction Stop
$matchingAddress = Get-NetIPAddress -InterfaceIndex $adapter.ifIndex -AddressFamily IPv4 `
-IPAddress $Address.IPAddressToString -ErrorAction SilentlyContinue |
Where-Object PrefixLength -eq $NetworkPrefixLength |
Select-Object -First 1
if (-not $matchingAddress) {
$observed = @(Get-NetIPAddress -InterfaceIndex $adapter.ifIndex -AddressFamily IPv4 `
-ErrorAction SilentlyContinue |
Where-Object PrefixOrigin -ne 'WellKnown' |
ForEach-Object { "$($_.IPAddress)/$($_.PrefixLength)" }) -join ', '
throw "PlatformManaged mode expected $Address/$NetworkPrefixLength on $InterfaceAlias, but found: $observed. Configure a static private IP on the Azure NIC before running the bootstrap; do not assign it inside Windows."
}
}
function Register-ResumeTask {
param([Parameter(Mandatory)][string]$ScriptPath)
$powerShell = "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe"
$action = New-ScheduledTaskAction -Execute $powerShell `
-Argument "-NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -File `"$ScriptPath`" -Resume"
$trigger = New-ScheduledTaskTrigger -AtStartup
$trigger.Delay = 'PT1M'
$settings = New-ScheduledTaskSettingsSet `
-StartWhenAvailable `
-ExecutionTimeLimit (New-TimeSpan -Minutes 30) `
-RestartCount 3 `
-RestartInterval (New-TimeSpan -Minutes 2)
Register-ScheduledTask -TaskName $taskName -Action $action -Trigger $trigger `
-Settings $settings -User 'SYSTEM' -RunLevel Highest -Force | Out-Null
}
function Ensure-OrganizationalUnit {
param(
[Parameter(Mandatory)][string]$Name,
[Parameter(Mandatory)][string]$Path,
[Parameter(Mandatory)][string]$Server
)
$distinguishedName = "OU=$Name,$Path"
$escapedName = $Name.Replace('\', '\5c').Replace('*', '\2a').Replace('(', '\28').Replace(')', '\29')
$existing = Get-ADOrganizationalUnit -LDAPFilter "(ou=$escapedName)" `
-SearchBase $Path -SearchScope OneLevel -Server $Server -ErrorAction Stop |
Select-Object -First 1
if (-not $existing) {
New-ADOrganizationalUnit -Name $Name -Path $Path `
-ProtectedFromAccidentalDeletion $true -Server $Server | Out-Null
}
return $distinguishedName
}
function Wait-ActiveDirectoryReady {
param(
[Parameter(Mandatory)][string]$ExpectedBaseDn,
[ValidateRange(1, 120)][int]$Attempts = 36,
[ValidateRange(1, 30)][int]$DelaySeconds = 5
)
for ($attempt = 1; $attempt -le $Attempts; $attempt++) {
try {
$rootDse = Get-ADRootDSE -Server localhost -ErrorAction Stop
if ($rootDse.DefaultNamingContext -eq $ExpectedBaseDn) {
return
}
}
catch {
if ($attempt -eq $Attempts) {
throw
}
}
Start-Sleep -Seconds $DelaySeconds
}
throw "Active Directory did not publish $ExpectedBaseDn before the readiness timeout."
}
function Wait-DnsZoneReady {
param(
[Parameter(Mandatory)][string]$ZoneName,
[Parameter(Mandatory)][ipaddress]$DnsServer,
[ValidateRange(1, 120)][int]$Attempts = 30,
[ValidateRange(1, 30)][int]$DelaySeconds = 2
)
for ($attempt = 1; $attempt -le $Attempts; $attempt++) {
try {
$soa = @(Resolve-DnsName $ZoneName -Type SOA -DnsOnly `
-Server $DnsServer.IPAddressToString -ErrorAction Stop |
Where-Object Type -eq SOA)
if ($soa.Count -gt 0) {
return
}
}
catch {
if ($attempt -eq $Attempts) {
throw
}
}
Start-Sleep -Seconds $DelaySeconds
}
throw "DNS did not load the $ZoneName zone before the readiness timeout."
}
function Set-PackageShare {
param(
[Parameter(Mandatory)][string]$Path,
[Parameter(Mandatory)][string]$NetbiosName,
[Parameter(Mandatory)][string]$DomainSid
)
New-Item -ItemType Directory -Path $Path -Force | Out-Null
$domainAdminsSid = [Security.Principal.SecurityIdentifier]::new("$DomainSid-512")
$domainUsersSid = [Security.Principal.SecurityIdentifier]::new("$DomainSid-513")
$domainComputersSid = [Security.Principal.SecurityIdentifier]::new("$DomainSid-515")
$systemSid = [Security.Principal.SecurityIdentifier]::new('S-1-5-18')
$acl = New-Object Security.AccessControl.DirectorySecurity
$acl.SetAccessRuleProtection($true, $false)
$inheritance = [Security.AccessControl.InheritanceFlags]'ContainerInherit,ObjectInherit'
$propagation = [Security.AccessControl.PropagationFlags]::None
$allow = [Security.AccessControl.AccessControlType]::Allow
$acl.AddAccessRule([Security.AccessControl.FileSystemAccessRule]::new(
$systemSid, [Security.AccessControl.FileSystemRights]::FullControl,
$inheritance, $propagation, $allow))
$acl.AddAccessRule([Security.AccessControl.FileSystemAccessRule]::new(
$domainAdminsSid, [Security.AccessControl.FileSystemRights]::FullControl,
$inheritance, $propagation, $allow))
$acl.AddAccessRule([Security.AccessControl.FileSystemAccessRule]::new(
$domainComputersSid, [Security.AccessControl.FileSystemRights]'ReadAndExecute, Synchronize',
$inheritance, $propagation, $allow))
$acl.AddAccessRule([Security.AccessControl.FileSystemAccessRule]::new(
$domainUsersSid, [Security.AccessControl.FileSystemRights]'ReadAndExecute, Synchronize',
$inheritance, $propagation, $allow))
Set-Acl -LiteralPath $Path -AclObject $acl
$domainAdmins = $domainAdminsSid.Translate([Security.Principal.NTAccount]).Value
$domainUsers = $domainUsersSid.Translate([Security.Principal.NTAccount]).Value
$domainComputers = $domainComputersSid.Translate([Security.Principal.NTAccount]).Value
$share = Get-SmbShare -Name Packages -ErrorAction SilentlyContinue
if ($share -and $share.Path -ne $Path) {
throw "The existing Packages share points to $($share.Path), not $Path."
}
if (-not $share) {
New-SmbShare -Name Packages -Path $Path -FullAccess $domainAdmins `
-ReadAccess $domainComputers,$domainUsers -FolderEnumerationMode AccessBased | Out-Null
}
else {
Grant-SmbShareAccess -Name Packages -AccountName $domainAdmins `
-AccessRight Full -Force | Out-Null
Grant-SmbShareAccess -Name Packages -AccountName $domainComputers `
-AccessRight Read -Force | Out-Null
Grant-SmbShareAccess -Name Packages -AccountName $domainUsers `
-AccessRight Read -Force | Out-Null
}
}
Assert-Administrator
trap {
Write-BootstrapLog ("ERROR: " + $_.Exception.Message)
throw
}
$operatingSystem = Get-CimInstance Win32_OperatingSystem
if ([int]$operatingSystem.ProductType -eq 1) {
throw 'The domain controller bootstrap requires Windows Server, not a Windows client edition.'
}
$existingState = $null
if (Test-Path -LiteralPath $statePath -PathType Leaf) {
$existingState = Get-Content -LiteralPath $statePath -Raw | ConvertFrom-Json
}
if ($Resume -or (-not $ServerIPv4Address -and $existingState)) {
if (-not $existingState) {
throw 'The persisted bootstrap state is missing; start the server bootstrap normally.'
}
$ServerIPv4Address = [ipaddress][string]$existingState.ServerIPv4Address
$PrefixLength = [int]$existingState.PrefixLength
$NetworkInterfaceAlias = [string]$existingState.NetworkInterfaceAlias
$DefaultGateway = if ($existingState.DefaultGateway) { [ipaddress][string]$existingState.DefaultGateway } else { $null }
$NetworkConfigurationMode = if ($existingState.NetworkConfigurationMode) { [string]$existingState.NetworkConfigurationMode } else { 'GuestStatic' }
$TrustedClientNetworks = if ($existingState.TrustedClientNetworks) { @($existingState.TrustedClientNetworks | ForEach-Object { [string]$_ }) } else { @() }
$DnsForwarders = @($existingState.DnsForwarders | ForEach-Object { [ipaddress][string]$_ })
$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
}
if (-not $ServerIPv4Address) {
$ServerIPv4Address = [ipaddress](Read-Host 'Fixed IPv4 address for this domain controller')
}
if (-not (Test-PrivateIPv4Address -Address $ServerIPv4Address)) {
throw 'ServerIPv4Address must be the private address of the domain controller. An Azure public IP is never assigned to AD or published in domain DNS.'
}
$domainSubnet = ConvertTo-NetworkCidr -Address $ServerIPv4Address `
-NetworkPrefixLength $PrefixLength
$TrustedClientNetworks = @($TrustedClientNetworks |
ForEach-Object { ConvertTo-PrivateNetworkCidr -Cidr $_ } |
Where-Object { $_ -ne $domainSubnet } |
Select-Object -Unique)
$allowedRemoteAddresses = @($domainSubnet) + $TrustedClientNetworks
$sourceRoot = $PSScriptRoot
if (-not $Resume) {
Assert-PackageManifest -PackageRoot $sourceRoot
New-Item -ItemType Directory -Path $bootstrapRoot -Force | Out-Null
if ((Resolve-Path -LiteralPath $sourceRoot).Path -ne (Resolve-Path -LiteralPath $bootstrapRoot).Path) {
Copy-Item -Path (Join-Path $sourceRoot '*') -Destination $bootstrapRoot -Recurse -Force
}
Assert-PackageManifest -PackageRoot $bootstrapRoot
}
else {
Assert-PackageManifest -PackageRoot $bootstrapRoot
}
$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'
foreach ($requiredPath in @(
$stagedScriptPath,
(Join-Path $scriptsRoot 'Deploy-AuthBroker.ps1'),
(Join-Path $scriptsRoot 'New-LabCertificate.ps1'),
(Join-Path $scriptsRoot 'Set-LabBrokerDns.ps1'),
(Join-Path $scriptsRoot 'Set-SguDomainComputerPolicies.ps1'),
(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-SguRustDeskLinuxEnrollment.ps1'),
(Join-Path $scriptsRoot 'Install-SguRustDeskServer.ps1'),
(Join-Path $scriptsRoot 'Invoke-SguRustDeskLinuxRegistrationProcessor.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"
}
}
if (-not $existingState) {
if ($DnsForwarders.Count -eq 0) {
$DnsForwarders = @(Get-DnsClientServerAddress -AddressFamily IPv4 |
Where-Object InterfaceAlias -ne $NetworkInterfaceAlias |
Select-Object -ExpandProperty ServerAddresses |
Where-Object { $_ -and $_ -ne $ServerIPv4Address.IPAddressToString } |
ForEach-Object { [ipaddress]$_ } |
Select-Object -Unique)
}
$existingState = [ordered]@{
Phase = 'Promote'
ServerIPv4Address = $ServerIPv4Address.IPAddressToString
PrefixLength = $PrefixLength
NetworkInterfaceAlias = $NetworkInterfaceAlias
DefaultGateway = if ($DefaultGateway) { $DefaultGateway.IPAddressToString } else { $null }
NetworkConfigurationMode = $NetworkConfigurationMode
TrustedClientNetworks = $TrustedClientNetworks
DnsForwarders = @($DnsForwarders | ForEach-Object IPAddressToString)
DomainName = $DomainName
DomainNetbios = $DomainNetbios
BrokerRecordName = $BrokerRecordName
RustDeskRecordName = $RustDeskRecordName
PackageSharePath = $PackageSharePath
}
[IO.File]::WriteAllText(
$statePath,
($existingState | ConvertTo-Json -Depth 4),
[Text.UTF8Encoding]::new($false))
}
if ($NetworkConfigurationMode -eq 'PlatformManaged') {
Write-BootstrapLog "Validating platform-managed address $ServerIPv4Address/$PrefixLength on $NetworkInterfaceAlias without changing DHCP, routes, or the Azure NIC."
Assert-PlatformManagedDomainAddress -InterfaceAlias $NetworkInterfaceAlias `
-Address $ServerIPv4Address -NetworkPrefixLength $PrefixLength
}
else {
Write-BootstrapLog "Configuring $NetworkInterfaceAlias as $ServerIPv4Address/$PrefixLength."
Set-StaticDomainAddress -InterfaceAlias $NetworkInterfaceAlias `
-Address $ServerIPv4Address -NetworkPrefixLength $PrefixLength -Gateway $DefaultGateway
}
$computer = Get-CimInstance Win32_ComputerSystem
if (-not $computer.PartOfDomain) {
if ([string]$existingState.Phase -eq 'Finalize') {
throw 'Active Directory promotion completed but Windows has not restarted. Restart the server to continue automatically.'
}
if (-not $SafeModeAdministratorPassword) {
$SafeModeAdministratorPassword = Read-Host `
'Directory Services Restore Mode password (not stored)' -AsSecureString
}
Write-BootstrapLog 'Installing Active Directory Domain Services, DNS, and management tools.'
Install-WindowsFeature AD-Domain-Services,DNS,GPMC,RSAT-AD-Tools `
-IncludeManagementTools | Out-Null
Register-ResumeTask -ScriptPath $stagedScriptPath
Write-BootstrapLog "Creating the $DomainName forest. Windows must restart once."
Install-ADDSForest `
-DomainName $DomainName `
-DomainNetbiosName $DomainNetbios `
-InstallDns `
-SafeModeAdministratorPassword $SafeModeAdministratorPassword `
-NoRebootOnCompletion `
-Force | Out-Null
$existingState.Phase = 'Finalize'
[IO.File]::WriteAllText(
$statePath,
($existingState | ConvertTo-Json -Depth 4),
[Text.UTF8Encoding]::new($false))
if ($SkipRestart) {
Write-BootstrapLog 'Promotion succeeded. Restart manually; finalization will resume at startup.'
return [pscustomobject]@{
Phase = 'AwaitingRestart'
DomainName = $DomainName
ServerIPv4Address = $ServerIPv4Address.IPAddressToString
ResumeTask = $taskName
}
}
Restart-Computer -Force
return
}
if (-not $computer.Domain.Equals($DomainName, [StringComparison]::OrdinalIgnoreCase)) {
throw "This server belongs to $($computer.Domain), not $DomainName."
}
Write-BootstrapLog 'Finalizing Active Directory, DNS, policies, broker, shares, and remote management.'
# Once the machine is a DC, every active adapter must query the local DNS
# service. Only the private domain adapter may publish its address in the AD
# zone; otherwise clients can receive the DHCP/NAT address of the Internet NIC.
Get-NetAdapter | Where-Object Status -eq 'Up' | ForEach-Object {
Set-DnsClientServerAddress -InterfaceIndex $_.ifIndex `
-ServerAddresses $ServerIPv4Address.IPAddressToString
Set-DnsClient -InterfaceIndex $_.ifIndex `
-RegisterThisConnectionsAddress:($_.Name -eq $NetworkInterfaceAlias)
}
Clear-DnsClientCache
Register-DnsClient
Import-Module ActiveDirectory -ErrorAction Stop
Wait-ActiveDirectoryReady -ExpectedBaseDn $baseDn
# A newly promoted Windows Server 2025 DC can retain the Public firewall
# profile because network identification ran before local DNS and LDAP were
# ready. A private-adapter bounce triggers the supported domain-detection path.
$domainProfile = Get-NetConnectionProfile -InterfaceAlias $NetworkInterfaceAlias `
-ErrorAction SilentlyContinue
if (-not $domainProfile -or $domainProfile.NetworkCategory -ne 'DomainAuthenticated') {
if ($NetworkConfigurationMode -eq 'GuestStatic') {
Write-BootstrapLog "Refreshing $NetworkInterfaceAlias so Windows detects the domain network profile."
Restart-NetAdapter -Name $NetworkInterfaceAlias -Confirm:$false
}
else {
# Restarting an Azure NIC from inside the guest can sever the only
# management path. Refresh NLA instead; this does not change the
# platform-managed address, DHCP lease, route, or link state.
Write-BootstrapLog 'Refreshing Network Location Awareness without restarting the Azure adapter.'
Restart-Service NlaSvc -Force -ErrorAction SilentlyContinue
}
for ($attempt = 1; $attempt -le 15; $attempt++) {
Start-Sleep -Seconds 2
$domainProfile = Get-NetConnectionProfile -InterfaceAlias $NetworkInterfaceAlias `
-ErrorAction SilentlyContinue
if ($domainProfile -and $domainProfile.NetworkCategory -eq 'DomainAuthenticated') {
break
}
}
}
# Apply the single-address DNS listener only after any adapter refresh. That
# avoids transient DNS socket errors while the private address is momentarily
# unavailable, while still preventing the Internet/NAT address from being
# published once finalization completes.
New-ItemProperty `
-Path 'HKLM:\SYSTEM\CurrentControlSet\Services\DNS\Parameters' `
-Name PublishAddresses `
-PropertyType String `
-Value $ServerIPv4Address.IPAddressToString `
-Force | Out-Null
$dnsServerSetting = Get-DnsServerSetting -All -WarningAction SilentlyContinue
$dnsServerSetting.ListeningIPAddress = @($ServerIPv4Address)
Set-DnsServerSetting -InputObject $dnsServerSetting -WarningAction SilentlyContinue | Out-Null
Restart-Service DNS -Force
Wait-DnsZoneReady -ZoneName $DomainName -DnsServer $ServerIPv4Address
$adServer = 'localhost'
$domain = Get-ADDomain -Identity $DomainName -Server $adServer
$laboratoryOuDn = Ensure-OrganizationalUnit -Name 'Laboratorio' -Path $baseDn -Server $adServer
$usersOuDn = Ensure-OrganizationalUnit -Name 'Usuarios-SGU' -Path $baseDn -Server $adServer
foreach ($ouName in @('Docentes', 'Alumnos', 'Administrativos')) {
Ensure-OrganizationalUnit -Name $ouName -Path $usersOuDn -Server $adServer | Out-Null
}
$remoteDesktopGroupName = 'SG-Laboratorio-Usuarios-RDP'
$remoteDesktopGroup = Get-ADGroup -LDAPFilter "(sAMAccountName=$remoteDesktopGroupName)" `
-SearchBase $baseDn -SearchScope Subtree -Server $adServer `
-ErrorAction SilentlyContinue
if (-not $remoteDesktopGroup) {
New-ADGroup -Name $remoteDesktopGroupName -SamAccountName $remoteDesktopGroupName `
-GroupCategory Security -GroupScope Global -Path $laboratoryOuDn `
-Description 'SGU users permitted to use Remote Desktop on laboratory clients.' `
-Server $adServer | Out-Null
$remoteDesktopGroup = Get-ADGroup -LDAPFilter "(sAMAccountName=$remoteDesktopGroupName)" `
-SearchBase $laboratoryOuDn -SearchScope OneLevel -Server $adServer
}
& (Join-Path $scriptsRoot 'Set-LabBrokerDns.ps1') `
-ZoneName $DomainName `
-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 |
Where-Object {
$_.Subject -eq "CN=$brokerDnsName" -and
$_.HasPrivateKey -and
$_.NotAfter -gt (Get-Date).AddDays(30)
} |
Sort-Object NotAfter -Descending |
Select-Object -First 1
if (-not $serverCertificate) {
$certificateResult = & (Join-Path $scriptsRoot 'New-LabCertificate.ps1') `
-Role BrokerServer `
-BrokerDnsName $brokerDnsName `
-OutputDirectory $certificateDirectory
$serverCertificate = Get-ChildItem Cert:\LocalMachine\My |
Where-Object Thumbprint -eq $certificateResult.Thumbprint |
Select-Object -First 1
}
else {
New-Item -ItemType Directory -Path $certificateDirectory -Force | Out-Null
$publicCertificatePath = Join-Path $certificateDirectory 'sgu-auth-broker.cer'
Export-Certificate -Cert $serverCertificate -FilePath $publicCertificatePath -Force | Out-Null
if (-not (Get-ChildItem Cert:\LocalMachine\Root | Where-Object Thumbprint -eq $serverCertificate.Thumbprint)) {
Import-Certificate -FilePath $publicCertificatePath `
-CertStoreLocation Cert:\LocalMachine\Root | Out-Null
}
}
$allowedClientThumbprints = @()
$brokerConfigurationPath = 'C:\Program Files\SGU\AuthBroker\appsettings.Production.json'
if (Test-Path -LiteralPath $brokerConfigurationPath -PathType Leaf) {
$priorConfiguration = Get-Content -LiteralPath $brokerConfigurationPath -Raw | ConvertFrom-Json
$allowedClientThumbprints = @($priorConfiguration.Broker.Tls.AllowedClientThumbprints)
}
& (Join-Path $scriptsRoot 'Deploy-AuthBroker.ps1') `
-PublishPath $brokerPublishPath `
-ServerCertificateSubject $brokerDnsName `
-AllowedClientThumbprints $allowedClientThumbprints `
-LdapHost $adServer `
-BaseDn $baseDn `
-DomainNetbios $DomainNetbios `
-UpnSuffix $DomainName `
-RemoteDesktopGroupDn $remoteDesktopGroup.DistinguishedName `
-DefaultCompany 'La Salle' `
-FirewallLocalAddress $ServerIPv4Address `
-FirewallRemoteAddress $allowedRemoteAddresses `
-CreateMissingOus `
-DisableCertificateRevocationCheckForLab | Out-Null
# Remove stale A records registered by any non-domain/NAT adapter before its
# dynamic DNS registration was disabled.
$hostRecords = @(Get-DnsServerResourceRecord -ZoneName $DomainName `
-Name $env:COMPUTERNAME -RRType A -ErrorAction SilentlyContinue)
foreach ($hostRecord in $hostRecords) {
if ($hostRecord.RecordData.IPv4Address.IPAddressToString -ne $ServerIPv4Address.IPAddressToString) {
Remove-DnsServerResourceRecord -ZoneName $DomainName -InputObject $hostRecord -Force
}
}
& (Join-Path $scriptsRoot 'Enable-SguServerRemoteManagement.ps1') `
-AllowedRemoteAddress $allowedRemoteAddresses | Out-Null
$contentPath = Join-Path $bootstrapRoot 'payload\server-content\Packages'
if (Test-Path -LiteralPath $contentPath -PathType Container) {
New-Item -ItemType Directory -Path $PackageSharePath -Force | Out-Null
Copy-Item -Path (Join-Path $contentPath '*') -Destination $PackageSharePath -Recurse -Force
}
Set-PackageShare -Path $PackageSharePath -NetbiosName $DomainNetbios `
-DomainSid $domain.DomainSID.Value
$packageFirewallRule = Get-NetFirewallRule -DisplayName 'SGU Bootstrap Packages (SMB)' `
-ErrorAction SilentlyContinue
if (-not $packageFirewallRule) {
New-NetFirewallRule `
-DisplayName 'SGU Bootstrap Packages (SMB)' `
-Direction Inbound `
-Action Allow `
-Protocol TCP `
-LocalPort 445 `
-LocalAddress $ServerIPv4Address.IPAddressToString `
-RemoteAddress $allowedRemoteAddresses `
-Profile Any | Out-Null
}
else {
$packageFirewallRule | Set-NetFirewallRule -Enabled True -Profile Any
$packageFirewallRule | Get-NetFirewallAddressFilter |
Set-NetFirewallAddressFilter `
-LocalAddress $ServerIPv4Address.IPAddressToString `
-RemoteAddress $allowedRemoteAddresses | Out-Null
}
$collectorFqdn = "$env:COMPUTERNAME.$DomainName"
& (Join-Path $scriptsRoot 'Set-SguDomainComputerPolicies.ps1') `
-TargetOuDn $laboratoryOuDn `
-DomainController $env:COMPUTERNAME `
-EventCollectorFqdn $collectorFqdn | Out-Null
& (Join-Path $scriptsRoot 'Install-SguDomainMonitoring.ps1') `
-CollectorFqdn $collectorFqdn `
-ComputerOuDn $laboratoryOuDn `
-RetentionDays 183 | Out-Null
$userPolicyParameters = @{
TargetOuDn = $usersOuDn
DomainController = $env:COMPUTERNAME
ClearManagedWallpaper = $true
}
& (Join-Path $scriptsRoot 'Set-SguDomainUserPolicies.ps1') @userPolicyParameters | Out-Null
$rustDeskServer = & (Join-Path $scriptsRoot 'Install-SguRustDeskServer.ps1') `
-ServerAddress $rustDeskDnsName `
-FirewallRemoteAddress $allowedRemoteAddresses
$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',
'Install-SguRustDeskLinuxEnrollment.ps1',
'Invoke-SguRustDeskLinuxRegistrationProcessor.ps1')) {
Copy-Item -LiteralPath (Join-Path $scriptsRoot $scriptName) `
-Destination (Join-Path $rustDeskManagementRoot $scriptName) -Force
}
$rustDeskLinuxEnrollment = & (Join-Path $rustDeskManagementRoot 'Install-SguRustDeskLinuxEnrollment.ps1') `
-DomainName $DomainName `
-ServerAddress $rustDeskDnsName `
-ServerPublicKey $rustDeskServer.PublicKey `
-ProcessorScriptPath (Join-Path $rustDeskManagementRoot 'Invoke-SguRustDeskLinuxRegistrationProcessor.ps1')
$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
DomainName = $DomainName
ServerIPv4Address = $ServerIPv4Address.IPAddressToString
NetworkConfigurationMode = $NetworkConfigurationMode
TrustedClientNetworks = $TrustedClientNetworks
AllowedRemoteAddresses = $allowedRemoteAddresses
BrokerDnsName = $brokerDnsName
BrokerCertificateThumbprint = $serverCertificate.Thumbprint
BrokerService = (Get-Service SGUAuthBroker).Status.ToString()
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
RustDeskLinuxRegistrationTask = (Get-ScheduledTask -TaskName $rustDeskLinuxEnrollment.RegistrationTask).State.ToString()
RustDeskServerClientId = $rustDeskServerClient.RustDeskId
EventCollector = (Get-Service Wecsvc).Status.ToString()
EventSubscription = @(& wecutil.exe enum-subscription) -contains 'SGU-Lab-Monitoring'
MonitoringRetentionDays = 183
PackageShare = "\\$env:COMPUTERNAME\Packages"
LaboratoryOu = $laboratoryOuDn
UsersOu = $usersOuDn
RemoteDesktopGroup = $remoteDesktopGroup.DistinguishedName
DomainNetworkProfile = [string](Get-NetConnectionProfile `
-InterfaceAlias $NetworkInterfaceAlias -ErrorAction SilentlyContinue).NetworkCategory
}
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
$validation.RustDeskLinuxRegistrationTask -notin @('Ready', 'Running') -or
-not $validation.RustDeskHbbsListening -or
-not $validation.RustDeskHbbrListening -or
$validation.EventCollector -ne 'Running' -or
-not $validation.EventSubscription -or
$validation.DomainNetworkProfile -ne 'DomainAuthenticated') {
throw 'Server finalization did not pass service validation. Review bootstrap.log and re-run the bootstrap.'
}
[IO.File]::WriteAllText(
$completionPath,
($validation | ConvertTo-Json -Depth 4),
[Text.UTF8Encoding]::new($false))
if (Get-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue) {
Unregister-ScheduledTask -TaskName $taskName -Confirm:$false
}
Remove-Item -LiteralPath $statePath -Force -ErrorAction SilentlyContinue
Write-BootstrapLog 'SGU domain controller bootstrap completed successfully.'
[pscustomobject]$validation