Remote Desktop有効化はregistry、firewall、service、listenerを別fieldとして扱います。対応edition、現在のnetwork profile、承認ruleのNameと件数を両PolicyStoreで固定し、Public/Any scopeやactive profile不一致を拒否します。rollbackもregistryと両storeのexact readbackまで完了条件にします。
edition・profile・rule identityを固定する
Professional/Enterprise/Education/Server系のみを許可し、active profileが0件またはPublicなら停止します。approved rule Nameを明示し、ActiveStoreとPersistentStoreの件数・Name集合を完全一致させ、Any/Public profileを拒否します。PortNumberも3389へ固定します。
$ErrorActionPreference='Stop'
$approvedRuleNames=@('RemoteDesktop-UserMode-In-TCP','RemoteDesktop-UserMode-In-UDP')
$edition=[string](Get-WindowsEdition -Online -ErrorAction Stop).Edition
if($edition -notmatch '^(Professional|Enterprise|Education|Server)'){throw "RDP host非対応または未承認editionです: $edition"}
$activeProfiles=@(Get-NetConnectionProfile -ErrorAction Stop|Where-Object{$_.IPv4Connectivity -ne 'Disconnected' -or $_.IPv6Connectivity -ne 'Disconnected'})
if($activeProfiles.Count -eq 0){throw 'active network profileがありません。'}
if(@($activeProfiles|Where-Object NetworkCategory -eq 'Public').Count){throw 'Public networkでは有効化しません。'}
$activeRules=@($approvedRuleNames|ForEach-Object{Get-NetFirewallRule -PolicyStore ActiveStore -Name $_ -ErrorAction Stop})
$persistentRules=@($approvedRuleNames|ForEach-Object{Get-NetFirewallRule -PolicyStore PersistentStore -Name $_ -ErrorAction Stop})
if($activeRules.Count -ne $approvedRuleNames.Count -or $persistentRules.Count -ne $approvedRuleNames.Count){throw 'approved rule countが両storeで一致しません。'}
if((@($activeRules.Name|Sort-Object)-join ',') -cne (@($approvedRuleNames|Sort-Object)-join ',') -or (@($persistentRules.Name|Sort-Object)-join ',') -cne (@($approvedRuleNames|Sort-Object)-join ',')){throw 'approved rule identityが一致しません。'}
foreach($rule in @($activeRules+$persistentRules)){if([string]$rule.Profile -match 'Any|Public'){throw "Any/Public scopeを拒否します: $($rule.Name)"}}
$regPath='HKLM:\SYSTEM\CurrentControlSet\Control\Terminal Server'
$rdpTcpPath='HKLM:\SYSTEM\CurrentControlSet\Control\Terminal Server\WinStations\RDP-Tcp'
$beforeDeny=[int](Get-ItemPropertyValue -LiteralPath $regPath -Name fDenyTSConnections -ErrorAction Stop)
$port=[int](Get-ItemPropertyValue -LiteralPath $rdpTcpPath -Name PortNumber -ErrorAction Stop)
if($port -ne 3389){throw "expected listener port 3389ではありません: $port"}
$service=Get-Service TermService -ErrorAction Stop
$baseline=[pscustomobject]@{Edition=$edition;Deny=$beforeDeny;Port=$port;ServiceStatus=[string]$service.Status;ActiveProfiles=@($activeProfiles|ForEach-Object{"$($_.InterfaceIndex)|$($_.Name)|$($_.NetworkCategory)"});PersistentRules=@($persistentRules|ForEach-Object{[pscustomobject]@{Name=$_.Name;Enabled=[string]$_.Enabled;Profile=[string]$_.Profile}});ActiveRules=@($activeRules|ForEach-Object{[pscustomobject]@{Name=$_.Name;Enabled=[string]$_.Enabled;Profile=[string]$_.Profile}})}
$baseline
承認後に再bindして限定変更する
tokenへedition、active profiles、rule namesを含め、承認後にprofileとPersistentStore identityを再読します。registry、approved rules、TermServiceだけを変更し、途中error時は開始前registry/rule値へ戻してreadbackします。
$operationId=[guid]::NewGuid().ToString('D')
$token="ENABLE-RDP OP=$operationId EDITION=$edition PROFILES=$($baseline.ActiveProfiles -join ';') RULES=$($approvedRuleNames -join ',')"
if((Read-Host "registry/firewall/service変更を承認する場合だけ $token を入力") -ne $token){throw '中止しました。'}
$reboundProfiles=@(Get-NetConnectionProfile -ErrorAction Stop|Where-Object{$_.IPv4Connectivity -ne 'Disconnected' -or $_.IPv6Connectivity -ne 'Disconnected'})
if((@($reboundProfiles|ForEach-Object{"$($_.InterfaceIndex)|$($_.Name)|$($_.NetworkCategory)"}|Sort-Object)-join ',') -cne (@($baseline.ActiveProfiles|Sort-Object)-join ',')){throw '承認後active profileが変わりました。'}
if(@($reboundProfiles|Where-Object NetworkCategory -eq 'Public').Count){throw '承認後Publicになりました。'}
$reboundPersistent=@($approvedRuleNames|ForEach-Object{Get-NetFirewallRule -PolicyStore PersistentStore -Name $_ -ErrorAction Stop})
if($reboundPersistent.Count -ne $approvedRuleNames.Count -or (@($reboundPersistent.Name|Sort-Object)-join ',') -cne (@($approvedRuleNames|Sort-Object)-join ',')){throw '承認後rule identity/countが変わりました。'}
try{
Set-ItemProperty -LiteralPath $regPath -Name fDenyTSConnections -Value 0 -Type DWord -ErrorAction Stop
foreach($name in $approvedRuleNames){Set-NetFirewallRule -PolicyStore PersistentStore -Name $name -Enabled True -ErrorAction Stop}
if((Get-Service TermService -ErrorAction Stop).Status -ne 'Running'){Start-Service TermService -ErrorAction Stop}
}catch{
Set-ItemProperty -LiteralPath $regPath -Name fDenyTSConnections -Value $baseline.Deny -Type DWord -ErrorAction Stop
foreach($row in $baseline.PersistentRules){Set-NetFirewallRule -PolicyStore PersistentStore -Name $row.Name -Enabled $row.Enabled -ErrorAction Stop}
if([int](Get-ItemPropertyValue -LiteralPath $regPath -Name fDenyTSConnections -ErrorAction Stop) -ne $baseline.Deny){throw 'automatic rollback registry readback failed'}
$rb=@($approvedRuleNames|ForEach-Object{Get-NetFirewallRule -PolicyStore PersistentStore -Name $_ -ErrorAction Stop})
foreach($row in $baseline.PersistentRules){if([string]($rb|Where-Object Name -eq $row.Name).Enabled -cne $row.Enabled){throw "automatic rollback firewall failed: $($row.Name)"}}
throw
}
5つの独立完了条件
PersistentStore/ActiveStoreのexact countとName、Enabled、safe Profile、active profile compatibility、fDenyTSConnections=0、TermService Running、TCP 3389 listenerを個別に確認します。空配列や一部ruleでは成功しません。
$afterPersistent=@($approvedRuleNames|ForEach-Object{Get-NetFirewallRule -PolicyStore PersistentStore -Name $_ -ErrorAction Stop})
$afterActive=@($approvedRuleNames|ForEach-Object{Get-NetFirewallRule -PolicyStore ActiveStore -Name $_ -ErrorAction Stop})
if($afterPersistent.Count -ne $approvedRuleNames.Count -or $afterActive.Count -ne $approvedRuleNames.Count){throw 'verify rule count mismatch'}
if((@($afterPersistent.Name|Sort-Object)-join ',') -cne (@($approvedRuleNames|Sort-Object)-join ',') -or (@($afterActive.Name|Sort-Object)-join ',') -cne (@($approvedRuleNames|Sort-Object)-join ',')){throw 'verify rule identity mismatch'}
foreach($rule in @($afterPersistent+$afterActive)){
if([string]$rule.Enabled -cne 'True'){throw "rule disabled: $($rule.Name)"}
if([string]$rule.Profile -match 'Any|Public'){throw "unsafe firewall scope: $($rule.Name)"}
foreach($profile in $reboundProfiles){$required=if($profile.NetworkCategory -eq 'DomainAuthenticated'){'Domain'}else{[string]$profile.NetworkCategory};if([string]$rule.Profile -notmatch $required){throw "active profile mismatch: $($rule.Name)/$required"}}
}
$deny=[int](Get-ItemPropertyValue -LiteralPath $regPath -Name fDenyTSConnections -ErrorAction Stop)
$service=Get-Service TermService -ErrorAction Stop
$listeners=@(Get-NetTCPConnection -State Listen -LocalPort 3389 -ErrorAction Stop)
if($deny -ne 0 -or $service.Status -ne 'Running' -or $listeners.Count -eq 0){throw 'registry/TermService/3389 listenerの独立検証に失敗しました。'}
[pscustomobject]@{OperationId=$operationId;RegistryDeny=$deny;PersistentCount=$afterPersistent.Count;ActiveCount=$afterActive.Count;TermService=$service.Status;ListenerCount=$listeners.Count;Profiles=$baseline.ActiveProfiles;Exact=$true}
rollbackのregistry/firewall readback
別tokenで現在のapplied stateを再確認後、開始前registryと各Persistent rule Enabledへ戻します。両storeを再取得し、exact count・Name・Enabledがbaselineと一致した場合だけRollbackVerifiedです。
$restoreToken="RESTORE-RDP OP=$operationId DENY=$($baseline.Deny)"
if((Read-Host "開始前registry/firewallへ戻す場合だけ $restoreToken を入力。維持するならEnter") -eq $restoreToken){
$currentPersistent=@($approvedRuleNames|ForEach-Object{Get-NetFirewallRule -PolicyStore PersistentStore -Name $_ -ErrorAction Stop})
$currentActive=@($approvedRuleNames|ForEach-Object{Get-NetFirewallRule -PolicyStore ActiveStore -Name $_ -ErrorAction Stop})
if($currentPersistent.Count -ne $approvedRuleNames.Count -or $currentActive.Count -ne $approvedRuleNames.Count -or @($currentPersistent+$currentActive|Where-Object Enabled -ne True).Count){throw 'restore直前target stateが一致しません。'}
Set-ItemProperty -LiteralPath $regPath -Name fDenyTSConnections -Value $baseline.Deny -Type DWord -ErrorAction Stop
foreach($row in $baseline.PersistentRules){Set-NetFirewallRule -PolicyStore PersistentStore -Name $row.Name -Enabled $row.Enabled -ErrorAction Stop}
if($baseline.ServiceStatus -ne 'Running' -and (Get-Service TermService -ErrorAction Stop).Status -eq 'Running'){Stop-Service TermService -ErrorAction Stop}
$restoredDeny=[int](Get-ItemPropertyValue -LiteralPath $regPath -Name fDenyTSConnections -ErrorAction Stop)
$restoredPersistent=@($approvedRuleNames|ForEach-Object{Get-NetFirewallRule -PolicyStore PersistentStore -Name $_ -ErrorAction Stop})
$restoredActive=@($approvedRuleNames|ForEach-Object{Get-NetFirewallRule -PolicyStore ActiveStore -Name $_ -ErrorAction Stop})
if($restoredDeny -ne $baseline.Deny -or $restoredPersistent.Count -ne $approvedRuleNames.Count -or $restoredActive.Count -ne $approvedRuleNames.Count){throw 'restore registry/rule count readback failed'}
foreach($row in $baseline.PersistentRules){if([string]($restoredPersistent|Where-Object Name -eq $row.Name).Enabled -cne $row.Enabled){throw "PersistentStore restore mismatch: $($row.Name)"}}
foreach($row in $baseline.ActiveRules){if([string]($restoredActive|Where-Object Name -eq $row.Name).Enabled -cne $row.Enabled){throw "ActiveStore restore mismatch: $($row.Name)"}}
[pscustomobject]@{Status='RollbackVerified';RegistryDeny=$restoredDeny;PersistentCount=$restoredPersistent.Count;ActiveCount=$restoredActive.Count;Exact=$true}
}
受入条件
missing rule、partial store、Public/Any、active profile mismatch、unsupported edition、TermService停止、listenerなしの各fixtureは失敗します。clean applyとrollbackではregistry・firewall counts/identity・service/listenerの宣言値が実読値と一致します。
到達性と認証は別試験
listenerはhost内部の状態であり、外部到達性や認証成功を保証しません。管理networkからの接続、NLA、許可user/group、VPN、edge firewall、lockout policyを別の承認済み試験で確認してください。

コメント