Windows 11におけるドメイン参加: PowerShellによる魔法の自動化術

Windows 11のdomain joinは、対応edition、現在workgroup、local recovery access、target domain FQDN/OU、DNSと時刻をcheckpointへ保存してから実行します。credentialは対話promptだけで受け取りstateへ保存せず、preview・実Add-Computer・restart・fresh verificationを別gateにします。

目次

join前提とrecovery accessを保存する

Professional/Enterprise/Education系、未join端末、enabled local administratorを開始条件にします。DC SRV DNSとw32tm queryをterminating evidenceにし、workgroup・computer name・local recovery SID・target FQDN/OUをCreateNew stateへ保存します。

$ErrorActionPreference='Stop'
$targetDomain='corp.contoso.example'
$targetOu='OU=Workstations,DC=corp,DC=contoso,DC=example'
$localRecoveryUser='LocalRecoveryAdmin'
if($targetDomain -notmatch '^(?=.{1,253}$)(?:[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?\.)+[A-Za-z]{2,63}$'){throw 'target domain FQDNが不正です。'}
if($targetOu -notmatch '^OU=.*?,DC='){throw 'target OU DNを確認してください。'}
$edition=[string](Get-WindowsEdition -Online -ErrorAction Stop).Edition
if($edition -notmatch '^(Professional|Enterprise|Education)'){throw "domain join非対応または未承認editionです: $edition"}
$cs=Get-CimInstance Win32_ComputerSystem -ErrorAction Stop
if([bool]$cs.PartOfDomain){throw "既にdomain joinedです: $($cs.Domain)"}
$recovery=Get-LocalUser -Name $localRecoveryUser -ErrorAction Stop
if(-not $recovery.Enabled){throw 'local recovery userが無効です。'}
$adminGroup=Get-LocalGroup -SID 'S-1-5-32-544' -ErrorAction Stop
$admins=@(Get-LocalGroupMember -Group $adminGroup.Name -ErrorAction Stop)
if($recovery.SID.Value -notin @($admins.SID.Value)){throw 'local recovery userがAdministrators memberではありません。'}
$srv=@(Resolve-DnsName -Name "_ldap._tcp.dc._msdcs.$targetDomain" -Type SRV -ErrorAction Stop)
if($srv.Count -eq 0){throw 'domain controller SRVを解決できません。'}
$null=& w32tm.exe /query /status
if($LASTEXITCODE -ne 0){throw "w32tm query failed: $LASTEXITCODE"}
$operationId=[guid]::NewGuid().ToString('D')
$stateDir='C:\ProgramData\ITtrip\DomainJoin';$null=New-Item -ItemType Directory -Path $stateDir -Force -ErrorAction Stop
$statePath=Join-Path $stateDir "$operationId.json"
$state=[ordered]@{OperationId=$operationId;Stage='Planned';ComputerName=[string]$cs.Name;OldWorkgroup=[string]$cs.Domain;TargetDomain=$targetDomain;TargetOu=$targetOu;Edition=$edition;LocalRecoveryUser=$recovery.Name;LocalRecoverySid=$recovery.SID.Value;CreatedUtc=(Get-Date).ToUniversalTime().ToString('o')}
$bytes=[Text.Encoding]::UTF8.GetBytes(($state|ConvertTo-Json -Depth 6));$fs=[IO.File]::Open($statePath,[IO.FileMode]::CreateNew,[IO.FileAccess]::Write,[IO.FileShare]::None);try{$fs.Write($bytes,0,$bytes.Length);$fs.Flush($true)}finally{$fs.Dispose()}
[pscustomobject]@{StatePath=$statePath;State=$state;DcSrvCount=$srv.Count;CredentialStored=$false}

credential非保存でpreview後に実joinする

Get-Credentialはmemory内だけで使い、finallyで参照を破棄します。承認直前にworkgroup/computer/local recovery identityを再bindし、実Add-Computer -PassThruのHasSucceededを必須にします。

