Skip to content

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-T01 through P03-T05 and the approved Windows 11 image workflow.
  • Confirm 4001 is stopped and has template: 1.
  • Confirm CHR DHCP relay and workstation-to-DC rules precede their default drops.
  • Confirm the DHCP scope supplies gateway 172.20.30.1, DNS 172.20.20.11, and suffix corp.gntech.me.
  • Confirm svc.join.hq exists and its delegation is compliant on OU=Workstations.
  • Use Proxmox only to clone, tag VLAN 30, and start the VM. Perform Windows onboarding inside the guest.

Current State Verification

On Proxmox:

qm config 4001
qm status 4001
qm config 4012

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:

  • 4001 completes OOBE, remains outside the domain, receives DHCP, and runs QGA
  • it also retains the build gateway 172.20.110.1 in PersistentStore
  • the onboarding script blocks unexpected gateways by default
  • -RemoveLegacyBuildGateway removes only the explicitly configured build gateway from both PersistentStore and ActiveStore on a DHCP adapter
  • recapture 4001 through 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 former DESKTOP-KN9M7KD object no longer exists
  • IP 172.20.30.101 is 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.hq exists in the approved service-account OU and has workstation/server delegation

Evidence

  • qm config 4001 and qm 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.