P03-T06 - Basic Workstation Join Validation
Objective
Deploy a Windows 11 client from TPL-W11E, validate DHCP/DNS/routing, assign the final hostname, join corp.gntech.me with delegated credentials, and prove OU placement and computer policy.
Validated on Saturday, July 18, 2026 with one endpoint, HQ-CL01. A second endpoint is required only when concurrent administration or repeat evidence is explicitly needed.
Inputs
| Key | Validated value | Configurable parameter |
|---|---|---|
| Template | 4001 / TPL-W11E |
Select approved workstation template |
| Client | 4012 / HQ-CL01 |
TargetName |
| VLAN | 30 |
Proxmox NIC tag |
| DHCP | 172.20.30.100-199 |
Site scope |
| Gateway | 172.20.30.1 |
ExpectedGateway |
| DNS | 172.20.20.11 |
ExpectedDnsServer |
| Domain | corp.gntech.me |
DomainName |
| OU | OU=Workstations,OU=GNTECH,DC=corp,DC=gntech,DC=me |
OuPath |
| Join account | GNTECH\svc.join.hq |
Secure credential prompt |
Target State
| Property | Required result |
|---|---|
| Addressing | DHCP only |
| Default gateway | Only 172.20.30.1 |
| Hostname | HQ-CL01 |
| Domain | corp.gntech.me |
| OU | OU=Workstations |
| Site | HQ |
| Policy | Baseline and hardening GPOs applied |
Prechecks
- Complete
P03-T01throughP03-T05and the approved Windows 11 image workflow. - Confirm
4001is stopped and hastemplate: 1. - Confirm CHR DHCP relay and workstation-to-DC rules precede their default drops.
- Confirm the DHCP scope supplies gateway
172.20.30.1, DNS172.20.20.11, and suffixcorp.gntech.me. - Confirm
svc.join.hqexists and its delegation is compliant onOU=Workstations. - Use Proxmox only to clone, tag VLAN 30, and start the VM. Perform Windows onboarding inside the guest.
Current State Verification
On Proxmox:
Inside the new workstation, before joining:
$setup = Get-ItemProperty 'HKLM:\SYSTEM\Setup'
$state = Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Setup\State'
$setup | Select-Object OOBEInProgress,SystemSetupInProgress
$state | Select-Object ImageState
Get-NetIPAddress -AddressFamily IPv4
Get-NetRoute -AddressFamily IPv4 -DestinationPrefix '0.0.0.0/0'
Get-NetRoute -PolicyStore PersistentStore -AddressFamily IPv4 -DestinationPrefix '0.0.0.0/0' -ErrorAction SilentlyContinue
Known template finding from the July 18 clone-test:
4001completes OOBE, remains outside the domain, receives DHCP, and runs QGA- it also retains the build gateway
172.20.110.1inPersistentStore - the onboarding script blocks unexpected gateways by default
-RemoveLegacyBuildGatewayremoves only the explicitly configured build gateway from both PersistentStore and ActiveStore on a DHCP adapter- recapture
4001through the interactive Sysprep image workflow to remove this workaround permanently; do not run Sysprep through QGA
Execution
Clone and network the endpoint on Proxmox. Use an unused customer VMID; the validated lab currently uses 4012:
qm clone 4001 4012 --name HQ-CL01 --full 1 --storage local-zfs
qm set 4012 --cores 2 --memory 4096 --balloon 0 --net0 virtio,bridge=GEILLAN,tag=30,firewall=0
qm start 4012
Run the onboarding script from elevated PowerShell inside the workstation. The normal sysadmin path securely prompts for the domain credential:
Set-ExecutionPolicy -ExecutionPolicy Bypass -Scope Process -Force
.\Join-HQ-Workstation.ps1 -TargetName 'HQ-CL01' -RemoveLegacyBuildGateway -RestartIfNeeded
Show full workstation onboarding script
param(
[string]$TargetName,
[string]$DomainName,
[string]$OuPath,
[string]$DomainControllerFqdn,
[string]$ExpectedDnsServer,
[string]$ExpectedGateway,
[string]$LegacyBuildGateway = '172.20.110.1',
[string]$InternetTestHost,
[pscredential]$DomainCredential,
[switch]$RemoveLegacyBuildGateway,
[switch]$RestartIfNeeded,
[switch]$SkipInternetTest
)
$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 Resolve-Inputs {
$script:TargetName = if ([string]::IsNullOrWhiteSpace($TargetName)) {
Read-RequiredValue -Prompt 'Target workstation name' -DefaultValue $env:COMPUTERNAME
}
else {
$TargetName.Trim()
}
$script:DomainName = if ([string]::IsNullOrWhiteSpace($DomainName)) {
Read-RequiredValue -Prompt 'Domain FQDN' -DefaultValue 'corp.gntech.me'
}
else {
$DomainName.Trim()
}
$script:OuPath = if ([string]::IsNullOrWhiteSpace($OuPath)) {
Read-RequiredValue -Prompt 'Target OU distinguished name' -DefaultValue 'OU=Workstations,OU=GNTECH,DC=corp,DC=gntech,DC=me'
}
else {
$OuPath.Trim()
}
$script:DomainControllerFqdn = if ([string]::IsNullOrWhiteSpace($DomainControllerFqdn)) {
Read-RequiredValue -Prompt 'Domain controller FQDN for validation' -DefaultValue 'hq-dc01.corp.gntech.me'
}
else {
$DomainControllerFqdn.Trim()
}
$script:ExpectedDnsServer = if ([string]::IsNullOrWhiteSpace($ExpectedDnsServer)) {
Read-RequiredValue -Prompt 'Expected DNS server IPv4' -DefaultValue '172.20.20.11'
}
else {
$ExpectedDnsServer.Trim()
}
$script:ExpectedGateway = if ([string]::IsNullOrWhiteSpace($ExpectedGateway)) {
Read-RequiredValue -Prompt 'Expected default gateway IPv4' -DefaultValue '172.20.30.1'
}
else {
$ExpectedGateway.Trim()
}
$script:InternetTestHost = if ([string]::IsNullOrWhiteSpace($InternetTestHost)) {
Read-RequiredValue -Prompt 'Internet validation host' -DefaultValue 'github.com'
}
else {
$InternetTestHost.Trim()
}
}
function Assert-Elevated {
$identity = [Security.Principal.WindowsIdentity]::GetCurrent()
$principal = [Security.Principal.WindowsPrincipal]::new($identity)
if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {
throw 'Run this script from an elevated PowerShell session.'
}
}
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 Test-FirstBootState {
$setupState = Get-ItemProperty 'HKLM:\SYSTEM\Setup'
$imageState = Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Setup\State'
if ($setupState.OOBEInProgress -ne 0) {
throw 'OOBE is still in progress. Stop and fix the source template before domain join.'
}
if ($setupState.SystemSetupInProgress -ne 0) {
throw 'System setup is still in progress. Stop and fix the source template before domain join.'
}
if ($imageState.ImageState -ne 'IMAGE_STATE_COMPLETE') {
throw "Unexpected image state '$($imageState.ImageState)'. Stop and fix the source template before domain join."
}
}
function Test-NetworkReadiness {
param(
[Microsoft.Management.Infrastructure.CimInstance]$Adapter
)
$ipConfig = Get-NetIPConfiguration -InterfaceIndex $Adapter.ifIndex
$ipv4Address = $ipConfig.IPv4Address | Select-Object -First 1
$defaultGateways = @($ipConfig.IPv4DefaultGateway)
$dnsServers = (Get-DnsClientServerAddress -InterfaceIndex $Adapter.ifIndex -AddressFamily IPv4).ServerAddresses
if (-not $ipv4Address) {
throw 'No IPv4 address is assigned on the active adapter.'
}
if (-not $ipv4Address.PrefixOrigin -or $ipv4Address.PrefixOrigin -ne 'Dhcp') {
throw 'The active adapter is not using DHCP for IPv4. Workstation onboarding requires DHCP.'
}
if ($defaultGateways.Count -eq 0) {
throw 'No default gateway is present on the active adapter.'
}
$gatewayAddresses = @($defaultGateways | ForEach-Object NextHop | Where-Object { $_ })
if ($ExpectedGateway -notin $gatewayAddresses) {
throw "Expected gateway $ExpectedGateway was not found on the active adapter."
}
$unexpectedGateways = @($gatewayAddresses | Where-Object { $_ -ne $ExpectedGateway })
if ($unexpectedGateways.Count -gt 0) {
throw "Unexpected default gateway(s) detected: $($unexpectedGateways -join ', '). Correct the template or use the explicit legacy-build cleanup switch."
}
if (-not $dnsServers -or $dnsServers.Count -eq 0) {
throw 'No IPv4 DNS servers are configured on the active adapter.'
}
if ($ExpectedDnsServer -notin $dnsServers) {
throw "Expected DNS server $ExpectedDnsServer was not found on the active adapter."
}
Resolve-DnsName $DomainControllerFqdn | Out-Null
if (-not $SkipInternetTest) {
$internetTest = Test-NetConnection $InternetTestHost -Port 443 -WarningAction SilentlyContinue
if (-not $internetTest.TcpTestSucceeded) {
throw "Internet validation failed for $InternetTestHost on TCP 443."
}
}
Write-Output "ActiveAdapter: $($Adapter.Name)"
Write-Output "IPv4: $($ipv4Address.IPAddress)"
Write-Output "Gateway: $($gatewayAddresses -join ', ')"
Write-Output "DnsServers: $($dnsServers -join ', ')"
}
function Get-DomainJoinCredential {
if ($DomainCredential) { return $DomainCredential }
$defaultUser = "Administrator@$($script:DomainName)"
try {
return Get-Credential -UserName $defaultUser -Message 'Enter domain join credential'
}
catch {
throw 'A valid domain credential is required to join the workstation.'
}
}
function Remove-LegacyBuildRoute {
param([Microsoft.Management.Infrastructure.CimInstance]$Adapter)
if (-not $RemoveLegacyBuildGateway) { return }
if ([string]::IsNullOrWhiteSpace($LegacyBuildGateway)) {
throw 'LegacyBuildGateway is required when RemoveLegacyBuildGateway is used.'
}
$interface = Get-NetIPInterface -InterfaceIndex $Adapter.ifIndex -AddressFamily IPv4
if ($interface.Dhcp -ne 'Enabled') {
throw 'Legacy build-route cleanup is allowed only on a DHCP-enabled adapter.'
}
$legacyRoutes = @(Get-NetRoute `
-PolicyStore PersistentStore `
-InterfaceIndex $Adapter.ifIndex `
-DestinationPrefix '0.0.0.0/0' `
-ErrorAction SilentlyContinue |
Where-Object NextHop -eq $LegacyBuildGateway)
foreach ($route in $legacyRoutes) {
Remove-NetRoute `
-PolicyStore PersistentStore `
-InterfaceIndex $Adapter.ifIndex `
-DestinationPrefix $route.DestinationPrefix `
-NextHop $route.NextHop `
-Confirm:$false
}
$activeLegacyRoutes = @(Get-NetRoute `
-PolicyStore ActiveStore `
-InterfaceIndex $Adapter.ifIndex `
-DestinationPrefix '0.0.0.0/0' `
-ErrorAction SilentlyContinue |
Where-Object NextHop -eq $LegacyBuildGateway)
foreach ($route in $activeLegacyRoutes) {
Remove-NetRoute `
-PolicyStore ActiveStore `
-InterfaceIndex $Adapter.ifIndex `
-DestinationPrefix $route.DestinationPrefix `
-NextHop $route.NextHop `
-Confirm:$false
}
Write-Output "LegacyBuildPersistentRoutesRemoved: $($legacyRoutes.Count)"
Write-Output "LegacyBuildActiveRoutesRemoved: $($activeLegacyRoutes.Count)"
}
Resolve-Inputs
Assert-Elevated
Test-FirstBootState
$adapter = Get-PrimaryAdapter
Remove-LegacyBuildRoute -Adapter $adapter
Test-NetworkReadiness -Adapter $adapter
$computerSystem = Get-CimInstance Win32_ComputerSystem
$renameRequired = $computerSystem.Name -ne $TargetName
$joinRequired = -not $computerSystem.PartOfDomain
$restartRequired = $false
Write-Output "CurrentComputerName: $($computerSystem.Name)"
Write-Output "TargetComputerName: $TargetName"
Write-Output "PartOfDomain: $($computerSystem.PartOfDomain)"
if (-not $renameRequired -and -not $joinRequired) {
Write-Output 'No rename or domain join action is required.'
return
}
$credential = Get-DomainJoinCredential
if ($renameRequired -and $joinRequired) {
Add-Computer `
-DomainName $DomainName `
-Credential $credential `
-OUPath $OuPath `
-NewName $TargetName `
-Force
$restartRequired = $true
Write-Output 'The workstation was renamed and joined to the domain.'
}
elseif ($joinRequired) {
Add-Computer `
-DomainName $DomainName `
-Credential $credential `
-OUPath $OuPath `
-Force
$restartRequired = $true
Write-Output 'The workstation was joined to the domain.'
}
elseif ($renameRequired) {
Rename-Computer -NewName $TargetName -DomainCredential $credential -Force
$restartRequired = $true
Write-Output 'The workstation was renamed.'
}
if ($restartRequired -and $RestartIfNeeded) {
Restart-Computer -Force
}
elseif ($restartRequired) {
Write-Output 'A restart is required to complete the workstation onboarding.'
}
Automation transports may supply a PSCredential object through DomainCredential; never provide or store a plaintext password in the script or repository.
The script:
- refuses incomplete OOBE or setup state
- requires DHCP, expected DNS, expected gateway, DC resolution, and optional Internet HTTPS
- blocks additional gateways unless the explicit legacy-build cleanup is requested
- securely prompts for join credentials by default
- passes domain credentials correctly for join and for rename of an already joined workstation
- restarts only when requested
Validation
On HQ-CL01:
Get-CimInstance Win32_ComputerSystem | Select-Object Name,Domain,PartOfDomain
Get-NetIPConfiguration
Get-NetRoute -AddressFamily IPv4 -DestinationPrefix '0.0.0.0/0'
nltest /dsgetsite
gpupdate /force
gpresult /scope computer /r
Test-NetConnection hq-dc01.corp.gntech.me -Port 389
Test-NetConnection hq-fs01.corp.gntech.me -Port 445
Test-NetConnection github.com -Port 443
On HQ-DC01:
Get-ADComputer HQ-CL01 -Properties DistinguishedName,DNSHostName,LastLogonDate
Get-DhcpServerv4Lease -ScopeId 172.20.30.0 | Where-Object IPAddress -eq '172.20.30.101'
.\Grant-HQ-JoinDelegation.ps1 -ValidateOnly
Show full join-delegation validation 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.'
}
Validated result:
- hostname and AD object are
HQ-CL01; the formerDESKTOP-KN9M7KDobject no longer exists - IP
172.20.30.101is active through DHCP - the only default gateway is
172.20.30.1 - site discovery is
HQ - baseline and hardening GPOs apply
- LDAP, SMB, and external HTTPS succeed
svc.join.hqexists in the approved service-account OU and has workstation/server delegation
Evidence
qm config 4001andqm config 4012- clean clone-test output and documented legacy gateway finding
- onboarding script result
- client identity/network/site/
gpresult - DC computer object and DHCP lease
- delegation validation
Rollback
- If OOBE, DHCP, or naming is wrong, delete only the failed clone and redeploy it.
- If join partially succeeds, remove the failed computer object before retrying with a clean clone.
- Do not hand-edit the source template during normal onboarding; recapture it through the approved image workflow.