$credential=Get-Credential -Message "join権限を持つ $targetDomain のcredential(stateへ保存しません)"
try{
  Add-Computer -DomainName $targetDomain -OUPath $targetOu -Credential $credential -WhatIf
  $token="JOIN-DOMAIN OP=$operationId COMPUTER=$($state.ComputerName) DOMAIN=$targetDomain OU=$targetOu"
  if((Read-Host "previewを確認し、実joinする場合だけ $token を入力") -ne $token){throw '中止しました。'}
  $again=Get-CimInstance Win32_ComputerSystem -ErrorAction Stop
  if([bool]$again.PartOfDomain -or [string]$again.Name -cne $state.ComputerName -or [string]$again.Domain -cne $state.OldWorkgroup){throw '承認後computer/workgroup identityが変わりました。'}
  $local=Get-LocalUser -Name $state.LocalRecoveryUser -ErrorAction Stop
  if(-not $local.Enabled -or $local.SID.Value -cne $state.LocalRecoverySid){throw '承認後local recovery identityが変わりました。'}
  $pass=Add-Computer -DomainName $targetDomain -OUPath $targetOu -Credential $credential -PassThru -Force -ErrorAction Stop
  if(-not $pass.HasSucceeded){throw 'Add-Computerが成功を返しませんでした。'}
  $state.Stage='JoinIssued';$state.JoinIssuedUtc=(Get-Date).ToUniversalTime().ToString('o');$state|ConvertTo-Json -Depth 6|Set-Content -LiteralPath $statePath -Encoding UTF8 -ErrorAction Stop
}finally{$credential=$null;[GC]::Collect()}
[pscustomobject]@{Stage=$state.Stage;ComputerName=$state.ComputerName;TargetDomain=$targetDomain;CredentialStored=$false}

restartを独立承認する

local recovery login、consoleまたは代替管理経路、保存済みworkを確認した別tokenでのみRestart-Computerを実行します。join発行とrestartを一つの暗黙処理にしません。

$restartToken="RESTART-AFTER-DOMAIN-JOIN OP=$($state.OperationId) DOMAIN=$($state.TargetDomain)"
if((Read-Host "local recovery loginとconsole/代替経路を確認し、再起動する場合だけ $restartToken を入力") -ne $restartToken){throw 'joinは発行済みです。stateを保存し、承認済みwindowで再起動してください。'}
$state.Stage='JoinRestartApproved';$state|ConvertTo-Json -Depth 6|Set-Content -LiteralPath $statePath -Encoding UTF8 -ErrorAction Stop
Restart-Computer -Force -ErrorAction Stop

fresh sessionでdomain・DNS・GPO・secure channelを検証する

stateからPartOfDomain=true、exact domain、same computer name、Test-ComputerSecureChannel=true、DC SRV、host DNS、gpresult computer scopeを個別に検証します。一つでも失敗すればJoinedVerifiedにしません。

$statePath='PASTE_STATE_PATH'
$state=Get-Content -LiteralPath $statePath -Raw -ErrorAction Stop|ConvertFrom-Json
if($state.Stage -ne 'JoinRestartApproved'){throw 'fresh verify対象stageではありません。'}
$cs=Get-CimInstance Win32_ComputerSystem -ErrorAction Stop
if(-not [bool]$cs.PartOfDomain -or [string]$cs.Domain -ine [string]$state.TargetDomain -or [string]$cs.Name -cne [string]$state.ComputerName){throw 'PartOfDomain/domain/computer identityが一致しません。'}
if(-not(Test-ComputerSecureChannel -ErrorAction Stop)){throw 'joined secure channelが不健全です。'}
$srv=@(Resolve-DnsName -Name "_ldap._tcp.dc._msdcs.$($state.TargetDomain)" -Type SRV -ErrorAction Stop)
$hostDns=@(Resolve-DnsName -Name "$($state.ComputerName).$($state.TargetDomain)" -ErrorAction Stop)
if($srv.Count -eq 0 -or $hostDns.Count -eq 0){throw 'domain/host DNS verification failed'}
$null=& gpresult.exe /r /scope computer
if($LASTEXITCODE -ne 0){throw "gpresult computer scope failed: $LASTEXITCODE"}
$state.Stage='JoinedVerified';$state|ConvertTo-Json -Depth 6|Set-Content -LiteralPath $statePath -Encoding UTF8 -ErrorAction Stop
[pscustomobject]@{Stage=$state.Stage;PartOfDomain=$cs.PartOfDomain;Domain=$cs.Domain;Computer=$cs.Name;SecureChannel=$true;DcSrv=$srv.Count;HostDns=$hostDns.Count;GpoQueryExit=0}

別承認のstandalone recovery

