Fix fresh domain controller bootstrap

This commit is contained in:
2026-09-03 17:26:05 -06:00
parent 1d7c312a67
commit 57572c5567
5 changed files with 278 additions and 40 deletions
+29 -3
View File
@@ -36,6 +36,9 @@ param(
[int]$NtlmTimeoutSeconds = 20,
[ValidateRange(2, 90)]
[int]$ProfileTimeoutSeconds = 90,
[ValidateNotNullOrEmpty()]
[string[]]$FirewallRemoteAddress = @('LocalSubnet'),
[ipaddress]$FirewallLocalAddress,
[switch]$CreateMissingOus,
[switch]$DisableCertificateRevocationCheckForLab
)
@@ -215,9 +218,32 @@ if ($PSCmdlet.ShouldProcess($installPath, 'Install the SGU Authentication Broker
throw 'Could not enable recovery for non-crash SGUAuthBroker failures.'
}
if (-not (Get-NetFirewallRule -DisplayName 'SGU Authentication Broker (mTLS)' -ErrorAction SilentlyContinue)) {
New-NetFirewallRule -DisplayName 'SGU Authentication Broker (mTLS)' `
-Direction Inbound -Action Allow -Protocol TCP -LocalPort 8443 -Profile Domain | Out-Null
$firewallRule = Get-NetFirewallRule `
-DisplayName 'SGU Authentication Broker (mTLS)' `
-ErrorAction SilentlyContinue
if (-not $firewallRule) {
$firewallParameters = @{
DisplayName = 'SGU Authentication Broker (mTLS)'
Direction = 'Inbound'
Action = 'Allow'
Protocol = 'TCP'
LocalPort = 8443
Profile = 'Any'
RemoteAddress = $FirewallRemoteAddress
}
if ($FirewallLocalAddress) {
$firewallParameters.LocalAddress = $FirewallLocalAddress.IPAddressToString
}
$firewallRule = New-NetFirewallRule @firewallParameters
}
else {
$firewallRule | Set-NetFirewallRule -Enabled True -Profile Any
$addressParameters = @{ RemoteAddress = $FirewallRemoteAddress }
if ($FirewallLocalAddress) {
$addressParameters.LocalAddress = $FirewallLocalAddress.IPAddressToString
}
$firewallRule | Get-NetFirewallAddressFilter |
Set-NetFirewallAddressFilter @addressParameters | Out-Null
}
Start-Service -Name $serviceName
+23 -10
View File
@@ -1,5 +1,8 @@
[CmdletBinding(SupportsShouldProcess)]
param()
param(
[ValidateNotNullOrEmpty()]
[string[]]$AllowedRemoteAddress = @('LocalSubnet')
)
$ErrorActionPreference = 'Stop'
$identity = [Security.Principal.WindowsIdentity]::GetCurrent()
@@ -38,9 +41,12 @@ if ($PSCmdlet.ShouldProcess($env:COMPUTERNAME, 'Enable secure administrative RDP
Set-Service -Name TermService -StartupType Automatic
Start-Service -Name TermService
Get-NetFirewallRule -Name 'RemoteDesktop-UserMode-In-TCP','RemoteDesktop-UserMode-In-UDP' `
-ErrorAction SilentlyContinue |
Set-NetFirewallRule -Enabled True -Profile Domain
$remoteDesktopRules = @(Get-NetFirewallRule `
-Name 'RemoteDesktop-UserMode-In-TCP','RemoteDesktop-UserMode-In-UDP' `
-ErrorAction SilentlyContinue)
$remoteDesktopRules | Set-NetFirewallRule -Enabled True -Profile Any
$remoteDesktopRules | Get-NetFirewallAddressFilter |
Set-NetFirewallAddressFilter -RemoteAddress $AllowedRemoteAddress | Out-Null
$enableRemoting = Start-Process `
-FilePath "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe" `
@@ -57,9 +63,12 @@ if ($PSCmdlet.ShouldProcess($env:COMPUTERNAME, 'Enable secure administrative RDP
Set-Service -Name WinRM -StartupType Automatic
Start-Service -Name WinRM
Get-NetFirewallRule -Name 'WINRM-HTTP-In-TCP','WINRM-HTTP-In-TCP-NoScope' `
-ErrorAction SilentlyContinue |
Set-NetFirewallRule -Enabled True -Profile Domain
$winRmRules = @(Get-NetFirewallRule `
-Name 'WINRM-HTTP-In-TCP','WINRM-HTTP-In-TCP-NoScope' `
-ErrorAction SilentlyContinue)
$winRmRules | Set-NetFirewallRule -Enabled True -Profile Any
$winRmRules | Get-NetFirewallAddressFilter |
Set-NetFirewallAddressFilter -RemoteAddress $AllowedRemoteAddress | Out-Null
Get-NetFirewallRule -Name 'WINRM-HTTP-In-TCP-PUBLIC' -ErrorAction SilentlyContinue |
Disable-NetFirewallRule
@@ -74,8 +83,11 @@ if ($PSCmdlet.ShouldProcess($env:COMPUTERNAME, 'Enable secure administrative RDP
'WMI-WINMGMT-In-TCP',
'WMI-ASYNC-In-TCP'
)
Get-NetFirewallRule -Name $administrativeRules -ErrorAction SilentlyContinue |
Set-NetFirewallRule -Enabled True -Profile Domain
$enabledAdministrativeRules = @(Get-NetFirewallRule `
-Name $administrativeRules -ErrorAction SilentlyContinue)
$enabledAdministrativeRules | Set-NetFirewallRule -Enabled True -Profile Any
$enabledAdministrativeRules | Get-NetFirewallAddressFilter |
Set-NetFirewallAddressFilter -RemoteAddress $AllowedRemoteAddress | Out-Null
}
[pscustomobject]@{
@@ -88,7 +100,8 @@ if ($PSCmdlet.ShouldProcess($env:COMPUTERNAME, 'Enable secure administrative RDP
-Name UserAuthentication) -eq 1
TermService = (Get-Service TermService).Status
WinRM = (Get-Service WinRM).Status
FirewallProfile = 'Domain'
FirewallProfile = 'Any'
AllowedRemoteAddress = $AllowedRemoteAddress
AdministrativeAccessOnly = $true
AlwaysOnPowerPolicyApplied = $true
}
+157 -12
View File
@@ -171,8 +171,10 @@ function Ensure-OrganizationalUnit {
)
$distinguishedName = "OU=$Name,$Path"
$existing = Get-ADOrganizationalUnit -Identity $distinguishedName -Server $Server `
-ErrorAction SilentlyContinue
$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
@@ -180,6 +182,57 @@ function Ensure-OrganizationalUnit {
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,
@@ -234,6 +287,10 @@ function Set-PackageShare {
}
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.'
@@ -379,24 +436,75 @@ if (-not $computer.Domain.Equals($DomainName, [StringComparison]::OrdinalIgnoreC
}
Write-BootstrapLog 'Finalizing Active Directory, DNS, policies, broker, shares, and remote management.'
Import-Module ActiveDirectory -ErrorAction Stop
$domain = Get-ADDomain -Identity $DomainName -Server $env:COMPUTERNAME
$laboratoryOuDn = Ensure-OrganizationalUnit -Name 'Laboratorio' -Path $baseDn -Server $env:COMPUTERNAME
$usersOuDn = Ensure-OrganizationalUnit -Name 'Usuarios-SGU' -Path $baseDn -Server $env:COMPUTERNAME
# 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') {
Write-BootstrapLog "Refreshing $NetworkInterfaceAlias so Windows detects the domain network profile."
Restart-NetAdapter -Name $NetworkInterfaceAlias -Confirm:$false
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 $env:COMPUTERNAME | Out-Null
Ensure-OrganizationalUnit -Name $ouName -Path $usersOuDn -Server $adServer | Out-Null
}
$remoteDesktopGroupName = 'SG-Laboratorio-Usuarios-RDP'
$remoteDesktopGroup = Get-ADGroup -Identity $remoteDesktopGroupName -Server $env:COMPUTERNAME `
$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 $env:COMPUTERNAME | Out-Null
$remoteDesktopGroup = Get-ADGroup -Identity $remoteDesktopGroupName -Server $env:COMPUTERNAME
-Server $adServer | Out-Null
$remoteDesktopGroup = Get-ADGroup -LDAPFilter "(sAMAccountName=$remoteDesktopGroupName)" `
-SearchBase $laboratoryOuDn -SearchScope OneLevel -Server $adServer
}
& (Join-Path $scriptsRoot 'Set-LabBrokerDns.ps1') `
@@ -444,16 +552,30 @@ if (Test-Path -LiteralPath $brokerConfigurationPath -PathType Leaf) {
-PublishPath $brokerPublishPath `
-ServerCertificateSubject $brokerDnsName `
-AllowedClientThumbprints $allowedClientThumbprints `
-LdapHost $env:COMPUTERNAME `
-LdapHost $adServer `
-BaseDn $baseDn `
-DomainNetbios $DomainNetbios `
-UpnSuffix $DomainName `
-RemoteDesktopGroupDn $remoteDesktopGroup.DistinguishedName `
-DefaultCompany 'La Salle' `
-FirewallLocalAddress $ServerIPv4Address `
-FirewallRemoteAddress "$($ServerIPv4Address.IPAddressToString)/$PrefixLength" `
-CreateMissingOus `
-DisableCertificateRevocationCheckForLab | Out-Null
& (Join-Path $scriptsRoot 'Enable-SguServerRemoteManagement.ps1') | 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
}
}
$privateSubnet = "$($ServerIPv4Address.IPAddressToString)/$PrefixLength"
& (Join-Path $scriptsRoot 'Enable-SguServerRemoteManagement.ps1') `
-AllowedRemoteAddress $privateSubnet | Out-Null
$contentPath = Join-Path $bootstrapRoot 'payload\server-content\Packages'
if (Test-Path -LiteralPath $contentPath -PathType Container) {
@@ -463,6 +585,27 @@ if (Test-Path -LiteralPath $contentPath -PathType Container) {
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 $privateSubnet `
-Profile Any | Out-Null
}
else {
$packageFirewallRule | Set-NetFirewallRule -Enabled True -Profile Any
$packageFirewallRule | Get-NetFirewallAddressFilter |
Set-NetFirewallAddressFilter `
-LocalAddress $ServerIPv4Address.IPAddressToString `
-RemoteAddress $privateSubnet | Out-Null
}
& (Join-Path $scriptsRoot 'Set-SguDomainComputerPolicies.ps1') `
-TargetOuDn $laboratoryOuDn -DomainController $env:COMPUTERNAME | Out-Null
$userPolicyParameters = @{
@@ -493,6 +636,8 @@ $validation = [ordered]@{
LaboratoryOu = $laboratoryOuDn
UsersOu = $usersOuDn
RemoteDesktopGroup = $remoteDesktopGroup.DistinguishedName
DomainNetworkProfile = [string](Get-NetConnectionProfile `
-InterfaceAlias $NetworkInterfaceAlias -ErrorAction SilentlyContinue).NetworkCategory
}
if ($validation.BrokerService -ne 'Running' -or
+57 -10
View File
@@ -8,18 +8,65 @@ param(
)
$ErrorActionPreference = 'Stop'
$existing = Get-DnsServerResourceRecord -ZoneName $ZoneName -Name $RecordName -RRType A -ErrorAction SilentlyContinue
if ($existing) {
$current = @($existing.RecordData.IPv4Address.IPAddressToString)
if ($current.Count -ne 1 -or $current[0] -ne $IPv4Address.IPAddressToString) {
# The fixed lab address is an explicit bootstrap input and may change
# when the server is rebuilt. Replace only this exact A record set.
$existing | Remove-DnsServerResourceRecord -ZoneName $ZoneName -Force
Add-DnsServerResourceRecordA -ZoneName $ZoneName -Name $RecordName -IPv4Address $IPv4Address
$dnsReady = $false
for ($attempt = 1; $attempt -le 30; $attempt++) {
try {
$soa = @(Resolve-DnsName $ZoneName -Type SOA -DnsOnly -Server localhost `
-ErrorAction Stop | Where-Object Type -eq SOA)
if ($soa.Count -gt 0) {
$dnsReady = $true
break
}
}
catch {
# An AD-integrated zone can take a few seconds to load after DNS starts.
}
Start-Sleep -Seconds 2
}
else {
Add-DnsServerResourceRecordA -ZoneName $ZoneName -Name $RecordName -IPv4Address $IPv4Address
if (-not $dnsReady) {
throw "DNS did not load the $ZoneName zone before the readiness timeout."
}
$recordReady = $false
for ($attempt = 1; $attempt -le 5; $attempt++) {
$existing = @(Get-DnsServerResourceRecord -ZoneName $ZoneName -Name $RecordName `
-RRType A -ErrorAction SilentlyContinue)
$unwanted = @($existing | Where-Object {
$_.RecordData.IPv4Address.IPAddressToString -ne $IPv4Address.IPAddressToString
})
foreach ($record in $unwanted) {
Remove-DnsServerResourceRecord -ZoneName $ZoneName -InputObject $record -Force
}
$desired = @($existing | Where-Object {
$_.RecordData.IPv4Address.IPAddressToString -eq $IPv4Address.IPAddressToString
})
if ($desired.Count -eq 0) {
try {
Add-DnsServerResourceRecordA -ZoneName $ZoneName -Name $RecordName `
-IPv4Address $IPv4Address -ErrorAction Stop
}
catch {
# A record that becomes visible while an AD-integrated zone is
# finishing its load is harmless; the verified read below decides.
}
}
Start-Sleep -Milliseconds 250
$final = @(Get-DnsServerResourceRecord -ZoneName $ZoneName -Name $RecordName `
-RRType A -ErrorAction SilentlyContinue)
$finalAddresses = @($final | ForEach-Object {
$_.RecordData.IPv4Address.IPAddressToString
})
if ($finalAddresses.Count -eq 1 -and
$finalAddresses[0] -eq $IPv4Address.IPAddressToString) {
$recordReady = $true
break
}
Start-Sleep -Seconds 1
}
if (-not $recordReady) {
throw "The $RecordName.$ZoneName A record could not be set exclusively to $IPv4Address."
}
if ($ExternalForwarders.Count -gt 0) {