P03-T01 - HQ-DC01 Build and Promotion
Objective
Clone the Windows Server template, deploy HQ-DC01 into VLAN 20, and build the first domain controller with all required core components for the lab and future enterprise estate:
AD DSDNSDHCP- future-proof UPN suffix strategy
- initial enterprise OU hierarchy
- baseline reverse zones and DHCP scope
This runbook assumes the strategic naming standard already approved in this guide:
- AD domain:
corp.gntech.me - user sign-in suffix:
gntech.me - enterprise management root OU:
OU=GNTECH - object-first OU model for standard identities and devices
- tier OUs reserved for privileged administration structure
Inputs
| Key | Value |
|---|---|
| Source template | 4002 / TPL-WS2025-CORE |
| Recommended VMID | 4011 |
| Target VM | HQ-DC01 |
| Target VLAN | 20 |
| Static IP | 172.20.20.11/24 |
| Gateway | 172.20.20.1 |
| Forest/domain FQDN | corp.gntech.me |
| NetBIOS name | GNTECH |
| User UPN suffix | gntech.me |
| DHCP scope | 172.20.30.100-172.20.30.199 |
Target State
| Property | Value |
|---|---|
| Role | AD DS, DNS, DHCP |
| Hostname | HQ-DC01 |
| Network | Static on VLAN 20 |
| Domain | First DC in corp.gntech.me |
| Forest | First forest in corp.gntech.me |
| DNS forwarders | 1.1.1.1, 9.9.9.9 |
| Reverse zones | 172.20.20.0/24, 172.20.30.0/24 |
| DHCP authorization | Authorized in AD |
| Enterprise root OU | OU=GNTECH |
Prechecks
- Complete
P02-T05. - Confirm
VMID 4002is still a clean reusable template. - Reserve
VMID 4011and hostnameHQ-DC01in the client overlay. - Confirm route and DNS reachability from
VLAN 20to WAN if updates are required. - Snapshot the new clone before role installation if your storage policy allows it.
- Be ready to sign in directly to
HQ-DC01after cloning. - Be ready to enter both a strong local
Administratorpassword and theDSRMpassword securely on the server itself. Do not hardcode or commit either value into any script.
Execution
Validated against the live lab on H1 on Thursday, July 16, 2026:
HQ-DC01was deployed successfully from4002(Windows Server 2025 Core).- The initial clone inherited stale network state from the template, including an extra default route.
- The stage 1 script in this repository now corrects the exact IP, DNS, and default route state instead of assuming a clean adapter configuration.
- Proxmox is used only to clone, size, network, snapshot, and power the VM.
-
All Windows role configuration is executed by the sysadmin directly in the guest OS, not through Proxmox guest agent automation.
-
Clone the template on
H1and resize the system disk:
qm clone 4002 4011 \
--name HQ-DC01 \
--full 1 \
--storage local-zfs
qm set 4011 \
--cores 2 \
--memory 4096 \
--balloon 0 \
--net0 virtio,bridge=GEILLAN,tag=20,firewall=0
qm resize 4011 scsi0 80G
qm start 4011
- Bootstrap the guest network through
QGAso the sysadmin can connect byRDP.
This block exists because the sysadmin needs a predictable management path before any in-guest deployment script is run.
Validated lab model as of Saturday, July 18, 2026:
- Proxmox clones and powers the VM
QGAsets the first static network stateRDPis enabled- the sysadmin then signs in and runs the Windows deployment script directly inside the server
Run this direct command from H1. Change the values before running it if the deployment differs:
VMID=4011
IPV4_ADDRESS='172.20.20.11'
PREFIX_LENGTH='24'
GATEWAY='172.20.20.1'
DNS1='172.20.20.1'
DNS2='1.1.1.1'
qm guest exec "$VMID" -- powershell.exe -NoProfile -ExecutionPolicy Bypass -Command "
\$adapter = Get-NetAdapter | Where-Object Status -eq 'Up' | Sort-Object ifIndex | Select-Object -First 1;
if (-not \$adapter) { throw 'No active network adapter was found.' }
Get-NetIPAddress -InterfaceIndex \$adapter.ifIndex -AddressFamily IPv4 -ErrorAction SilentlyContinue |
Where-Object { \$_.IPAddress -notlike '169.254.*' } |
ForEach-Object {
Remove-NetIPAddress -InterfaceIndex \$adapter.ifIndex -AddressFamily IPv4 -IPAddress \$_.IPAddress -Confirm:\$false -ErrorAction SilentlyContinue
};
Get-NetRoute -InterfaceIndex \$adapter.ifIndex -DestinationPrefix '0.0.0.0/0' -AddressFamily IPv4 -ErrorAction SilentlyContinue |
ForEach-Object {
Remove-NetRoute -InterfaceIndex \$adapter.ifIndex -DestinationPrefix '0.0.0.0/0' -NextHop \$_.NextHop -Confirm:\$false -ErrorAction SilentlyContinue
};
New-NetIPAddress -InterfaceIndex \$adapter.ifIndex -IPAddress '$IPV4_ADDRESS' -PrefixLength $PREFIX_LENGTH -DefaultGateway '$GATEWAY' | Out-Null;
Set-DnsClientServerAddress -InterfaceIndex \$adapter.ifIndex -ServerAddresses '$DNS1','$DNS2';
Set-ItemProperty -Path 'HKLM:\System\CurrentControlSet\Control\Terminal Server' -Name 'fDenyTSConnections' -Value 0;
Enable-NetFirewallRule -DisplayGroup 'Remote Desktop' | Out-Null;
Write-Host 'Bootstrap complete';
Write-Host ('Adapter: ' + \$adapter.Name);
Write-Host ('IP: $IPV4_ADDRESS/$PREFIX_LENGTH');
Write-Host ('Gateway: $GATEWAY');
Write-Host ('DNS: $DNS1, $DNS2');
"
Values the sysadmin may change in that command:
| Variable | What the admin can change |
|---|---|
VMID |
target clone VMID |
IPV4_ADDRESS |
static server IP |
PREFIX_LENGTH |
IPv4 prefix length |
GATEWAY |
default gateway for the server VLAN |
DNS1 |
primary DNS server used before promotion |
DNS2 |
secondary DNS server used before promotion |
Expected result:
- the server answers on
172.20.20.11 RDPis enabled-
the Windows firewall
Remote Desktoprule group is enabled -
Sign in directly to
HQ-DC01byRDPas the local built-inAdministrator. -
Copy the stage 1 script to the server and run it locally in PowerShell:
Show full DC01 Stage 1 script
param(
[string]$TargetName,
[string]$TimeZone,
[string]$IPv4,
[Nullable[int]]$PrefixLength,
[string]$Gateway,
[string[]]$DnsServers,
[switch]$RestartIfNeeded
)
$ErrorActionPreference = 'Stop'
$ProgressPreference = 'SilentlyContinue'
function Read-RequiredValue {
param(
[string]$Prompt,
[string]$DefaultValue
)
$fullPrompt = $Prompt
if ($DefaultValue) {
$fullPrompt = "$Prompt [$DefaultValue]"
}
while ($true) {
$value = Read-Host -Prompt $fullPrompt
if (-not [string]::IsNullOrWhiteSpace($value)) {
return $value.Trim()
}
if (-not [string]::IsNullOrWhiteSpace($DefaultValue)) {
return $DefaultValue
}
}
}
function Read-RequiredInt {
param(
[string]$Prompt,
[int]$DefaultValue
)
while ($true) {
$value = Read-RequiredValue -Prompt $Prompt -DefaultValue $DefaultValue
$parsed = 0
if ([int]::TryParse($value, [ref]$parsed)) {
return $parsed
}
}
}
function Read-DnsServerList {
param(
[string[]]$DefaultServers
)
while ($true) {
$defaultValue = $DefaultServers -join ','
$value = Read-RequiredValue -Prompt 'DNS servers (comma-separated)' -DefaultValue $defaultValue
$servers = @(
$value.Split(',') |
ForEach-Object { $_.Trim() } |
Where-Object { -not [string]::IsNullOrWhiteSpace($_) }
)
if ($servers.Count -gt 0) {
return $servers
}
}
}
function Resolve-DeploymentInputs {
$script:TargetName = if ([string]::IsNullOrWhiteSpace($TargetName)) {
Read-RequiredValue -Prompt 'Target hostname' -DefaultValue 'HQ-DC01'
} else {
$TargetName.Trim()
}
$script:TimeZone = if ([string]::IsNullOrWhiteSpace($TimeZone)) {
Read-RequiredValue -Prompt 'Windows time zone ID' -DefaultValue 'SA Western Standard Time'
} else {
$TimeZone.Trim()
}
$script:IPv4 = if ([string]::IsNullOrWhiteSpace($IPv4)) {
Read-RequiredValue -Prompt 'IPv4 address' -DefaultValue '172.20.20.11'
} else {
$IPv4.Trim()
}
$script:PrefixLength = if ($null -eq $PrefixLength) {
Read-RequiredInt -Prompt 'IPv4 prefix length' -DefaultValue 24
} else {
[int]$PrefixLength
}
$script:Gateway = if ([string]::IsNullOrWhiteSpace($Gateway)) {
Read-RequiredValue -Prompt 'Default gateway' -DefaultValue '172.20.20.1'
} else {
$Gateway.Trim()
}
$script:DnsServers = if ($DnsServers.Count -eq 0) {
Read-DnsServerList -DefaultServers @('127.0.0.1', '1.1.1.1')
} else {
@(
$DnsServers |
ForEach-Object { $_.Trim() } |
Where-Object { -not [string]::IsNullOrWhiteSpace($_) }
)
}
}
function Get-PrimaryAdapter {
$adapter = Get-NetAdapter |
Where-Object Status -eq 'Up' |
Sort-Object ifIndex |
Select-Object -First 1
if (-not $adapter) {
throw 'No active network adapter was found.'
}
return $adapter
}
function Set-ExactDnsServers {
param(
[int]$InterfaceIndex,
[string[]]$DesiredServers
)
$current = (Get-DnsClientServerAddress -InterfaceIndex $InterfaceIndex -AddressFamily IPv4).ServerAddresses
if (@($current) -join ',' -ne @($DesiredServers) -join ',') {
Set-DnsClientServerAddress -InterfaceIndex $InterfaceIndex -ServerAddresses $DesiredServers
}
}
function Set-ExactIPv4Configuration {
param(
[int]$InterfaceIndex,
[string]$DesiredIPv4,
[int]$DesiredPrefixLength,
[string]$DesiredGateway
)
$existing = Get-NetIPAddress -InterfaceIndex $InterfaceIndex -AddressFamily IPv4 -ErrorAction SilentlyContinue
foreach ($address in $existing) {
if ($address.IPAddress -eq $DesiredIPv4 -and $address.PrefixLength -eq $DesiredPrefixLength) {
continue
}
if ($address.IPAddress -like '169.254.*') {
continue
}
Remove-NetIPAddress `
-InterfaceIndex $InterfaceIndex `
-AddressFamily IPv4 `
-IPAddress $address.IPAddress `
-Confirm:$false `
-ErrorAction SilentlyContinue
}
$desiredAddress = Get-NetIPAddress `
-InterfaceIndex $InterfaceIndex `
-AddressFamily IPv4 `
-ErrorAction SilentlyContinue |
Where-Object {
$_.IPAddress -eq $DesiredIPv4 -and
$_.PrefixLength -eq $DesiredPrefixLength
} |
Select-Object -First 1
if (-not $desiredAddress) {
New-NetIPAddress `
-InterfaceIndex $InterfaceIndex `
-IPAddress $DesiredIPv4 `
-PrefixLength $DesiredPrefixLength
}
$defaultRoutes = Get-NetRoute `
-InterfaceIndex $InterfaceIndex `
-DestinationPrefix '0.0.0.0/0' `
-AddressFamily IPv4 `
-ErrorAction SilentlyContinue
foreach ($route in $defaultRoutes) {
if ($route.NextHop -ne $DesiredGateway) {
Remove-NetRoute `
-InterfaceIndex $InterfaceIndex `
-DestinationPrefix '0.0.0.0/0' `
-NextHop $route.NextHop `
-Confirm:$false `
-ErrorAction SilentlyContinue
}
}
$desiredRoute = Get-NetRoute `
-InterfaceIndex $InterfaceIndex `
-DestinationPrefix '0.0.0.0/0' `
-AddressFamily IPv4 `
-ErrorAction SilentlyContinue |
Where-Object NextHop -eq $DesiredGateway |
Select-Object -First 1
if (-not $desiredRoute) {
New-NetRoute `
-InterfaceIndex $InterfaceIndex `
-DestinationPrefix '0.0.0.0/0' `
-NextHop $DesiredGateway `
-RouteMetric 256 | Out-Null
}
}
function Install-RequiredFeatures {
$requiredFeatures = 'AD-Domain-Services', 'DNS', 'DHCP'
$missingFeatures = Get-WindowsFeature $requiredFeatures |
Where-Object InstallState -ne 'Installed' |
Select-Object -ExpandProperty Name
if ($missingFeatures) {
Install-WindowsFeature -Name $missingFeatures -IncludeManagementTools | Out-Null
}
}
Resolve-DeploymentInputs
$adapter = Get-PrimaryAdapter
$restartRequired = $false
if ((Get-TimeZone).Id -ne $TimeZone) {
Set-TimeZone -Id $TimeZone
}
Set-ExactIPv4Configuration `
-InterfaceIndex $adapter.ifIndex `
-DesiredIPv4 $IPv4 `
-DesiredPrefixLength $PrefixLength `
-DesiredGateway $Gateway
Set-ExactDnsServers `
-InterfaceIndex $adapter.ifIndex `
-DesiredServers $DnsServers
if ($env:COMPUTERNAME -ne $TargetName) {
Rename-Computer -NewName $TargetName -Force
$restartRequired = $true
}
Install-RequiredFeatures
$summary = [pscustomobject]@{
Hostname = $env:COMPUTERNAME
TargetName = $TargetName
Adapter = $adapter.Name
IPv4 = $IPv4
PrefixLength = $PrefixLength
Gateway = $Gateway
DnsServers = ($DnsServers -join ', ')
RestartRequired = $restartRequired
}
$summary | Format-List | Out-String | Write-Output
if ($restartRequired -and $RestartIfNeeded) {
Write-Output 'Restarting to complete hostname change.'
Restart-Computer -Force
}
Full reference:
This script:
- prompts the sysadmin for required deployment values if they are not provided as parameters
- supports explicit parameter-based execution when the operator wants a non-interactive run
- removes inherited incorrect default routes before applying the expected
VLAN 20gateway - installs
AD-Domain-Services,DNS, andDHCP
Use a Windows time zone ID, not an IANA name. America/Santo_Domingo is valid in RouterOS, but not for Set-TimeZone on Windows Server. For the Dominican Republic baseline validated on July 16, 2026, use SA Western Standard Time. If the client uses a different region, check valid IDs first:
Example local execution from an elevated PowerShell session on HQ-DC01:
-
Reboot
HQ-DC01, sign in again, and confirm the expected hostname, static IP, gateway, and DNS settings. -
Run the promotion script for the next stage:
Show full forest-promotion script
param(
[string]$DomainName,
[string]$NetBIOSName,
[switch]$InstallDns = $true,
[switch]$NoRebootOnCompletion,
[switch]$ValidateOnly
)
$ErrorActionPreference = 'Stop'
$ProgressPreference = 'SilentlyContinue'
function Read-RequiredValue {
param(
[string]$Prompt,
[string]$DefaultValue
)
$fullPrompt = $Prompt
if ($DefaultValue) {
$fullPrompt = "$Prompt [$DefaultValue]"
}
while ($true) {
$value = Read-Host -Prompt $fullPrompt
if (-not [string]::IsNullOrWhiteSpace($value)) {
return $value.Trim()
}
if (-not [string]::IsNullOrWhiteSpace($DefaultValue)) {
return $DefaultValue
}
}
}
function Read-RequiredSecret {
param(
[string]$Prompt
)
try {
return (Read-Host -Prompt $Prompt -AsSecureString)
}
catch {
throw "A secure value for '$Prompt' is required when running non-interactively."
}
}
function Convert-SecureStringToLength {
param(
[Security.SecureString]$SecureValue
)
$bstr = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($SecureValue)
try {
return [Runtime.InteropServices.Marshal]::PtrToStringBSTR($bstr).Length
}
finally {
if ($bstr -ne [IntPtr]::Zero) {
[Runtime.InteropServices.Marshal]::ZeroFreeBSTR($bstr)
}
}
}
function Resolve-Inputs {
$script:DomainName = if ([string]::IsNullOrWhiteSpace($DomainName)) {
Read-RequiredValue -Prompt 'Forest/domain FQDN' -DefaultValue 'corp.gntech.me'
}
else {
$DomainName.Trim()
}
$script:NetBIOSName = if ([string]::IsNullOrWhiteSpace($NetBIOSName)) {
Read-RequiredValue -Prompt 'NetBIOS name' -DefaultValue 'GNTECH'
}
else {
$NetBIOSName.Trim()
}
if (-not $ValidateOnly) {
$script:LocalAdministratorPassword = Read-RequiredSecret `
-Prompt 'Local Administrator password'
$script:SafeModeAdministratorPassword = Read-RequiredSecret `
-Prompt 'DSRM password'
}
}
function Test-Prerequisites {
$computerSystem = Get-CimInstance Win32_ComputerSystem
if ($computerSystem.PartOfDomain) {
throw "Server is already joined to domain '$($computerSystem.Domain)'."
}
$addsFeature = Get-WindowsFeature AD-Domain-Services
if ($addsFeature.InstallState -ne 'Installed') {
throw 'AD-Domain-Services feature is not installed.'
}
$localAdministrator = Get-LocalUser | Where-Object SID -like 'S-1-5-21-*-500' | Select-Object -First 1
if (-not $localAdministrator) {
throw 'Built-in local Administrator account with RID 500 was not found.'
}
if (-not $ValidateOnly) {
$localAdminLength = Convert-SecureStringToLength -SecureValue $script:LocalAdministratorPassword
$safeModeLength = Convert-SecureStringToLength -SecureValue $script:SafeModeAdministratorPassword
if ($localAdminLength -lt 12) {
throw 'Local Administrator password must be at least 12 characters.'
}
if ($safeModeLength -lt 12) {
throw 'DSRM password must be at least 12 characters.'
}
}
[pscustomobject]@{
Hostname = $env:COMPUTERNAME
DomainName = $script:DomainName
NetBIOSName = $script:NetBIOSName
PartOfDomain = $computerSystem.PartOfDomain
AddsInstalled = $addsFeature.InstallState
InstallDns = [bool]$InstallDns
NoRebootOnCompletion = [bool]$NoRebootOnCompletion
ValidateOnly = [bool]$ValidateOnly
}
}
Resolve-Inputs
$summary = Test-Prerequisites
$summary | Format-List | Out-String | Write-Output
if ($ValidateOnly) {
Write-Output 'Validation mode only. Promotion was not started.'
return
}
$localAdministrator = Get-LocalUser | Where-Object SID -like 'S-1-5-21-*-500' | Select-Object -First 1
$localAdministrator | Set-LocalUser -Password $script:LocalAdministratorPassword
$promotionParams = @{
DomainName = $script:DomainName
DomainNetbiosName = $script:NetBIOSName
SafeModeAdministratorPassword = $script:SafeModeAdministratorPassword
InstallDns = [bool]$InstallDns
Force = $true
NoRebootOnCompletion = [bool]$NoRebootOnCompletion
}
Install-ADDSForest @promotionParams
Full reference:
This script:
- prompts for the domain FQDN, NetBIOS name, local
Administratorpassword, andDSRMpassword when values are not provided - supports
-ValidateOnlyso the operator can confirm prereqs before starting promotion
Real password entry must happen securely at execution time from the sysadmin console on HQ-DC01. Do not add the local Administrator password or DSRM password as repository parameters, defaults, or committed values.
- Run a prereq check directly on
HQ-DC01:
- If prereqs pass, run the actual promotion directly on
HQ-DC01:
The server will reboot as part of promotion.
-
Sign in again after reboot using the domain credential format that succeeds in the environment, for example
Administrator@corp.gntech.me. -
Run the post-promotion baseline script:
Show full post-promotion script
param(
[string]$DomainFqdn,
[string]$DomainDn,
[string]$DcFqdn,
[string]$DcIp,
[string]$UpnSuffix,
[string[]]$DnsForwarders,
[string]$ServerReverseNetworkId,
[string]$WorkstationReverseNetworkId,
[string]$DhcpScopeName,
[string]$DhcpScopeId,
[string]$DhcpStartRange,
[string]$DhcpEndRange,
[string]$DhcpSubnetMask,
[string]$DhcpRouter,
[string]$EnterpriseRootOuName,
[string]$OuTemplateCsvPath,
[switch]$ValidateOnly
)
$ErrorActionPreference = 'Stop'
$ProgressPreference = 'SilentlyContinue'
function Read-RequiredValue {
param(
[string]$Prompt,
[string]$DefaultValue
)
$fullPrompt = $Prompt
if ($DefaultValue) {
$fullPrompt = "$Prompt [$DefaultValue]"
}
while ($true) {
$value = Read-Host -Prompt $fullPrompt
if (-not [string]::IsNullOrWhiteSpace($value)) {
return $value.Trim()
}
if (-not [string]::IsNullOrWhiteSpace($DefaultValue)) {
return $DefaultValue
}
}
}
function Read-StringList {
param(
[string]$Prompt,
[string[]]$DefaultValues
)
while ($true) {
$defaultValue = $DefaultValues -join ','
$value = Read-RequiredValue -Prompt $Prompt -DefaultValue $defaultValue
$items = @(
$value.Split(',') |
ForEach-Object { $_.Trim() } |
Where-Object { -not [string]::IsNullOrWhiteSpace($_) }
)
if ($items.Count -gt 0) {
return $items
}
}
}
function Resolve-Inputs {
$script:DomainFqdn = if ([string]::IsNullOrWhiteSpace($DomainFqdn)) {
Read-RequiredValue -Prompt 'Domain FQDN' -DefaultValue 'corp.gntech.me'
} else { $DomainFqdn.Trim() }
$script:DomainDn = if ([string]::IsNullOrWhiteSpace($DomainDn)) {
Read-RequiredValue -Prompt 'Domain distinguished name' -DefaultValue 'DC=corp,DC=gntech,DC=me'
} else { $DomainDn.Trim() }
$script:DcFqdn = if ([string]::IsNullOrWhiteSpace($DcFqdn)) {
Read-RequiredValue -Prompt 'Domain controller FQDN' -DefaultValue 'HQ-DC01.corp.gntech.me'
} else { $DcFqdn.Trim() }
$script:DcIp = if ([string]::IsNullOrWhiteSpace($DcIp)) {
Read-RequiredValue -Prompt 'Domain controller IPv4' -DefaultValue '172.20.20.11'
} else { $DcIp.Trim() }
$script:UpnSuffix = if ([string]::IsNullOrWhiteSpace($UpnSuffix)) {
Read-RequiredValue -Prompt 'User UPN suffix' -DefaultValue 'gntech.me'
} else { $UpnSuffix.Trim() }
$script:DnsForwarders = if ($DnsForwarders.Count -eq 0) {
Read-StringList -Prompt 'DNS forwarders (comma-separated)' -DefaultValues @('1.1.1.1','9.9.9.9')
} else {
@($DnsForwarders | ForEach-Object { $_.Trim() } | Where-Object { $_ })
}
$script:ServerReverseNetworkId = if ([string]::IsNullOrWhiteSpace($ServerReverseNetworkId)) {
Read-RequiredValue -Prompt 'Server reverse zone network' -DefaultValue '172.20.20.0/24'
} else { $ServerReverseNetworkId.Trim() }
$script:WorkstationReverseNetworkId = if ([string]::IsNullOrWhiteSpace($WorkstationReverseNetworkId)) {
Read-RequiredValue -Prompt 'Workstation reverse zone network' -DefaultValue '172.20.30.0/24'
} else { $WorkstationReverseNetworkId.Trim() }
$script:DhcpScopeName = if ([string]::IsNullOrWhiteSpace($DhcpScopeName)) {
Read-RequiredValue -Prompt 'DHCP scope name' -DefaultValue 'VLAN30 Workstations'
} else { $DhcpScopeName.Trim() }
$script:DhcpScopeId = if ([string]::IsNullOrWhiteSpace($DhcpScopeId)) {
Read-RequiredValue -Prompt 'DHCP scope ID' -DefaultValue '172.20.30.0'
} else { $DhcpScopeId.Trim() }
$script:DhcpStartRange = if ([string]::IsNullOrWhiteSpace($DhcpStartRange)) {
Read-RequiredValue -Prompt 'DHCP start range' -DefaultValue '172.20.30.100'
} else { $DhcpStartRange.Trim() }
$script:DhcpEndRange = if ([string]::IsNullOrWhiteSpace($DhcpEndRange)) {
Read-RequiredValue -Prompt 'DHCP end range' -DefaultValue '172.20.30.199'
} else { $DhcpEndRange.Trim() }
$script:DhcpSubnetMask = if ([string]::IsNullOrWhiteSpace($DhcpSubnetMask)) {
Read-RequiredValue -Prompt 'DHCP subnet mask' -DefaultValue '255.255.255.0'
} else { $DhcpSubnetMask.Trim() }
$script:DhcpRouter = if ([string]::IsNullOrWhiteSpace($DhcpRouter)) {
Read-RequiredValue -Prompt 'DHCP router option' -DefaultValue '172.20.30.1'
} else { $DhcpRouter.Trim() }
$script:EnterpriseRootOuName = if ([string]::IsNullOrWhiteSpace($EnterpriseRootOuName)) {
Read-RequiredValue -Prompt 'Enterprise root OU name' -DefaultValue 'GNTECH'
} else { $EnterpriseRootOuName.Trim() }
$script:OuTemplateCsvPath = if ([string]::IsNullOrWhiteSpace($OuTemplateCsvPath)) {
$scriptDirectory = if (-not [string]::IsNullOrWhiteSpace($PSScriptRoot)) {
$PSScriptRoot
} else {
(Get-Location).Path
}
$defaultCsv = Join-Path -Path $scriptDirectory -ChildPath 'ou-baseline.csv'
if (Test-Path -LiteralPath $defaultCsv) { $defaultCsv } else { '' }
} else { $OuTemplateCsvPath.Trim() }
}
function Ensure-Module {
param([string]$Name)
Import-Module $Name
}
function Ensure-UpnSuffix {
$partitionsDn = "CN=Partitions,CN=Configuration,$script:DomainDn"
$partitionsObject = Get-ADObject $partitionsDn -Properties uPNSuffixes
if ($partitionsObject.uPNSuffixes -notcontains $script:UpnSuffix) {
Set-ADObject $partitionsDn -Add @{ uPNSuffixes = $script:UpnSuffix }
}
}
function Ensure-DnsForwarders {
$existingForwarders = @(Get-DnsServerForwarder -ErrorAction SilentlyContinue | ForEach-Object {
if ($_.IPAddress -is [array]) {
$_.IPAddress | ForEach-Object { $_.IPAddressToString }
}
elseif ($_.IPAddress) {
$_.IPAddress.IPAddressToString
}
})
foreach ($forwarder in $script:DnsForwarders) {
if ($existingForwarders -notcontains $forwarder) {
Add-DnsServerForwarder -IPAddress $forwarder
}
}
}
function Ensure-DnsZone {
param([string]$NetworkId)
$networkAddress = $NetworkId.Split('/')[0]
$octets = $networkAddress.Split('.')
$zoneName = "$($octets[2]).$($octets[1]).$($octets[0]).in-addr.arpa"
if (-not (Get-DnsServerZone -Name $zoneName -ErrorAction SilentlyContinue)) {
Add-DnsServerPrimaryZone -NetworkId $NetworkId -ReplicationScope Forest
}
}
function Ensure-DhcpAuthorization {
$authorized = @(Get-DhcpServerInDC -ErrorAction SilentlyContinue)
if (-not ($authorized | Where-Object DnsName -eq $script:DcFqdn)) {
Add-DhcpServerInDC -DnsName $script:DcFqdn -IpAddress $script:DcIp
}
& netsh dhcp add securitygroups | Out-Null
Restart-Service DHCPServer -Force
Start-Sleep -Seconds 10
}
function Ensure-DhcpScope {
if (-not (Get-DhcpServerv4Scope -ErrorAction SilentlyContinue | Where-Object ScopeId -eq $script:DhcpScopeId)) {
Add-DhcpServerv4Scope `
-Name $script:DhcpScopeName `
-StartRange $script:DhcpStartRange `
-EndRange $script:DhcpEndRange `
-SubnetMask $script:DhcpSubnetMask `
-State Active
}
Set-DhcpServerv4OptionValue `
-ScopeId $script:DhcpScopeId `
-Router $script:DhcpRouter `
-DnsServer $script:DcIp `
-DnsDomain $script:DomainFqdn
Set-DhcpServerv4DnsSetting `
-DynamicUpdates Always `
-DeleteDnsRRonLeaseExpiry $true `
-UpdateDnsRRForOlderClients $true
}
function Ensure-OU {
param(
[string]$Name,
[string]$Path,
[bool]$ProtectedFromAccidentalDeletion = $true
)
$ouDn = "OU=$Name,$Path"
if (-not (Get-ADOrganizationalUnit -LDAPFilter "(distinguishedName=$ouDn)" -ErrorAction SilentlyContinue)) {
New-ADOrganizationalUnit -Name $Name -Path $Path -ProtectedFromAccidentalDeletion $ProtectedFromAccidentalDeletion
}
}
function Get-DefaultOuDefinitions {
$rootDn = "OU=$($script:EnterpriseRootOuName),$($script:DomainDn)"
@(
@{ Name = $script:EnterpriseRootOuName; ParentDn = $script:DomainDn; ProtectedFromAccidentalDeletion = $true; Enabled = $true }
@{ Name = 'Tier0'; ParentDn = $rootDn; ProtectedFromAccidentalDeletion = $true; Enabled = $true }
@{ Name = 'Tier1'; ParentDn = $rootDn; ProtectedFromAccidentalDeletion = $true; Enabled = $true }
@{ Name = 'Tier2'; ParentDn = $rootDn; ProtectedFromAccidentalDeletion = $true; Enabled = $true }
@{ Name = 'Users'; ParentDn = $rootDn; ProtectedFromAccidentalDeletion = $true; Enabled = $true }
@{ Name = 'Workstations'; ParentDn = $rootDn; ProtectedFromAccidentalDeletion = $true; Enabled = $true }
@{ Name = 'Servers'; ParentDn = $rootDn; ProtectedFromAccidentalDeletion = $true; Enabled = $true }
@{ Name = 'Groups'; ParentDn = $rootDn; ProtectedFromAccidentalDeletion = $true; Enabled = $true }
@{ Name = 'ServiceAccounts'; ParentDn = $rootDn; ProtectedFromAccidentalDeletion = $true; Enabled = $true }
@{ Name = 'Admins'; ParentDn = $rootDn; ProtectedFromAccidentalDeletion = $true; Enabled = $true }
@{ Name = 'Sites'; ParentDn = $rootDn; ProtectedFromAccidentalDeletion = $true; Enabled = $true }
@{ Name = 'Staging'; ParentDn = $rootDn; ProtectedFromAccidentalDeletion = $true; Enabled = $true }
@{ Name = 'Quarantine'; ParentDn = $rootDn; ProtectedFromAccidentalDeletion = $true; Enabled = $true }
@{ Name = 'Corporate'; ParentDn = "OU=Users,$rootDn"; ProtectedFromAccidentalDeletion = $true; Enabled = $true }
@{ Name = 'Regional'; ParentDn = "OU=Users,$rootDn"; ProtectedFromAccidentalDeletion = $true; Enabled = $true }
@{ Name = 'Contractors'; ParentDn = "OU=Users,$rootDn"; ProtectedFromAccidentalDeletion = $true; Enabled = $true }
@{ Name = 'PrivilegedExcluded'; ParentDn = "OU=Users,$rootDn"; ProtectedFromAccidentalDeletion = $true; Enabled = $true }
@{ Name = 'Disabled'; ParentDn = "OU=Users,$rootDn"; ProtectedFromAccidentalDeletion = $true; Enabled = $true }
@{ Name = 'AMER'; ParentDn = "OU=Workstations,$rootDn"; ProtectedFromAccidentalDeletion = $true; Enabled = $true }
@{ Name = 'EMEA'; ParentDn = "OU=Workstations,$rootDn"; ProtectedFromAccidentalDeletion = $true; Enabled = $true }
@{ Name = 'APAC'; ParentDn = "OU=Workstations,$rootDn"; ProtectedFromAccidentalDeletion = $true; Enabled = $true }
@{ Name = 'Kiosks'; ParentDn = "OU=Workstations,$rootDn"; ProtectedFromAccidentalDeletion = $true; Enabled = $true }
@{ Name = 'VDI'; ParentDn = "OU=Workstations,$rootDn"; ProtectedFromAccidentalDeletion = $true; Enabled = $true }
@{ Name = 'Quarantine'; ParentDn = "OU=Workstations,$rootDn"; ProtectedFromAccidentalDeletion = $true; Enabled = $true }
@{ Name = 'Infrastructure'; ParentDn = "OU=Servers,$rootDn"; ProtectedFromAccidentalDeletion = $true; Enabled = $true }
@{ Name = 'Applications'; ParentDn = "OU=Servers,$rootDn"; ProtectedFromAccidentalDeletion = $true; Enabled = $true }
@{ Name = 'Regional'; ParentDn = "OU=Servers,$rootDn"; ProtectedFromAccidentalDeletion = $true; Enabled = $true }
@{ Name = 'JumpHosts'; ParentDn = "OU=Servers,$rootDn"; ProtectedFromAccidentalDeletion = $true; Enabled = $true }
@{ Name = 'RoleBased'; ParentDn = "OU=Groups,$rootDn"; ProtectedFromAccidentalDeletion = $true; Enabled = $true }
@{ Name = 'ResourceBased'; ParentDn = "OU=Groups,$rootDn"; ProtectedFromAccidentalDeletion = $true; Enabled = $true }
@{ Name = 'MailEnabled'; ParentDn = "OU=Groups,$rootDn"; ProtectedFromAccidentalDeletion = $true; Enabled = $true }
@{ Name = 'Delegation'; ParentDn = "OU=Groups,$rootDn"; ProtectedFromAccidentalDeletion = $true; Enabled = $true }
@{ Name = 'Tier0'; ParentDn = "OU=ServiceAccounts,$rootDn"; ProtectedFromAccidentalDeletion = $true; Enabled = $true }
@{ Name = 'Tier1'; ParentDn = "OU=ServiceAccounts,$rootDn"; ProtectedFromAccidentalDeletion = $true; Enabled = $true }
@{ Name = 'Tier2'; ParentDn = "OU=ServiceAccounts,$rootDn"; ProtectedFromAccidentalDeletion = $true; Enabled = $true }
@{ Name = 'Applications'; ParentDn = "OU=ServiceAccounts,$rootDn"; ProtectedFromAccidentalDeletion = $true; Enabled = $true }
@{ Name = 'Tier0'; ParentDn = "OU=Admins,$rootDn"; ProtectedFromAccidentalDeletion = $true; Enabled = $true }
@{ Name = 'Tier1'; ParentDn = "OU=Admins,$rootDn"; ProtectedFromAccidentalDeletion = $true; Enabled = $true }
@{ Name = 'Tier2'; ParentDn = "OU=Admins,$rootDn"; ProtectedFromAccidentalDeletion = $true; Enabled = $true }
@{ Name = 'EmergencyAccess'; ParentDn = "OU=Admins,$rootDn"; ProtectedFromAccidentalDeletion = $true; Enabled = $true }
@{ Name = 'AMER'; ParentDn = "OU=Sites,$rootDn"; ProtectedFromAccidentalDeletion = $true; Enabled = $true }
@{ Name = 'EMEA'; ParentDn = "OU=Sites,$rootDn"; ProtectedFromAccidentalDeletion = $true; Enabled = $true }
@{ Name = 'APAC'; ParentDn = "OU=Sites,$rootDn"; ProtectedFromAccidentalDeletion = $true; Enabled = $true }
@{ Name = 'AdminGroups'; ParentDn = "OU=Tier0,$rootDn"; ProtectedFromAccidentalDeletion = $true; Enabled = $true }
@{ Name = 'Delegation'; ParentDn = "OU=Tier0,$rootDn"; ProtectedFromAccidentalDeletion = $true; Enabled = $true }
@{ Name = 'PrivilegedRoles'; ParentDn = "OU=Tier0,$rootDn"; ProtectedFromAccidentalDeletion = $true; Enabled = $true }
@{ Name = 'AdminGroups'; ParentDn = "OU=Tier1,$rootDn"; ProtectedFromAccidentalDeletion = $true; Enabled = $true }
@{ Name = 'Delegation'; ParentDn = "OU=Tier1,$rootDn"; ProtectedFromAccidentalDeletion = $true; Enabled = $true }
@{ Name = 'PrivilegedRoles'; ParentDn = "OU=Tier1,$rootDn"; ProtectedFromAccidentalDeletion = $true; Enabled = $true }
@{ Name = 'AdminGroups'; ParentDn = "OU=Tier2,$rootDn"; ProtectedFromAccidentalDeletion = $true; Enabled = $true }
@{ Name = 'Delegation'; ParentDn = "OU=Tier2,$rootDn"; ProtectedFromAccidentalDeletion = $true; Enabled = $true }
@{ Name = 'PrivilegedRoles'; ParentDn = "OU=Tier2,$rootDn"; ProtectedFromAccidentalDeletion = $true; Enabled = $true }
)
}
function Get-OuDefinitions {
if (-not [string]::IsNullOrWhiteSpace($script:OuTemplateCsvPath)) {
if (-not (Test-Path -LiteralPath $script:OuTemplateCsvPath)) {
throw "OU template CSV '$($script:OuTemplateCsvPath)' was not found."
}
return @(Import-Csv -LiteralPath $script:OuTemplateCsvPath | Where-Object {
$_.Enabled -match '^(?i:true|1|yes)$'
})
}
return @(Get-DefaultOuDefinitions)
}
function Ensure-OuHierarchy {
$definitions = @(Get-OuDefinitions)
foreach ($definition in $definitions) {
$protected = $true
if ($definition.PSObject.Properties.Name -contains 'ProtectedFromAccidentalDeletion') {
$protected = $definition.ProtectedFromAccidentalDeletion -match '^(?i:true|1|yes)$'
}
Ensure-OU -Name $definition.Name -Path $definition.ParentDn -ProtectedFromAccidentalDeletion $protected
}
}
Resolve-Inputs
Ensure-Module -Name ActiveDirectory
Ensure-Module -Name DhcpServer
Ensure-Module -Name DnsServer
$summary = [pscustomobject]@{
DomainFqdn = $script:DomainFqdn
DomainDn = $script:DomainDn
DcFqdn = $script:DcFqdn
DcIp = $script:DcIp
UpnSuffix = $script:UpnSuffix
DnsForwarders = ($script:DnsForwarders -join ', ')
ServerReverseNetworkId = $script:ServerReverseNetworkId
WorkstationReverseNetworkId = $script:WorkstationReverseNetworkId
DhcpScopeId = $script:DhcpScopeId
DhcpRouter = $script:DhcpRouter
EnterpriseRootOuName = $script:EnterpriseRootOuName
OuTemplateCsvPath = $(if ([string]::IsNullOrWhiteSpace($script:OuTemplateCsvPath)) { 'Built-in default hierarchy' } else { $script:OuTemplateCsvPath })
ValidateOnly = [bool]$ValidateOnly
}
$summary | Format-List | Out-String | Write-Output
if ($ValidateOnly) {
Write-Output 'Validation mode only. Post-promotion baseline was not applied.'
return
}
Ensure-UpnSuffix
Ensure-DnsForwarders
Ensure-DnsZone -NetworkId $script:ServerReverseNetworkId
Ensure-DnsZone -NetworkId $script:WorkstationReverseNetworkId
Ensure-DhcpAuthorization
Ensure-DhcpScope
Ensure-OuHierarchy
Write-Output 'Post-promotion baseline completed successfully.'
- authorizes
DHCPin AD, refreshes DHCP security groups, and creates the validated workstation scope - creates the validated
GNTECHOU hierarchy used in this lab - can read the OU hierarchy from a CSV template when the sysadmin wants a customer-specific OU baseline without editing the script
The OU hierarchy is applied in this step. It is not a separate later runbook.
CSV-driven OU option:
- template file:
data/ou-baseline.csv - script parameter:
-OuTemplateCsvPath - if the sysadmin places
ou-baseline.csvnext toConfigure-HQ-DC01-PostPromotion.ps1, the script can use that file directly - if no CSV is supplied, the script falls back to the validated built-in OU hierarchy used by the lab
Full reference:
Top-level OUs created by the script under OU=GNTECH:
Tier0Tier1Tier2UsersWorkstationsServersGroupsServiceAccountsAdminsSitesStaging-
Quarantine -
Execute the post-promotion script locally on
HQ-DC01:
Example with an explicit OU template CSV:
- Validate the new domain controller by running the validation script locally:
Show full DC01 validation script
param(
[string]$ExpectedHostname,
[string]$ExpectedDomainFqdn,
[string]$ExpectedNetBIOSName,
[string]$ExpectedUpnSuffix,
[string]$ExpectedDcIp,
[string[]]$ExpectedDnsForwarders,
[string[]]$ExpectedDnsZones,
[string]$ExpectedDhcpAuthorizedDnsName,
[string]$ExpectedDhcpAuthorizedIp,
[string]$ExpectedDhcpScopeId,
[string]$ExpectedEnterpriseRootOuDn,
[string]$OuTemplateCsvPath,
[string[]]$ExpectedOuDns
)
$ErrorActionPreference = 'Stop'
$ProgressPreference = 'SilentlyContinue'
function Read-RequiredValue {
param(
[string]$Prompt,
[string]$DefaultValue
)
$fullPrompt = $Prompt
if ($DefaultValue) {
$fullPrompt = "$Prompt [$DefaultValue]"
}
while ($true) {
$value = Read-Host -Prompt $fullPrompt
if (-not [string]::IsNullOrWhiteSpace($value)) {
return $value.Trim()
}
if (-not [string]::IsNullOrWhiteSpace($DefaultValue)) {
return $DefaultValue
}
}
}
function Read-StringList {
param(
[string]$Prompt,
[string[]]$DefaultValues
)
while ($true) {
$defaultValue = $DefaultValues -join ','
$value = Read-RequiredValue -Prompt $Prompt -DefaultValue $defaultValue
$items = @(
$value.Split(',') |
ForEach-Object { $_.Trim() } |
Where-Object { -not [string]::IsNullOrWhiteSpace($_) }
)
if ($items.Count -gt 0) {
return $items
}
}
}
function Resolve-Inputs {
$script:ExpectedHostname = if ([string]::IsNullOrWhiteSpace($ExpectedHostname)) {
Read-RequiredValue -Prompt 'Expected hostname' -DefaultValue 'HQ-DC01'
} else { $ExpectedHostname.Trim() }
$script:ExpectedDomainFqdn = if ([string]::IsNullOrWhiteSpace($ExpectedDomainFqdn)) {
Read-RequiredValue -Prompt 'Expected domain FQDN' -DefaultValue 'corp.gntech.me'
} else { $ExpectedDomainFqdn.Trim() }
$script:ExpectedNetBIOSName = if ([string]::IsNullOrWhiteSpace($ExpectedNetBIOSName)) {
Read-RequiredValue -Prompt 'Expected NetBIOS name' -DefaultValue 'GNTECH'
} else { $ExpectedNetBIOSName.Trim() }
$script:ExpectedUpnSuffix = if ([string]::IsNullOrWhiteSpace($ExpectedUpnSuffix)) {
Read-RequiredValue -Prompt 'Expected UPN suffix' -DefaultValue 'gntech.me'
} else { $ExpectedUpnSuffix.Trim() }
$script:ExpectedDcIp = if ([string]::IsNullOrWhiteSpace($ExpectedDcIp)) {
Read-RequiredValue -Prompt 'Expected DC IPv4' -DefaultValue '172.20.20.11'
} else { $ExpectedDcIp.Trim() }
$script:ExpectedDnsForwarders = if ($ExpectedDnsForwarders.Count -eq 0) {
Read-StringList -Prompt 'Expected DNS forwarders (comma-separated)' -DefaultValues @('1.1.1.1','9.9.9.9')
} else {
@($ExpectedDnsForwarders | ForEach-Object { $_.Trim() } | Where-Object { $_ })
}
$script:ExpectedDnsZones = if ($ExpectedDnsZones.Count -eq 0) {
Read-StringList -Prompt 'Expected DNS zones (comma-separated)' -DefaultValues @('corp.gntech.me','20.20.172.in-addr.arpa','30.20.172.in-addr.arpa')
} else {
@($ExpectedDnsZones | ForEach-Object { $_.Trim() } | Where-Object { $_ })
}
$script:ExpectedDhcpAuthorizedDnsName = if ([string]::IsNullOrWhiteSpace($ExpectedDhcpAuthorizedDnsName)) {
Read-RequiredValue -Prompt 'Expected DHCP authorized DNS name' -DefaultValue 'hq-dc01.corp.gntech.me'
} else { $ExpectedDhcpAuthorizedDnsName.Trim() }
$script:ExpectedDhcpAuthorizedIp = if ([string]::IsNullOrWhiteSpace($ExpectedDhcpAuthorizedIp)) {
Read-RequiredValue -Prompt 'Expected DHCP authorized IP' -DefaultValue '172.20.20.11'
} else { $ExpectedDhcpAuthorizedIp.Trim() }
$script:ExpectedDhcpScopeId = if ([string]::IsNullOrWhiteSpace($ExpectedDhcpScopeId)) {
Read-RequiredValue -Prompt 'Expected DHCP scope ID' -DefaultValue '172.20.30.0'
} else { $ExpectedDhcpScopeId.Trim() }
$script:ExpectedEnterpriseRootOuDn = if ([string]::IsNullOrWhiteSpace($ExpectedEnterpriseRootOuDn)) {
Read-RequiredValue -Prompt 'Expected enterprise root OU DN' -DefaultValue 'OU=GNTECH,DC=corp,DC=gntech,DC=me'
} else { $ExpectedEnterpriseRootOuDn.Trim() }
$script:OuTemplateCsvPath = if ([string]::IsNullOrWhiteSpace($OuTemplateCsvPath)) {
$scriptDirectory = if (-not [string]::IsNullOrWhiteSpace($PSScriptRoot)) {
$PSScriptRoot
} else {
(Get-Location).Path
}
$defaultCsv = Join-Path -Path $scriptDirectory -ChildPath 'ou-baseline.csv'
if (Test-Path -LiteralPath $defaultCsv) { $defaultCsv } else { '' }
} else { $OuTemplateCsvPath.Trim() }
$script:ExpectedOuDns = if ($ExpectedOuDns.Count -eq 0 -and [string]::IsNullOrWhiteSpace($script:OuTemplateCsvPath)) {
@(
'OU=Tier0,OU=GNTECH,DC=corp,DC=gntech,DC=me',
'OU=Tier1,OU=GNTECH,DC=corp,DC=gntech,DC=me',
'OU=Tier2,OU=GNTECH,DC=corp,DC=gntech,DC=me',
'OU=Users,OU=GNTECH,DC=corp,DC=gntech,DC=me',
'OU=Workstations,OU=GNTECH,DC=corp,DC=gntech,DC=me',
'OU=Servers,OU=GNTECH,DC=corp,DC=gntech,DC=me',
'OU=Groups,OU=GNTECH,DC=corp,DC=gntech,DC=me',
'OU=ServiceAccounts,OU=GNTECH,DC=corp,DC=gntech,DC=me',
'OU=Admins,OU=GNTECH,DC=corp,DC=gntech,DC=me',
'OU=Sites,OU=GNTECH,DC=corp,DC=gntech,DC=me'
)
} else {
@($ExpectedOuDns | ForEach-Object { $_.Trim() } | Where-Object { $_ })
}
}
function Ensure-Module {
param([string]$Name)
Import-Module $Name
}
function Get-ForwarderStrings {
@(Get-DnsServerForwarder -ErrorAction SilentlyContinue | ForEach-Object {
if ($_.IPAddress -is [array]) {
$_.IPAddress | ForEach-Object { $_.IPAddressToString }
}
elseif ($_.IPAddress) {
$_.IPAddress.IPAddressToString
}
})
}
function Get-ExpectedOuDnsFromCsv {
param([string]$Path)
if (-not (Test-Path -LiteralPath $Path)) {
throw "OU template CSV '$Path' was not found."
}
@(Import-Csv -LiteralPath $Path | Where-Object {
$_.Enabled -match '^(?i:true|1|yes)$'
} | ForEach-Object {
"OU=$($_.Name),$($_.ParentDn)"
})
}
Resolve-Inputs
Ensure-Module -Name ActiveDirectory
Ensure-Module -Name DhcpServer
Ensure-Module -Name DnsServer
if (-not [string]::IsNullOrWhiteSpace($script:OuTemplateCsvPath)) {
$script:ExpectedOuDns = @(Get-ExpectedOuDnsFromCsv -Path $script:OuTemplateCsvPath | Where-Object { $_ -ne $script:ExpectedEnterpriseRootOuDn })
}
$forest = Get-ADForest
$domain = Get-ADDomain
$computerSystem = Get-CimInstance Win32_ComputerSystem
$features = Get-WindowsFeature AD-Domain-Services, DNS, DHCP
$services = Get-Service NTDS, DNS, DHCPServer
$forwarders = Get-ForwarderStrings
$zones = @(Get-DnsServerZone -ErrorAction SilentlyContinue | Where-Object ZoneName -in $script:ExpectedDnsZones)
$authorizedDhcp = @(Get-DhcpServerInDC -ErrorAction SilentlyContinue)
$scope = Get-DhcpServerv4Scope -ScopeId $script:ExpectedDhcpScopeId -ErrorAction SilentlyContinue
$scopeOptions = @(Get-DhcpServerv4OptionValue -ScopeId $script:ExpectedDhcpScopeId -ErrorAction SilentlyContinue)
$rootOu = Get-ADOrganizationalUnit -LDAPFilter "(distinguishedName=$($script:ExpectedEnterpriseRootOuDn))" -ErrorAction SilentlyContinue
$expectedOusFound = @()
foreach ($ouDn in $script:ExpectedOuDns) {
$found = Get-ADOrganizationalUnit -LDAPFilter "(distinguishedName=$ouDn)" -ErrorAction SilentlyContinue
if ($found) {
$expectedOusFound += $ouDn
}
}
Write-Output '=== Identity ==='
Write-Output "Hostname: $env:COMPUTERNAME"
Write-Output "PartOfDomain: $($computerSystem.PartOfDomain)"
Write-Output "ComputerSystemDomain: $($computerSystem.Domain)"
Write-Output "ForestRootDomain: $($forest.RootDomain)"
Write-Output "ForestMode: $($forest.ForestMode)"
Write-Output "UPNSuffixes: $($forest.UPNSuffixes -join ', ')"
Write-Output "DomainDNSRoot: $($domain.DNSRoot)"
Write-Output "DomainNetBIOSName: $($domain.NetBIOSName)"
Write-Output "DomainMode: $($domain.DomainMode)"
Write-Output '=== Features ==='
foreach ($feature in $features) {
Write-Output "$($feature.Name): $($feature.InstallState)"
}
Write-Output '=== Services ==='
foreach ($service in $services) {
Write-Output "$($service.Name): $($service.Status)"
}
Write-Output '=== DNS ==='
if ($forwarders.Count -eq 0) {
Write-Output 'Forwarders: none found'
} else {
foreach ($forwarder in $forwarders) {
Write-Output "Forwarder: $forwarder"
}
}
if ($zones.Count -eq 0) {
Write-Output 'Zones: none found'
} else {
foreach ($zone in $zones) {
Write-Output "Zone: $($zone.ZoneName) / Type=$($zone.ZoneType) / DsIntegrated=$($zone.IsDsIntegrated)"
}
}
Write-Output '=== DHCP ==='
if ($authorizedDhcp.Count -eq 0) {
Write-Output 'Authorized DHCP servers: none found'
} else {
foreach ($entry in $authorizedDhcp) {
Write-Output "Authorized DHCP: $($entry.DnsName) / $($entry.IPAddress.IPAddressToString)"
}
}
if (-not $scope) {
Write-Output "Scope $($script:ExpectedDhcpScopeId): missing"
} else {
Write-Output "Scope: $($scope.ScopeId.IPAddressToString) / Name=$($scope.Name) / State=$($scope.State) / Start=$($scope.StartRange.IPAddressToString) / End=$($scope.EndRange.IPAddressToString)"
}
if ($scopeOptions.Count -eq 0) {
Write-Output "DHCP options for scope $($script:ExpectedDhcpScopeId): none found"
} else {
foreach ($option in $scopeOptions) {
$valueText = if ($option.Value -is [array]) { $option.Value -join ', ' } else { $option.Value }
Write-Output "DHCP Option $($option.OptionId): $($option.Name) = $valueText"
}
}
Write-Output '=== OU Structure ==='
if (-not $rootOu) {
Write-Output "Enterprise root OU missing: $($script:ExpectedEnterpriseRootOuDn)"
} else {
Write-Output "Enterprise root OU: $($rootOu.DistinguishedName)"
}
foreach ($ouDn in $script:ExpectedOuDns) {
if ($expectedOusFound -contains $ouDn) {
Write-Output "OU Present: $ouDn"
} else {
Write-Output "OU Missing: $ouDn"
}
}
Full reference:
This script validates the live baseline that was actually applied on Thursday, July 16, 2026:
corp.gntech.meforest and domain state- installed
AD DS,DNS, andDHCP - running
NTDS,DNS, andDHCPServer - forwarders
1.1.1.1and9.9.9.9 - reverse zones
172.20.20.0/24and172.20.30.0/24 - authorized DHCP service and scope
172.20.30.0 - object layout rooted at
OU=GNTECH
Validation
HQ-DC01boots with the expected hostname and static IP.corp.gntech.meexists as the new forest root domain.gntech.meexists as an alternate UPN suffix.dcdiag /test:Advertising /test:Services /test:SysVolCheck /test:NetLogonsreports no blocking errors.- DNS forwarders and reverse lookup zones are present.
- DHCP is authorized and the
VLAN 30scope is active. OU=GNTECHexists with the validated day-zero object layout applied in this lab.NETLOGONandSYSVOLshares are present.DFSREventwarnings are treated as non-blocking only ifSYSVOLandNETLOGONare already shared and theDFSRservice is healthy. Re-test after convergence before declaring the build complete.HQ-DC01is ready for immediate post-deploy identity seeding such as test users, admin users, and service accounts.
Evidence
ipconfig /allGet-WindowsFeature AD-Domain-Services, DNS, DHCPdcdiag /test:Advertising /test:Services /test:SysVolCheck /test:NetLogonsGet-DhcpServerv4ScopeGet-ADForestGet-ADDomainGet-DnsServerForwarderGet-DnsServerZoneGet-DhcpServerInDCGet-ADOrganizationalUnitGet-Service DFSR, DNS, DHCPServerGet-WinEvent -LogName 'DFS Replication'
Post-deploy follow-on work
After HQ-DC01 passes validation, create the first test identities directly on HQ-DC01 before continuing to file-share and workstation validation.
Run the test-user creation script locally from an elevated PowerShell session on HQ-DC01:
Show full test-user creation script
param(
[string]$DomainDn,
[string]$UsersOuDn,
[string]$AdminsOuDn,
[string]$UserUpnSuffix,
[string]$AdminUpnSuffix,
[string]$PublicWriteGroupSamAccountName,
[switch]$ValidateOnly
)
$ErrorActionPreference = 'Stop'
$ProgressPreference = 'SilentlyContinue'
function Read-RequiredValue {
param(
[string]$Prompt,
[string]$DefaultValue
)
$fullPrompt = $Prompt
if ($DefaultValue) { $fullPrompt = "$Prompt [$DefaultValue]" }
while ($true) {
$value = Read-Host -Prompt $fullPrompt
if (-not [string]::IsNullOrWhiteSpace($value)) { return $value.Trim() }
if (-not [string]::IsNullOrWhiteSpace($DefaultValue)) { return $DefaultValue }
}
}
function Read-RequiredSecret {
param([string]$Prompt)
try {
return Read-Host -Prompt $Prompt -AsSecureString
}
catch {
throw "A secure value for '$Prompt' is required when running non-interactively."
}
}
function Resolve-Inputs {
$script:DomainDn = if ([string]::IsNullOrWhiteSpace($DomainDn)) { Read-RequiredValue 'Domain DN' 'DC=corp,DC=gntech,DC=me' } else { $DomainDn.Trim() }
$script:UsersOuDn = if ([string]::IsNullOrWhiteSpace($UsersOuDn)) { Read-RequiredValue 'Users OU DN' 'OU=Users,OU=GNTECH,DC=corp,DC=gntech,DC=me' } else { $UsersOuDn.Trim() }
$script:AdminsOuDn = if ([string]::IsNullOrWhiteSpace($AdminsOuDn)) { Read-RequiredValue 'Admins OU DN' 'OU=Admins,OU=GNTECH,DC=corp,DC=gntech,DC=me' } else { $AdminsOuDn.Trim() }
$script:UserUpnSuffix = if ([string]::IsNullOrWhiteSpace($UserUpnSuffix)) { Read-RequiredValue 'Standard user UPN suffix' 'gntech.me' } else { $UserUpnSuffix.Trim() }
$script:AdminUpnSuffix = if ([string]::IsNullOrWhiteSpace($AdminUpnSuffix)) { Read-RequiredValue 'Admin user UPN suffix' 'gntech.me' } else { $AdminUpnSuffix.Trim() }
$script:PublicWriteGroupSamAccountName = if ([string]::IsNullOrWhiteSpace($PublicWriteGroupSamAccountName)) { Read-RequiredValue 'Public write group SamAccountName' 'DL-HQ-FS01-Public-RW' } else { $PublicWriteGroupSamAccountName.Trim() }
if (-not $ValidateOnly) {
$script:DefaultUserPassword = Read-RequiredSecret -Prompt 'Standard user password'
$script:DefaultAdminPassword = Read-RequiredSecret -Prompt 'Admin user password'
}
}
function Ensure-Module {
param([string]$Name)
Import-Module $Name
}
function Ensure-TestUser {
param(
[string]$Name,
[string]$GivenName,
[string]$Surname,
[string]$DisplayName,
[string]$UserPrincipalName,
[string]$SamAccountName,
[string]$EmployeeId,
[string]$Path,
[securestring]$Password
)
$existing = Get-ADUser -Filter "SamAccountName -eq '$SamAccountName'" -ErrorAction SilentlyContinue
if (-not $existing) {
New-ADUser `
-Name $Name `
-GivenName $GivenName `
-Surname $Surname `
-DisplayName $DisplayName `
-UserPrincipalName $UserPrincipalName `
-SamAccountName $SamAccountName `
-EmployeeID $EmployeeId `
-Path $Path `
-AccountPassword $Password `
-Enabled $true `
-ChangePasswordAtLogon $false
}
}
function Ensure-GroupMembership {
param(
[string]$GroupSamAccountName,
[string]$MemberSamAccountName
)
$group = Get-ADGroup -Filter "SamAccountName -eq '$GroupSamAccountName'" -ErrorAction SilentlyContinue
if (-not $group) {
throw "The group '$GroupSamAccountName' was not found."
}
$member = Get-ADUser -Filter "SamAccountName -eq '$MemberSamAccountName'" -ErrorAction SilentlyContinue
if (-not $member) {
throw "The user '$MemberSamAccountName' was not found."
}
$isMember = Get-ADGroupMember -Identity $group.DistinguishedName -Recursive | Where-Object SamAccountName -eq $MemberSamAccountName
if ($isMember) {
return [pscustomobject]@{
GroupSamAccountName = $GroupSamAccountName
MemberSamAccountName = $MemberSamAccountName
Action = 'Exists'
}
}
if (-not $ValidateOnly) {
Add-ADGroupMember -Identity $group.DistinguishedName -Members $member.DistinguishedName
}
[pscustomobject]@{
GroupSamAccountName = $GroupSamAccountName
MemberSamAccountName = $MemberSamAccountName
Action = $(if ($ValidateOnly) { 'Planned' } else { 'Added' })
}
}
Resolve-Inputs
Ensure-Module -Name ActiveDirectory
$definitions = @(
@{
Name = 'Miguel Perez'
GivenName = 'Miguel'
Surname = 'Perez'
DisplayName = 'Miguel Perez'
UserPrincipalName = "miguel.perez@$($script:UserUpnSuffix)"
SamAccountName = 'miguel.perez'
EmployeeId = 'GNT-000123'
Path = $script:UsersOuDn
IsAdmin = $false
},
@{
Name = 'Ana Garcia'
GivenName = 'Ana'
Surname = 'Garcia'
DisplayName = 'Ana Garcia'
UserPrincipalName = "ana.garcia@$($script:UserUpnSuffix)"
SamAccountName = 'ana.garcia'
EmployeeId = 'GNT-000124'
Path = $script:UsersOuDn
IsAdmin = $false
},
@{
Name = 'Admin Miguel Perez'
GivenName = 'Miguel'
Surname = 'Perez'
DisplayName = 'Admin Miguel Perez'
UserPrincipalName = "adm.miguel.perez@$($script:AdminUpnSuffix)"
SamAccountName = 'adm.miguel.perez'
EmployeeId = 'GNT-A000123'
Path = $script:AdminsOuDn
IsAdmin = $true
},
@{
Name = 'Admin Gerlin Nolasco'
GivenName = 'Gerlin'
Surname = 'Nolasco'
DisplayName = 'Admin Gerlin Nolasco'
UserPrincipalName = "adm.gerlin.nolasco@$($script:AdminUpnSuffix)"
SamAccountName = 'adm.gerlin.nolasco'
EmployeeId = 'GNT-A000124'
Path = $script:AdminsOuDn
IsAdmin = $true
}
)
$summary = $definitions | ForEach-Object {
[pscustomobject]@{
Name = $_.Name
UserPrincipalName = $_.UserPrincipalName
SamAccountName = $_.SamAccountName
EmployeeId = $_.EmployeeId
Path = $_.Path
}
}
$summary | Format-Table -AutoSize | Out-String | Write-Output
[pscustomobject]@{
PublicWriteGroupSamAccountName = $script:PublicWriteGroupSamAccountName
PublicWriteMemberSamAccountName = 'miguel.perez'
PublicDeniedMemberSamAccountName = 'ana.garcia'
} | Format-List | Out-String | Write-Output
if ($ValidateOnly) {
Write-Output 'Validation mode only. Test users and group memberships were not created.'
return
}
foreach ($definition in $definitions) {
$password = if ($definition.IsAdmin) { $script:DefaultAdminPassword } else { $script:DefaultUserPassword }
Ensure-TestUser `
-Name $definition.Name `
-GivenName $definition.GivenName `
-Surname $definition.Surname `
-DisplayName $definition.DisplayName `
-UserPrincipalName $definition.UserPrincipalName `
-SamAccountName $definition.SamAccountName `
-EmployeeId $definition.EmployeeId `
-Path $definition.Path `
-Password $password
}
$membershipResult = Ensure-GroupMembership `
-GroupSamAccountName $script:PublicWriteGroupSamAccountName `
-MemberSamAccountName 'miguel.perez'
$membershipResult | Format-Table -AutoSize | Out-String | Write-Output
Write-Output 'Test users completed successfully.'
Full reference:
Why here:
- the users depend on the domain and OU baseline created by
HQ-DC01 - they are consumed later by
P03-T07 - HQ-FS01 File ServicesandP03-T08 - Workstation Resource Access Validation - they should not be introduced first from
HQ-FS01or a workstation runbook
Create the baseline service identities next, from the same elevated session on HQ-DC01:
The script validates the domain and target OU first, securely prompts only for accounts that do not already exist, and never stores passwords in the repository. Existing accounts are checked for the expected OU and metadata; the script does not reset their passwords on a re-run.
Show full service-account script
param(
[string]$DomainDn,
[string]$ServiceAccountsOuDn,
[string]$ServiceUpnSuffix,
[hashtable]$AccountPasswords,
[switch]$PasswordNeverExpires,
[switch]$ValidateOnly
)
$ErrorActionPreference = 'Stop'
$ProgressPreference = 'SilentlyContinue'
function Assert-Elevated {
$identity = [Security.Principal.WindowsIdentity]::GetCurrent()
$principal = New-Object Security.Principal.WindowsPrincipal($identity)
if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {
throw 'Run this script from an elevated PowerShell session.'
}
}
function Read-RequiredValue {
param(
[string]$Prompt,
[string]$DefaultValue
)
$fullPrompt = $Prompt
if ($DefaultValue) {
$fullPrompt = "$Prompt [$DefaultValue]"
}
while ($true) {
$value = Read-Host -Prompt $fullPrompt
if (-not [string]::IsNullOrWhiteSpace($value)) {
return $value.Trim()
}
if (-not [string]::IsNullOrWhiteSpace($DefaultValue)) {
return $DefaultValue
}
}
}
function Read-RequiredSecret {
param([string]$Prompt)
try {
return Read-Host -Prompt $Prompt -AsSecureString
}
catch {
throw "A secure value for '$Prompt' is required."
}
}
function Resolve-Inputs {
$script:DomainDn = if ([string]::IsNullOrWhiteSpace($DomainDn)) {
Read-RequiredValue -Prompt 'Domain DN' -DefaultValue 'DC=corp,DC=gntech,DC=me'
}
else {
$DomainDn.Trim()
}
$script:ServiceAccountsOuDn = if ([string]::IsNullOrWhiteSpace($ServiceAccountsOuDn)) {
Read-RequiredValue -Prompt 'Service accounts OU DN' -DefaultValue 'OU=Tier1,OU=ServiceAccounts,OU=GNTECH,DC=corp,DC=gntech,DC=me'
}
else {
$ServiceAccountsOuDn.Trim()
}
$script:ServiceUpnSuffix = if ([string]::IsNullOrWhiteSpace($ServiceUpnSuffix)) {
''
}
else {
$ServiceUpnSuffix.Trim()
}
}
function Ensure-Module {
param([string]$Name)
Import-Module $Name
}
function Assert-AdTargets {
$domain = Get-ADDomain -ErrorAction Stop
if ($domain.DistinguishedName -ne $script:DomainDn) {
throw "Connected domain '$($domain.DistinguishedName)' does not match requested domain '$($script:DomainDn)'."
}
Get-ADOrganizationalUnit -Identity $script:ServiceAccountsOuDn -ErrorAction Stop | Out-Null
}
function Get-ServiceAccountUpn {
param([string]$SamAccountName)
if ([string]::IsNullOrWhiteSpace($script:ServiceUpnSuffix)) {
return $null
}
return "$SamAccountName@$($script:ServiceUpnSuffix)"
}
function Ensure-ServiceAccount {
param(
[hashtable]$Definition,
[securestring]$Password
)
$existing = Get-ADUser -Filter "SamAccountName -eq '$($Definition.SamAccountName)'" -Properties UserPrincipalName,PasswordNeverExpires,Description -ErrorAction SilentlyContinue
if ($existing) {
if ($existing.DistinguishedName -notlike "*,$($script:ServiceAccountsOuDn)") {
throw "Account '$($Definition.SamAccountName)' exists outside the target OU: $($existing.DistinguishedName)"
}
$changes = @{}
$expectedUpn = Get-ServiceAccountUpn -SamAccountName $Definition.SamAccountName
if ($expectedUpn -and $existing.UserPrincipalName -ne $expectedUpn) { $changes.UserPrincipalName = $expectedUpn }
if ($existing.Description -ne $Definition.Description) { $changes.Description = $Definition.Description }
if ($PasswordNeverExpires -and -not $existing.PasswordNeverExpires) { $changes.PasswordNeverExpires = $true }
if ($changes.Count -gt 0 -and -not $ValidateOnly) { Set-ADUser -Identity $existing @changes }
return [pscustomobject]@{
SamAccountName = $Definition.SamAccountName
Action = if ($changes.Count -eq 0) { 'Compliant' } elseif ($ValidateOnly) { 'Drift' } else { 'Updated' }
Details = if ($changes.Count -eq 0) { '' } else { ($changes.Keys -join ',') }
}
}
if ($ValidateOnly) {
return [pscustomobject]@{
SamAccountName = $Definition.SamAccountName
Action = 'Missing'
Details = ''
}
}
$parameters = @{
Name = $Definition.Name
DisplayName = $Definition.DisplayName
SamAccountName = $Definition.SamAccountName
Path = $script:ServiceAccountsOuDn
AccountPassword = $Password
Enabled = $true
ChangePasswordAtLogon = $false
Description = $Definition.Description
}
$upn = Get-ServiceAccountUpn -SamAccountName $Definition.SamAccountName
if ($upn) {
$parameters['UserPrincipalName'] = $upn
}
if ($PasswordNeverExpires) {
$parameters['PasswordNeverExpires'] = $true
}
New-ADUser @parameters
return [pscustomobject]@{
SamAccountName = $Definition.SamAccountName
Action = 'Created'
Details = ''
}
}
Resolve-Inputs
Assert-Elevated
Ensure-Module -Name ActiveDirectory
Assert-AdTargets
$definitions = @(
@{
Name = 'svc.join.hq'
DisplayName = 'svc.join.hq'
SamAccountName = 'svc.join.hq'
Description = 'Delegated domain join service account for workstation and server onboarding.'
},
@{
Name = 'svc.backup.hq'
DisplayName = 'svc.backup.hq'
SamAccountName = 'svc.backup.hq'
Description = 'Backup service account for approved backup tooling and targets.'
},
@{
Name = 'svc.monitor.hq'
DisplayName = 'svc.monitor.hq'
SamAccountName = 'svc.monitor.hq'
Description = 'Monitoring and inventory service account for approved observability tooling.'
},
@{
Name = 'svc.deploy.hq'
DisplayName = 'svc.deploy.hq'
SamAccountName = 'svc.deploy.hq'
Description = 'Deployment automation service account for scripted server and workstation configuration.'
}
)
$summary = $definitions | ForEach-Object {
[pscustomobject]@{
Name = $_.Name
SamAccountName = $_.SamAccountName
UserPrincipalName = Get-ServiceAccountUpn -SamAccountName $_.SamAccountName
Path = $script:ServiceAccountsOuDn
Description = $_.Description
}
}
$summary | Format-Table -AutoSize | Out-String | Write-Output
if ($ValidateOnly) {
$results = foreach ($definition in $definitions) {
Ensure-ServiceAccount -Definition $definition -Password $null
}
$results | Format-Table -AutoSize | Out-String | Write-Output
Write-Output 'Validation mode only. Service accounts were not created or changed.'
return
}
$passwords = @{}
foreach ($definition in $definitions) {
$existing = Get-ADUser -Filter "SamAccountName -eq '$($definition.SamAccountName)'" -ErrorAction SilentlyContinue
if (-not $existing) {
if ($AccountPasswords -and $AccountPasswords.ContainsKey($definition.SamAccountName)) {
$providedPassword = $AccountPasswords[$definition.SamAccountName]
if ($providedPassword -isnot [securestring]) {
throw "AccountPasswords entry for '$($definition.SamAccountName)' must be a SecureString."
}
$passwords[$definition.SamAccountName] = $providedPassword
}
else {
$passwords[$definition.SamAccountName] = Read-RequiredSecret -Prompt "Password for $($definition.SamAccountName)"
}
}
}
$results = foreach ($definition in $definitions) {
Ensure-ServiceAccount -Definition $definition -Password $passwords[$definition.SamAccountName]
}
$results | Format-Table -AutoSize | Out-String | Write-Output
Write-Output 'Service account creation completed successfully.'
Then delegate domain-join rights to svc.join.hq on the validated workstation and server OUs:
Show full join-delegation script
param(
[string]$JoinAccountSam = 'svc.join.hq',
[string[]]$TargetOuDns,
[switch]$ValidateOnly
)
$ErrorActionPreference = 'Stop'
$ProgressPreference = 'SilentlyContinue'
function Assert-Elevated {
$identity = [Security.Principal.WindowsIdentity]::GetCurrent()
$principal = New-Object Security.Principal.WindowsPrincipal($identity)
if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {
throw 'Run this script from an elevated PowerShell session.'
}
}
function Read-RequiredValue {
param(
[string]$Prompt,
[string]$DefaultValue
)
$fullPrompt = $Prompt
if ($DefaultValue) {
$fullPrompt = "$Prompt [$DefaultValue]"
}
while ($true) {
$value = Read-Host -Prompt $fullPrompt
if (-not [string]::IsNullOrWhiteSpace($value)) {
return $value.Trim()
}
if (-not [string]::IsNullOrWhiteSpace($DefaultValue)) {
return $DefaultValue
}
}
}
function Read-StringList {
param(
[string]$Prompt,
[string[]]$DefaultValues
)
while ($true) {
$defaultValue = $DefaultValues -join ','
$value = Read-RequiredValue -Prompt $Prompt -DefaultValue $defaultValue
$items = @(
$value.Split(',') |
ForEach-Object { $_.Trim() } |
Where-Object { -not [string]::IsNullOrWhiteSpace($_) }
)
if ($items.Count -gt 0) {
return $items
}
}
}
function Resolve-Inputs {
$script:JoinAccountSam = if ([string]::IsNullOrWhiteSpace($JoinAccountSam)) {
Read-RequiredValue -Prompt 'Join account SamAccountName' -DefaultValue 'svc.join.hq'
}
else {
$JoinAccountSam.Trim()
}
$script:TargetOuDns = if ($TargetOuDns.Count -eq 0) {
Read-StringList -Prompt 'Target OU distinguished names (comma-separated)' -DefaultValues @(
'OU=Workstations,OU=GNTECH,DC=corp,DC=gntech,DC=me',
'OU=Servers,OU=GNTECH,DC=corp,DC=gntech,DC=me'
)
}
else {
@($TargetOuDns | ForEach-Object { $_.Trim() } | Where-Object { $_ })
}
}
function Ensure-Module {
param([string]$Name)
Import-Module $Name
}
function Assert-JoinAccountExists {
param([string]$SamAccountName)
$account = Get-ADUser -Filter "SamAccountName -eq '$SamAccountName'" -Properties SID -ErrorAction SilentlyContinue
if (-not $account) {
throw "The join account '$SamAccountName' was not found in Active Directory."
}
return $account
}
function Assert-OuExists {
param([string]$DistinguishedName)
$ou = Get-ADOrganizationalUnit -Identity $DistinguishedName -ErrorAction SilentlyContinue
if (-not $ou) {
throw "The OU '$DistinguishedName' was not found."
}
return $ou
}
function Grant-JoinAcl {
param(
[string]$TargetOuDn,
[System.Security.Principal.SecurityIdentifier]$Sid
)
$computerClassGuid = [Guid]'bf967a86-0de6-11d0-a285-00aa003049e2'
$emptyGuid = [Guid]::Empty
$aclPath = "AD:$TargetOuDn"
$acl = Get-Acl -Path $aclPath
$rules = @(
(New-Object System.DirectoryServices.ActiveDirectoryAccessRule(
$Sid,
[System.DirectoryServices.ActiveDirectoryRights]::CreateChild,
[System.Security.AccessControl.AccessControlType]::Allow,
$computerClassGuid,
[System.DirectoryServices.ActiveDirectorySecurityInheritance]::All
)),
(New-Object System.DirectoryServices.ActiveDirectoryAccessRule(
$Sid,
[System.DirectoryServices.ActiveDirectoryRights]::DeleteChild,
[System.Security.AccessControl.AccessControlType]::Allow,
$computerClassGuid,
[System.DirectoryServices.ActiveDirectorySecurityInheritance]::All
)),
(New-Object System.DirectoryServices.ActiveDirectoryAccessRule(
$Sid,
[System.DirectoryServices.ActiveDirectoryRights]::GenericAll,
[System.Security.AccessControl.AccessControlType]::Allow,
$emptyGuid,
[System.DirectoryServices.ActiveDirectorySecurityInheritance]::Descendents,
$computerClassGuid
))
)
$results = foreach ($rule in $rules) {
$present = @($acl.Access | Where-Object {
$_.IdentityReference.Translate([System.Security.Principal.SecurityIdentifier]).Value -eq $Sid.Value -and
$_.AccessControlType -eq $rule.AccessControlType -and
($_.ActiveDirectoryRights -band $rule.ActiveDirectoryRights) -eq $rule.ActiveDirectoryRights -and
$_.ObjectType -eq $rule.ObjectType -and
$_.InheritanceType -eq $rule.InheritanceType -and
$_.InheritedObjectType -eq $rule.InheritedObjectType
}).Count -gt 0
if (-not $present -and -not $ValidateOnly) { $acl.AddAccessRule($rule) | Out-Null }
[pscustomobject]@{
TargetOuDn = $TargetOuDn
IdentitySid = $Sid.Value
Rights = $rule.ActiveDirectoryRights
InheritanceType = $rule.InheritanceType
Action = if ($present) { 'Compliant' } elseif ($ValidateOnly) { 'Missing' } else { 'Granted' }
}
}
if (-not $ValidateOnly -and @($results | Where-Object Action -eq 'Granted').Count -gt 0) {
Set-Acl -Path $aclPath -AclObject $acl
}
return $results
}
Resolve-Inputs
Assert-Elevated
Ensure-Module -Name ActiveDirectory
$joinAccount = Assert-JoinAccountExists -SamAccountName $script:JoinAccountSam
$summary = [pscustomobject]@{
JoinAccountSamAccountName = $joinAccount.SamAccountName
JoinAccountDn = $joinAccount.DistinguishedName
TargetOus = ($script:TargetOuDns -join ' | ')
ValidateOnly = [bool]$ValidateOnly
}
$summary | Format-List | Out-String | Write-Output
foreach ($targetOuDn in $script:TargetOuDns) {
Assert-OuExists -DistinguishedName $targetOuDn | Out-Null
Grant-JoinAcl -TargetOuDn $targetOuDn -Sid $joinAccount.SID |
Format-Table -AutoSize | Out-String | Write-Output
}
if ($ValidateOnly) {
Write-Output 'Validation mode only. No delegation changes were applied.'
}
else {
Write-Output 'Join delegation completed successfully.'
}
Permission boundary:
svc.join.hqhas a validated effective permission: create, delete, and manage computer objects beneath the configuredWorkstationsandServersOUs. A real join ofHQ-CL02proved the workstation path.svc.backup.hq,svc.monitor.hq, andsvc.deploy.hqare provisioned identities only; this runbook does not delegate application, server, share, backup, monitoring, or deployment rights to them.- Do not claim those three accounts are operational until a later role-specific runbook grants least privilege and captures a real functional test.
-PasswordNeverExpiresis optional, not the default. Prefer a managed service account when the consuming product supports it.
Validate without changing AD:
.\Create-HQ-ServiceAccounts.ps1 -ServiceUpnSuffix 'gntech.me' -ValidateOnly
.\Grant-HQ-JoinDelegation.ps1 -ValidateOnly
Remediation
If post-build validation shows DHCP authorization or group errors such as:
The DHCP service was unable to create or lookup the DHCP Users local groupThe DHCP server was unable to create or lookup the DHCP Administrators local groupThe DHCP service failed to see a directory server for authorizationThe DHCP/BINL service ... is not authorized to start
run this recovery script locally on HQ-DC01 from an elevated PowerShell session:
$ErrorActionPreference = 'Stop'
$ProgressPreference = 'SilentlyContinue'
Import-Module DhcpServer
$DcFqdn = 'HQ-DC01.corp.gntech.me'
$DcIp = '172.20.20.11'
& netsh dhcp add securitygroups | Out-Null
Restart-Service DHCPServer -Force
Start-Sleep -Seconds 15
if (-not (Get-DhcpServerInDC -ErrorAction SilentlyContinue | Where-Object DnsName -eq $DcFqdn)) {
Add-DhcpServerInDC -DnsName $DcFqdn -IpAddress $DcIp
Start-Sleep -Seconds 15
}
Restart-Service DHCPServer -Force
Get-DhcpServerInDC
Get-Service DHCPServer | Select-Object Name,Status,StartType
If validation shows only DFSREvent warnings but NETLOGON and SYSVOL shares already exist, treat that as convergence-in-progress rather than immediate failure. Wait 15 to 30 minutes and re-run the validation script. Escalate only if:
NETLOGONorSYSVOLis missingDFSRis not runningdcdiag /test:SysVolCheckordcdiag /test:NetLogonsstill fails after the wait
If the first interactive console logon on HQ-DC01 shows a black screen but the domain controller services are otherwise healthy, Explorer.exe and sihost.exe may be stuck in a bad post-logon state. In the validated lab, the following local recovery restored the shell successfully without changing the DC role state:
$ErrorActionPreference = 'Stop'
$ProgressPreference = 'SilentlyContinue'
Stop-Process -Name explorer -Force -ErrorAction SilentlyContinue
Stop-Process -Name sihost -Force -ErrorAction SilentlyContinue
Start-Sleep -Seconds 5
Start-Process 'C:\Windows\explorer.exe'
Get-Process explorer -ErrorAction SilentlyContinue | ForEach-Object {
Write-Output "Explorer PID: $($_.Id)"
}
Use that only as a shell recovery step when:
AD DS,DNS, andDHCPare already functioningWinlogonshell registry values are still default- the issue is limited to the interactive desktop experience
Do not treat this as a substitute for fixing a broken promotion or broken directory services state.
Rollback
- Discard the clone and recreate it from
4002if promotion fails before the forest is successfully created. - If promotion partly succeeds, demote only if the failure mode is well understood; otherwise restore from snapshot or rebuild from template.
- Ensure any temporary password-bearing script created on
H1is deleted immediately after use.
Sources
- Microsoft Learn:
Install-WindowsFeature - https://learn.microsoft.com/en-us/powershell/module/servermanager/install-windowsfeature?view=windowsserver2025-ps
- Microsoft Learn:
Install-ADDSForest - https://learn.microsoft.com/en-us/powershell/module/addsdeployment/install-addsforest?view=windowsserver2025-ps
- Microsoft Learn:
Add-DhcpServerInDC - https://learn.microsoft.com/en-us/powershell/module/dhcpserver/add-dhcpserverindc?view=windowsserver2025-ps
- Microsoft Learn: Microsoft Entra UPN population
- https://learn.microsoft.com/en-us/entra/identity/hybrid/connect/plan-connect-userprincipalname