#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