d25fea82b1
foreach по PSCustomObject перечисляет свойства, не cert — нужен @(). Рекурсивное flatten в Get-RdpRepairCertificateArray, самотест Export-паттерна. Co-authored-by: Cursor <cursoragent@cursor.com>
364 lines
14 KiB
PowerShell
364 lines
14 KiB
PowerShell
#Requires -Version 5.1
|
|
Set-StrictMode -Version Latest
|
|
|
|
$script:RdpRepairDataRoot = Join-Path $env:ProgramData 'RdpRepair'
|
|
$script:RdpRepairRdpTcpKey = 'HKLM:\SYSTEM\CurrentControlSet\Control\Terminal Server\WinStations\RDP-Tcp'
|
|
$script:RdpRepairTsKey = 'HKLM:\SYSTEM\CurrentControlSet\Control\Terminal Server'
|
|
|
|
function Initialize-RdpRepairDataRoot {
|
|
if (-not (Test-Path -LiteralPath $script:RdpRepairDataRoot)) {
|
|
New-Item -ItemType Directory -Path $script:RdpRepairDataRoot -Force | Out-Null
|
|
}
|
|
return $script:RdpRepairDataRoot
|
|
}
|
|
|
|
function Write-RdpRepairLog {
|
|
param(
|
|
[string]$Message,
|
|
[ValidateSet('INFO', 'WARN', 'ERROR', 'OK', 'STEP')]
|
|
[string]$Level = 'INFO',
|
|
[string]$LogPath
|
|
)
|
|
$ts = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
|
|
$line = "[$ts] [$Level] $Message"
|
|
if ($LogPath) {
|
|
Add-Content -LiteralPath $LogPath -Value $line -Encoding UTF8
|
|
}
|
|
switch ($Level) {
|
|
'ERROR' { Write-Host $line -ForegroundColor Red }
|
|
'WARN' { Write-Host $line -ForegroundColor Yellow }
|
|
'OK' { Write-Host $line -ForegroundColor Green }
|
|
'STEP' { Write-Host $line -ForegroundColor Cyan }
|
|
default { Write-Host $line }
|
|
}
|
|
}
|
|
|
|
function Test-RdpRepairIsAdministrator {
|
|
$id = [Security.Principal.WindowsIdentity]::GetCurrent()
|
|
$p = New-Object Security.Principal.WindowsPrincipal($id)
|
|
return $p.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
|
|
}
|
|
|
|
function Assert-RdpRepairAdministrator {
|
|
if (-not (Test-RdpRepairIsAdministrator)) {
|
|
throw 'Run PowerShell as Administrator.'
|
|
}
|
|
}
|
|
|
|
function Get-RdpRepairComputerDnsNames {
|
|
$cs = Get-CimInstance Win32_ComputerSystem -ErrorAction Stop
|
|
$computer = $env:COMPUTERNAME
|
|
$fqdn = "$computer.$($cs.Domain)".ToLower()
|
|
$names = @($computer, $fqdn)
|
|
if ($fqdn -notmatch '\.') {
|
|
$names += "$computer.local"
|
|
}
|
|
return $names | Select-Object -Unique
|
|
}
|
|
|
|
function Get-RdpRepairSslCertificateHash {
|
|
$p = Get-ItemProperty -Path $script:RdpRepairRdpTcpKey -Name SSLCertificateSHA1Hash -ErrorAction SilentlyContinue
|
|
if ($null -eq $p -or [string]::IsNullOrWhiteSpace($p.SSLCertificateSHA1Hash)) {
|
|
return $null
|
|
}
|
|
return $p.SSLCertificateSHA1Hash
|
|
}
|
|
|
|
function Get-RdpRepairSecurityLayer {
|
|
$p = Get-ItemProperty -Path $script:RdpRepairRdpTcpKey -ErrorAction SilentlyContinue
|
|
if ($null -eq $p.SecurityLayer) { return '(default)' }
|
|
return [int]$p.SecurityLayer
|
|
}
|
|
|
|
function Get-RdpRepairRemoteDesktopCertificates {
|
|
$list = New-Object System.Collections.Generic.List[object]
|
|
$store = New-Object System.Security.Cryptography.X509Certificates.X509Store('Remote Desktop', 'LocalMachine')
|
|
$store.Open([System.Security.Cryptography.X509Certificates.OpenFlags]::ReadOnly)
|
|
try {
|
|
foreach ($c in $store.Certificates) {
|
|
$list.Add([PSCustomObject]@{
|
|
Subject = $c.Subject
|
|
Thumbprint = $c.Thumbprint
|
|
NotAfter = $c.NotAfter
|
|
HasKey = $c.HasPrivateKey
|
|
})
|
|
}
|
|
} finally {
|
|
$store.Close()
|
|
}
|
|
return [object[]]$list.ToArray()
|
|
}
|
|
|
|
function Get-RdpRepairCertificateArray {
|
|
param([object]$Certificates)
|
|
if ($null -eq $Certificates) { return @() }
|
|
$flat = New-Object System.Collections.Generic.List[object]
|
|
$pending = New-Object System.Collections.Generic.Stack[object]
|
|
foreach ($entry in @($Certificates)) { $pending.Push($entry) }
|
|
while ($pending.Count -gt 0) {
|
|
$item = $pending.Pop()
|
|
if ($null -eq $item) { continue }
|
|
if ($item -is [object[]]) {
|
|
foreach ($inner in @($item)) { $pending.Push($inner) }
|
|
} elseif ($null -ne $item.PSObject.Properties['Thumbprint']) {
|
|
[void]$flat.Add($item)
|
|
} else {
|
|
[void]$flat.Add($item)
|
|
}
|
|
}
|
|
return [object[]]$flat.ToArray()
|
|
}
|
|
|
|
function Get-RdpRepairFirstCertificate {
|
|
param([object]$Certificates)
|
|
$arr = @(Get-RdpRepairCertificateArray -Certificates $Certificates)
|
|
if ($arr.Count -lt 1) { return $null }
|
|
return $arr[0]
|
|
}
|
|
|
|
function Clear-RdpRepairCertificateStores {
|
|
param([string]$LogPath)
|
|
foreach ($sn in @('Remote Desktop', 'My')) {
|
|
$store = New-Object System.Security.Cryptography.X509Certificates.X509Store($sn, 'LocalMachine')
|
|
$store.Open([System.Security.Cryptography.X509Certificates.OpenFlags]::ReadWrite)
|
|
try {
|
|
foreach ($c in @($store.Certificates)) {
|
|
Write-RdpRepairLog "Удаление cert из ${sn}: $($c.Thumbprint) ($($c.Subject))" 'INFO' $LogPath
|
|
$store.Remove($c)
|
|
}
|
|
} finally {
|
|
$store.Close()
|
|
}
|
|
}
|
|
Remove-ItemProperty -Path $script:RdpRepairRdpTcpKey -Name SSLCertificateSHA1Hash -ErrorAction SilentlyContinue
|
|
}
|
|
|
|
function Get-RdpRepairPrivateKeyPath {
|
|
param([System.Security.Cryptography.X509Certificates.X509Certificate2]$Certificate)
|
|
$rsa = [System.Security.Cryptography.X509Certificates.RSACertificateExtensions]::GetRSAPrivateKey($Certificate)
|
|
if (-not $rsa) { return $null }
|
|
$name = $rsa.Key.UniqueName
|
|
foreach ($base in @(
|
|
Join-Path $env:ProgramData 'Microsoft\Crypto\RSA\MachineKeys'
|
|
Join-Path $env:ProgramData 'Microsoft\Crypto\Keys'
|
|
)) {
|
|
$path = Join-Path $base $name
|
|
if (Test-Path -LiteralPath $path) { return $path }
|
|
}
|
|
return $null
|
|
}
|
|
|
|
function Set-RdpRepairPrivateKeyAcl {
|
|
param(
|
|
[System.Security.Cryptography.X509Certificates.X509Certificate2]$Certificate,
|
|
[string]$LogPath
|
|
)
|
|
$keyPath = Get-RdpRepairPrivateKeyPath -Certificate $Certificate
|
|
if (-not $keyPath) {
|
|
Write-RdpRepairLog 'Не найден файл приватного ключа' 'WARN' $LogPath
|
|
return $false
|
|
}
|
|
& icacls.exe $keyPath /grant 'NETWORK SERVICE:(F)' 'NT AUTHORITY\SYSTEM:(F)' 'BUILTIN\Administrators:(F)' 'NT AUTHORITY\LOCAL SERVICE:(R)' /c | Out-Null
|
|
Write-RdpRepairLog "ACL обновлён: $keyPath" 'OK' $LogPath
|
|
return $true
|
|
}
|
|
|
|
function Add-RdpRepairCertificateToRemoteDesktopStore {
|
|
param([System.Security.Cryptography.X509Certificates.X509Certificate2]$Certificate)
|
|
$store = New-Object System.Security.Cryptography.X509Certificates.X509Store('Remote Desktop', 'LocalMachine')
|
|
$store.Open([System.Security.Cryptography.X509Certificates.OpenFlags]::ReadWrite)
|
|
try {
|
|
$store.Add($Certificate)
|
|
} finally {
|
|
$store.Close()
|
|
}
|
|
}
|
|
|
|
function Set-RdpRepairCertificateBinding {
|
|
param(
|
|
[string]$Thumbprint,
|
|
[string]$LogPath
|
|
)
|
|
$thumb = $Thumbprint.ToUpper()
|
|
$regPath = 'HKLM\SYSTEM\CurrentControlSet\Control\Terminal Server\WinStations\RDP-Tcp'
|
|
& reg.exe add $regPath /v SSLCertificateSHA1Hash /t REG_SZ /d $thumb /f | Out-Null
|
|
try {
|
|
$ts = Get-CimInstance -Namespace root\cimv2\terminalservices -ClassName Win32_TSGeneralSetting -Filter "TerminalName='RDP-Tcp'" -ErrorAction Stop
|
|
$ts | Set-CimInstance -Property @{ SSLCertificateSHA1Hash = $thumb } -ErrorAction Stop
|
|
Write-RdpRepairLog "WMI: SSLCertificateSHA1Hash = $thumb" 'INFO' $LogPath
|
|
} catch {
|
|
Write-RdpRepairLog "WMI Set-CimInstance: $($_.Exception.Message) (reg уже записан)" 'WARN' $LogPath
|
|
}
|
|
return (Get-RdpRepairSslCertificateHash)
|
|
}
|
|
|
|
function Restart-RdpRepairTermService {
|
|
param([int]$WaitSeconds = 8)
|
|
Stop-Service TermService -Force -ErrorAction Stop
|
|
Start-Sleep -Seconds 2
|
|
Start-Service TermService -ErrorAction Stop
|
|
Start-Sleep -Seconds $WaitSeconds
|
|
}
|
|
|
|
function Test-RdpRepairCertificateBindingPersisted {
|
|
param(
|
|
[string]$ExpectedThumbprint,
|
|
[string]$LogPath
|
|
)
|
|
$before = Get-RdpRepairSslCertificateHash
|
|
Write-RdpRepairLog "Hash BEFORE TermService start: $(if ($before) { $before } else { '(empty)' })" 'INFO' $LogPath
|
|
Restart-RdpRepairTermService
|
|
$after = Get-RdpRepairSslCertificateHash
|
|
Write-RdpRepairLog "Hash AFTER TermService start: $(if ($after) { $after } else { '(empty)' })" 'INFO' $LogPath
|
|
$ok = ($after -eq $ExpectedThumbprint.ToUpper())
|
|
if ($ok) {
|
|
Write-RdpRepairLog 'Привязка cert принята TermService' 'OK' $LogPath
|
|
} else {
|
|
Write-RdpRepairLog 'TermService сбросил или отклонил привязку cert' 'ERROR' $LogPath
|
|
}
|
|
return [PSCustomObject]@{
|
|
Before = $before
|
|
After = $after
|
|
Persisted = $ok
|
|
}
|
|
}
|
|
|
|
function New-RdpRepairSelfSignedCertificate {
|
|
param(
|
|
[string[]]$DnsNames,
|
|
[string]$LogPath
|
|
)
|
|
$providers = @(
|
|
@{ Name = 'Microsoft Enhanced RSA and AES Cryptographic Provider'; KeySpec = 'KeyExchange' }
|
|
@{ Name = $null; KeySpec = $null }
|
|
)
|
|
$notAfter = (Get-Date).AddYears(2)
|
|
$subject = "CN=$($DnsNames | Select-Object -First 1)"
|
|
foreach ($prov in $providers) {
|
|
try {
|
|
$params = @{
|
|
Subject = $subject
|
|
DnsName = $DnsNames
|
|
CertStoreLocation = 'Cert:\LocalMachine\My'
|
|
KeyAlgorithm = 'RSA'
|
|
KeyLength = 2048
|
|
KeyUsage = 'DigitalSignature', 'KeyEncipherment'
|
|
TextExtension = @('2.5.29.37={text}1.3.6.1.5.5.7.3.1')
|
|
NotAfter = $notAfter
|
|
}
|
|
if ($prov.Name) {
|
|
$params['Provider'] = $prov.Name
|
|
$params['KeySpec'] = $prov.KeySpec
|
|
}
|
|
$cert = New-SelfSignedCertificate @params
|
|
$keyPath = Get-RdpRepairPrivateKeyPath -Certificate $cert
|
|
$provLabel = if ($prov.Name) { $prov.Name } else { 'default' }
|
|
Write-RdpRepairLog "Создан cert $($cert.Thumbprint) провайдер=$provLabel key=$keyPath" 'OK' $LogPath
|
|
return $cert
|
|
} catch {
|
|
$provLabel = if ($prov.Name) { $prov.Name } else { 'default' }
|
|
Write-RdpRepairLog "New-SelfSignedCertificate ($provLabel): $($_.Exception.Message)" 'WARN' $LogPath
|
|
}
|
|
}
|
|
throw 'Не удалось создать самозаверяющий сертификат ни одним провайдером.'
|
|
}
|
|
|
|
function Invoke-RdpRepairNativeReset {
|
|
param(
|
|
[string]$LogPath,
|
|
[switch]$Reboot
|
|
)
|
|
Write-RdpRepairLog 'Нативный сброс RDP cert (без ручного cert)' 'STEP' $LogPath
|
|
Stop-Service TermService -Force -ErrorAction SilentlyContinue
|
|
Set-ItemProperty -Path $script:RdpRepairTsKey -Name fDenyTSConnections -Value 1
|
|
Clear-RdpRepairCertificateStores -LogPath $LogPath
|
|
Set-ItemProperty -Path $script:RdpRepairTsKey -Name fDenyTSConnections -Value 0
|
|
if ($Reboot) {
|
|
Write-RdpRepairLog 'Перезагрузка через 60 с для нативного создания cert...' 'WARN' $LogPath
|
|
shutdown.exe /r /t 60 /f /c 'RdpRepair: native certificate rebuild'
|
|
return [PSCustomObject]@{ RebootScheduled = $true }
|
|
}
|
|
Start-Service TermService -ErrorAction SilentlyContinue
|
|
Start-Sleep -Seconds 5
|
|
$certs = @(Get-RdpRepairRemoteDesktopCertificates)
|
|
return [PSCustomObject]@{
|
|
RebootScheduled = $false
|
|
CertCount = $certs.Count
|
|
Certificates = ,[object[]]$certs
|
|
}
|
|
}
|
|
|
|
function Get-RdpRepairDiagnosticSnapshot {
|
|
$polPath = 'HKLM:\SOFTWARE\Policies\Microsoft\Windows NT\Terminal Services'
|
|
$pol = if (Test-Path $polPath) { Get-ItemProperty $polPath -ErrorAction SilentlyContinue } else { $null }
|
|
$fips = (Get-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Control\Lsa\FipsAlgorithmPolicy' -Name Enabled -ErrorAction SilentlyContinue).Enabled
|
|
$certs = @(Get-RdpRepairRemoteDesktopCertificates)
|
|
$ev1057 = @(Get-WinEvent -FilterHashtable @{
|
|
LogName = 'System'
|
|
Id = 1057, 36870
|
|
StartTime = (Get-Date).AddHours(-24)
|
|
} -ErrorAction SilentlyContinue).Count
|
|
$os = Get-CimInstance Win32_OperatingSystem
|
|
return [PSCustomObject]@{
|
|
ComputerName = $env:COMPUTERNAME
|
|
OsCaption = $os.Caption
|
|
OsBuild = $os.BuildNumber
|
|
FipsEnabled = $fips
|
|
SecurityLayer = Get-RdpRepairSecurityLayer
|
|
SslHash = Get-RdpRepairSslCertificateHash
|
|
TermService = (Get-Service TermService).Status
|
|
RdpAllowed = -not (Get-ItemProperty $script:RdpRepairTsKey).fDenyTSConnections
|
|
CertCount = $certs.Count
|
|
Certificates = ,[object[]]$certs
|
|
GpoLicenseSrv = $pol.LicenseServers
|
|
GpoLicMode = $pol.LicensingMode
|
|
Events1057_24h = $ev1057
|
|
}
|
|
}
|
|
|
|
function Export-RdpRepairDiagnosticReport {
|
|
param([string]$LogPath)
|
|
$snap = Get-RdpRepairDiagnosticSnapshot
|
|
$reportPath = Join-Path (Initialize-RdpRepairDataRoot) ("report-{0:yyyyMMdd-HHmmss}.txt" -f (Get-Date))
|
|
$lines = @(
|
|
"=== RDP/TLS Diagnostic Report ==="
|
|
"Generated: $(Get-Date -Format o)"
|
|
""
|
|
)
|
|
$snap.PSObject.Properties | ForEach-Object {
|
|
if ($_.Name -eq 'Certificates') {
|
|
$lines += 'Certificates:'
|
|
foreach ($c in @(Get-RdpRepairCertificateArray $_.Value)) {
|
|
$lines += (' - {0}; Thumb={1}; NotAfter={2}; Key={3}' -f $c.Subject, $c.Thumbprint, $c.NotAfter, $c.HasKey)
|
|
}
|
|
} else {
|
|
$lines += ('{0}: {1}' -f $_.Name, $_.Value)
|
|
}
|
|
}
|
|
$text = $lines -join "`r`n"
|
|
[System.IO.File]::WriteAllText($reportPath, $text, [System.Text.UTF8Encoding]::new($false))
|
|
Write-RdpRepairLog "Отчёт: $reportPath" 'OK' $LogPath
|
|
return $reportPath
|
|
}
|
|
|
|
function Set-RdpRepairSecurityLayer {
|
|
param(
|
|
[int]$Layer,
|
|
[string]$LogPath
|
|
)
|
|
Stop-Service TermService -Force -ErrorAction Stop
|
|
Set-ItemProperty -Path $script:RdpRepairRdpTcpKey -Name SecurityLayer -Value $Layer
|
|
if ($Layer -eq 0 -or $Layer -eq 1) {
|
|
Remove-ItemProperty -Path $script:RdpRepairRdpTcpKey -Name SSLCertificateSHA1Hash -ErrorAction SilentlyContinue
|
|
}
|
|
Start-Service TermService -ErrorAction Stop
|
|
Write-RdpRepairLog "SecurityLayer установлен в $Layer" 'OK' $LogPath
|
|
}
|
|
|
|
function Confirm-RdpRepairContinue {
|
|
param([string]$Prompt)
|
|
if ($env:RDP_REPAIR_NONINTERACTIVE -eq '1') { return $true }
|
|
$r = Read-Host "$Prompt [Y/n]"
|
|
return ($r -eq '' -or $r -match '^[yY]$')
|
|
}
|