diff --git a/docs/bootstrap-recovery.md b/docs/bootstrap-recovery.md index 7da9984..bf828ae 100644 --- a/docs/bootstrap-recovery.md +++ b/docs/bootstrap-recovery.md @@ -52,10 +52,17 @@ El proceso crea o configura de forma idempotente: - GPO de experiencia del equipo y restricciones de sesión SGU; - certificado de servidor no exportable y broker mTLS en TCP 8443; - recurso `\\SERVIDOR\Packages`, con lectura para Domain Computers; -- RDP con NLA, WinRM/PowerShell Remoting y reglas administrativas sólo en el - perfil Domain; +- RDP con NLA, WinRM/PowerShell Remoting y reglas administrativas limitadas a + la subred privada indicada, incluso si Windows tarda en reconocer el perfil + Domain después de la promoción; - pantalla, suspensión e hibernación en Nunca. +En un servidor con dos NIC, el bootstrap desactiva el registro DNS de la NIC de +Internet y obliga al servicio DNS a escuchar y publicar únicamente la IP fija +privada. También vuelve a iniciar brevemente esa NIC privada si Windows Server +2025 todavía la clasifica como Public al terminar la promoción. La salida HTTPS +continúa por la NIC que tenga el gateway predeterminado. + El broker arranca con una lista de clientes vacía. Eso no abre el servicio: mTLS rechaza todos los certificados hasta que el primer cliente registra el suyo. Los archivos opcionales colocados en `payload\server-content\Packages` al crear @@ -129,8 +136,8 @@ validaciones. Desde el repositorio y con el SDK fijado en `global.json`: ```powershell -.\scripts\New-SguBootstrapPackages.ps1 -Version 0.1.0 -.\scripts\Publish-GiteaRelease.ps1 -Version 0.1.0 +.\scripts\New-SguBootstrapPackages.ps1 -Version 0.1.1 +.\scripts\Publish-GiteaRelease.ps1 -Version 0.1.1 ``` El segundo comando usa `GITEA_TOKEN` sólo en memoria o, si no está definido, @@ -139,7 +146,7 @@ la línea de comandos. Para empaquetar recursos institucionales adicionales: ```powershell .\scripts\New-SguBootstrapPackages.ps1 ` - -Version 0.1.0 ` + -Version 0.1.1 ` -ServerContentPath C:\Preparacion\Packages ``` diff --git a/scripts/Deploy-AuthBroker.ps1 b/scripts/Deploy-AuthBroker.ps1 index aa4a982..ff5f63f 100644 --- a/scripts/Deploy-AuthBroker.ps1 +++ b/scripts/Deploy-AuthBroker.ps1 @@ -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 diff --git a/scripts/Enable-SguServerRemoteManagement.ps1 b/scripts/Enable-SguServerRemoteManagement.ps1 index 14ed788..cbfb7ac 100644 --- a/scripts/Enable-SguServerRemoteManagement.ps1 +++ b/scripts/Enable-SguServerRemoteManagement.ps1 @@ -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 } diff --git a/scripts/Initialize-SguDomainController.ps1 b/scripts/Initialize-SguDomainController.ps1 index d278469..7e19439 100644 --- a/scripts/Initialize-SguDomainController.ps1 +++ b/scripts/Initialize-SguDomainController.ps1 @@ -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 diff --git a/scripts/Set-LabBrokerDns.ps1 b/scripts/Set-LabBrokerDns.ps1 index ef51af3..d4c6c1b 100644 --- a/scripts/Set-LabBrokerDns.ps1 +++ b/scripts/Set-LabBrokerDns.ps1 @@ -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) {