JoinedVerified stateとhealthy current domain identityを再bindし、別credentialとtokenでRemove-Computerを実行します。restartも別承認とし、fresh sessionでPartOfDomain=false、exact workgroup、same computer、local recovery SID/admin membershipを確認します。

# standalone recovery: 別sessionでstateを読み、別credential/token/restart承認を要求する。
$statePath='PASTE_STATE_PATH'
$state=Get-Content -LiteralPath $statePath -Raw -ErrorAction Stop|ConvertFrom-Json
if($state.Stage -ne 'JoinedVerified'){throw 'recovery開始可能stageではありません。'}
$cs=Get-CimInstance Win32_ComputerSystem -ErrorAction Stop
if(-not [bool]$cs.PartOfDomain -or [string]$cs.Domain -ine [string]$state.TargetDomain -or -not(Test-ComputerSecureChannel -ErrorAction Stop)){throw 'recovery直前domain identityが一致しません。'}
$token="LEAVE-DOMAIN OP=$($state.OperationId) DOMAIN=$($state.TargetDomain) WORKGROUP=$($state.OldWorkgroup)"
if((Read-Host "standalone recoveryを承認する場合だけ $token を入力") -ne $token){throw 'recoveryを実行しません。'}
$credential=Get-Credential -Message 'domain unjoin credential(保存しません)'
try{$pass=Remove-Computer -UnjoinDomainCredential $credential -WorkgroupName ([string]$state.OldWorkgroup) -PassThru -Force -ErrorAction Stop;if(-not $pass.HasSucceeded){throw 'Remove-Computer failed'}}finally{$credential=$null;[GC]::Collect()}
$state.Stage='RecoveryIssued';$state|ConvertTo-Json -Depth 6|Set-Content -LiteralPath $statePath -Encoding UTF8 -ErrorAction Stop
$restartToken="RESTART-AFTER-RECOVERY OP=$($state.OperationId)"
if((Read-Host "recovery再起動を承認する場合だけ $restartToken を入力") -ne $restartToken){throw 'unjoinは発行済みです。stateを保存してください。'}
$state.Stage='RecoveryRestartApproved';$state|ConvertTo-Json -Depth 6|Set-Content -LiteralPath $statePath -Encoding UTF8 -ErrorAction Stop
Restart-Computer -Force -ErrorAction Stop
$statePath='PASTE_STATE_PATH'
$state=Get-Content -LiteralPath $statePath -Raw -ErrorAction Stop|ConvertFrom-Json
if($state.Stage -ne 'RecoveryRestartApproved'){throw 'recovery verify対象stageではありません。'}
$cs=Get-CimInstance Win32_ComputerSystem -ErrorAction Stop
$local=Get-LocalUser -Name $state.LocalRecoveryUser -ErrorAction Stop
$adminGroup=Get-LocalGroup -SID 'S-1-5-32-544' -ErrorAction Stop
$admins=@(Get-LocalGroupMember -Group $adminGroup.Name -ErrorAction Stop)
if([bool]$cs.PartOfDomain -or [string]$cs.Domain -cne [string]$state.OldWorkgroup -or [string]$cs.Name -cne [string]$state.ComputerName){throw 'workgroup recovery identity mismatch'}
if(-not $local.Enabled -or $local.SID.Value -cne $state.LocalRecoverySid -or $local.SID.Value -notin @($admins.SID.Value)){throw 'local recovery access mismatch'}
[pscustomobject]@{Stage='RecoveryVerified';PartOfDomain=$cs.PartOfDomain;Workgroup=$cs.Domain;Computer=$cs.Name;LocalRecoverySid=$local.SID.Value;Exact=$true}

受入条件

unsupported edition、既join、recovery user不備、DNS/time error、承認後identity変化ではAdd-Computer前に停止します。clean joinはdomain/DNS/GPO/secure channel、clean recoveryはworkgroup/local accessまでexactです。

公式情報・参考資料

この記事を書いた人

実務の現場で詰まりがちなポイントを地図にするITブログ「IT trip」を運営。Windows/Office(Teams・Excel)からSQL、サーバ運用、ガジェットまで、再現性のある手順と“なぜそうなるか”を丁寧に解説します。読んだらすぐ試せること、そして迷った人の次の一歩が見えることを大切にしています。

コメント

コメントする

目次