Загрузка данных
# RUN ON THE AFFECTED WINDOWS VPS, in elevated 64-bit Windows PowerShell.
# REPAIR, not just diagnostics: backs up the registry, restarts RDP, allows its TCP port.
# Existing RDP sessions disconnect. No reboot. New firewall rule allows all source IPs.
& {
$RdpPort = 0 # 0 = keep valid existing port, otherwise use 3389; set explicitly if needed.
function Write-RdpLog {
param([string]$Message)
$line = (Get-Date -Format 'yyyy-MM-dd HH:mm:ss') + ' ' + $Message
Write-Host $line
if ($script:RdpRecoveryLog) { Add-Content -LiteralPath $script:RdpRecoveryLog -Value $line -Encoding UTF8 }
}
function Get-RdpValue {
param([string]$Path, [string]$Name)
$entry = [pscustomobject]@{ Path=$Path; Name=$Name; Exists=$false; Kind='Absent'; Value=$null }
if (Test-Path -LiteralPath $Path) {
$key = Get-Item -LiteralPath $Path -ErrorAction Stop
try {
if ($key.GetValueNames() -contains $Name) {
$entry.Exists = $true
$entry.Kind = $key.GetValueKind($Name).ToString()
$entry.Value = $key.GetValue($Name, $null, [Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames)
}
} finally { $key.Close() }
}
return $entry
}
function Get-RdpNumber {
param($Entry, [long]$Minimum, [long]$Maximum, [int]$Fallback)
# Salvage a numeric REG_SZ/QWORD, but never interpret binary or array data.
$number = 0L
if ($Entry.Exists -and $Entry.Kind -in @('DWord','QWord','String') -and
[long]::TryParse([string]$Entry.Value, [ref]$number) -and
$number -ge $Minimum -and $number -le $Maximum) { return [int]$number }
return $Fallback
}
function Get-RdpRepairPlan {
param([int]$PortOverride, [bool]$DomainJoined)
$root = 'HKLM:\SYSTEM\CurrentControlSet\Control\Terminal Server'
$listener = $root + '\WinStations\RDP-Tcp'
$policy = 'HKLM:\SOFTWARE\Policies\Microsoft\Windows NT\Terminal Services'
if (-not (Test-Path -LiteralPath $listener)) {
throw 'The entire RDP-Tcp key is missing. A few defaults cannot recreate it safely. Restore a backup from the same Windows version; no RDP settings changed.'
}
$specs = @(
@{Path=$root; Name='fDenyTSConnections'; Min=0; Max=0; Default=0},
@{Path=$listener; Name='fEnableWinStation'; Min=1; Max=1; Default=1},
@{Path=$listener; Name='fLogonDisabled'; Min=0; Max=0; Default=0},
@{Path=$listener; Name='PortNumber'; Min=1; Max=65535; Default=3389},
@{Path=$listener; Name='UserAuthentication'; Min=0; Max=1; Default=1},
@{Path=$listener; Name='SecurityLayer'; Min=0; Max=2; Default=1},
@{Path=$listener; Name='MinEncryptionLevel'; Min=1; Max=4; Default=3}
)
$plan = @()
foreach ($spec in $specs) {
$entry = Get-RdpValue $spec.Path $spec.Name
$desired = Get-RdpNumber $entry $spec.Min $spec.Max $spec.Default
if ($spec.Name -eq 'PortNumber' -and $PortOverride -gt 0) { $desired = $PortOverride }
$change = -not $entry.Exists -or $entry.Kind -ne 'DWord' -or $entry.Value -ne $desired
$plan += [pscustomobject]@{ Before=$entry; Desired=$desired; Change=$change }
}
foreach ($spec in @(
@{Name='fDenyTSConnections'; Min=0; Max=0; Default=0},
@{Name='UserAuthentication'; Min=0; Max=1; Default=1},
@{Name='SecurityLayer'; Min=0; Max=2; Default=1},
@{Name='MinEncryptionLevel'; Min=1; Max=4; Default=3}
)) {
$entry = Get-RdpValue $policy $spec.Name
if (-not $entry.Exists) { continue }
$desired = Get-RdpNumber $entry $spec.Min $spec.Max $spec.Default
$change = $entry.Kind -ne 'DWord' -or $entry.Value -ne $desired
if ($DomainJoined -and $change) {
throw ('Domain RDP policy blocks repair or is invalid: ' + $spec.Name + '. Fix the controlling GPO; this script will not override domain policy.')
}
$plan += [pscustomobject]@{ Before=$entry; Desired=$desired; Change=$change }
}
return $plan
}
function Test-RdpLegacyTask {
param($Task)
$actions = @($Task.Actions)
if ($actions.Count -ne 1) { return $false }
$exe = [Environment]::ExpandEnvironmentVariables([string]$actions[0].Execute).Trim('"')
if ([IO.Path]::GetFileName($exe) -notmatch '^powershell(?:\.exe)?$') { return $false }
$arguments = [Environment]::ExpandEnvironmentVariables([string]$actions[0].Arguments)
$match = [regex]::Match($arguments, '(?i)^\s*(?:-(?:NoProfile|NonInteractive|WindowStyle\s+Hidden|ExecutionPolicy\s+Bypass)\s+)*-File\s+(?:"([^"]+)"|([^\s"]+))\s*$')
if (-not $match.Success) { return $false }
$file = $match.Groups[1].Value
if (-not $file) { $file = $match.Groups[2].Value }
$expected = Join-Path $env:SystemRoot 'Setup\Scripts\DisableNla.ps1'
if ($file -ine $expected -or -not (Test-Path -LiteralPath $expected)) { return $false }
$content = Get-Content -LiteralPath $expected -Raw -ErrorAction Stop
return ($content -match 'fDenyTSConnections\s+0\s+-Type\s+DWord' -and
$content -match 'UserAuthentication\s+0\s+-Type\s+DWord' -and
$content -match 'A509B1A7-37EF-4b3f-8CFC-4F3A74704073' -and
$content -match 'Restart-Service\s+(?:-Name\s+)?TermService\s+-Force')
}
function Export-RdpRegistryKey {
param([string]$NativeKey, [string]$Destination)
$output = & "$env:SystemRoot\System32\reg.exe" export $NativeKey $Destination /y 2>&1
if ($LASTEXITCODE -ne 0) { throw ('Registry backup failed: ' + $NativeKey + '. ' + ($output -join ' ')) }
}
function Backup-RdpRegistry {
param([string]$Directory)
$keys = [ordered]@{
'terminal-server'='HKLM\SYSTEM\CurrentControlSet\Control\Terminal Server'
'rdp-policy'='HKLM\SOFTWARE\Policies\Microsoft\Windows NT\Terminal Services'
'termservice'='HKLM\SYSTEM\CurrentControlSet\Services\TermService'
'umrdpservice'='HKLM\SYSTEM\CurrentControlSet\Services\UmRdpService'
}
foreach ($name in $keys.Keys) {
$providerPath = 'Registry::' + ($keys[$name] -replace '^HKLM\\', 'HKEY_LOCAL_MACHINE\')
if (Test-Path -LiteralPath $providerPath -ErrorAction Stop) {
Export-RdpRegistryKey $keys[$name] (Join-Path $Directory ($name + '.reg'))
} elseif ($name -ne 'rdp-policy') {
throw ('Required registry key missing during backup: ' + $name + '. Configuration not changed.')
}
}
}
function Set-RdpRepairPlan {
param([object[]]$Plan)
foreach ($item in $Plan) {
$entry = $item.Before
Write-RdpLog ('{0}\{1}: {2} [{3}] -> {4} [DWord]; change={5}' -f $entry.Path,$entry.Name,$entry.Value,$entry.Kind,$item.Desired,$item.Change)
if ($item.Change) {
New-ItemProperty -LiteralPath $entry.Path -Name $entry.Name -PropertyType DWord -Value $item.Desired -Force -ErrorAction Stop | Out-Null
if ($entry.Path -like '*\Policies\*') {
Write-RdpLog 'WARNING: a standalone policy value was repaired. Local GPO/MDM may reapply it; inspect gpedit.msc if it returns. No policy refresh or persistent watchdog is installed.'
}
}
$check = Get-RdpValue $entry.Path $entry.Name
if (-not $check.Exists -or $check.Kind -ne 'DWord' -or $check.Value -ne $item.Desired) {
throw ('Registry write/readback failed: ' + $entry.Path + '\' + $entry.Name)
}
}
}
function Restart-RdpAccessServices {
$service = Get-Service -Name TermService -ErrorAction Stop
$dependents = @($service.DependentServices | Where-Object Status -eq 'Running' | Select-Object -ExpandProperty Name)
Set-Service -Name TermService -StartupType Automatic -ErrorAction Stop
if ($service.Status -ne 'Stopped') {
Stop-Service -Name TermService -Force -NoWait -ErrorAction Stop
$service.WaitForStatus([System.ServiceProcess.ServiceControllerStatus]::Stopped, [timespan]::FromSeconds(25))
}
try {
$service.Start()
$service.WaitForStatus([System.ServiceProcess.ServiceControllerStatus]::Running, [timespan]::FromSeconds(25))
} finally {
foreach ($name in $dependents) {
try { (Get-Service -Name $name -ErrorAction Stop).Start() }
catch { Write-RdpLog ('WARNING: dependent service ' + $name + ': ' + $_.Exception.Message) }
}
}
$redirector = Get-CimInstance Win32_Service -Filter "Name='UmRdpService'" -OperationTimeoutSec 15 -ErrorAction Stop
if ($redirector.StartMode -eq 'Disabled') { Set-Service -Name UmRdpService -StartupType Manual -ErrorAction Stop }
$redirectorService = Get-Service -Name UmRdpService -ErrorAction Stop
if ($redirectorService.Status -eq 'Stopped') { $redirectorService.Start() }
}
function Read-RdpPacket {
param([System.IO.Stream]$Stream)
$deadline = [DateTime]::UtcNow.AddSeconds(10)
$header = New-Object byte[] 4
$offset = 0
while ($offset -lt 4) {
if ([DateTime]::UtcNow -gt $deadline) { throw 'RDP response deadline exceeded.' }
$read = $Stream.Read($header, $offset, 4 - $offset)
if ($read -le 0) { throw 'RDP closed the connection before the TPKT header.' }
$offset += $read
}
$length = [int]$header[2] * 256 + [int]$header[3]
if ($header[0] -ne 3 -or $header[1] -ne 0 -or $length -lt 11 -or $length -gt 4096) { throw 'Invalid RDP TPKT response.' }
$packet = New-Object byte[] $length
[Array]::Copy($header, $packet, 4)
$offset = 4
while ($offset -lt $length) {
if ([DateTime]::UtcNow -gt $deadline) { throw 'RDP response deadline exceeded.' }
$read = $Stream.Read($packet, $offset, $length - $offset)
if ($read -le 0) { throw 'Truncated RDP response.' }
$offset += $read
}
if ($packet[5] -ne 0xD0 -or $packet[4] -ne ($length - 5)) { throw 'Response is not an RDP X.224 connection confirmation.' }
if ($length -eq 11) { return 'Standard RDP (legacy security; preserved, not enabled by this repair)' }
if ($length -ne 19 -or $packet[13] -ne 8 -or $packet[14] -ne 0) { throw 'Unexpected RDP negotiation structure.' }
$code = [BitConverter]::ToUInt32($packet, 15)
if ($packet[11] -eq 3) { throw ('RDP negotiation rejected; failure code=' + $code + '. No TLS/NLA downgrade attempted.') }
if ($packet[11] -ne 2 -or $code -notin @(0,1,2,8)) { throw 'Unexpected RDP negotiation protocol.' }
return ('RDP negotiation accepted; selectedProtocol=' + $code)
}
function Test-RdpLocalProtocol {
param([int]$Port)
$service = Get-CimInstance Win32_Service -Filter "Name='TermService'" -OperationTimeoutSec 15 -ErrorAction Stop
$listeners = @(Get-NetTCPConnection -State Listen -ErrorAction Stop | Where-Object LocalPort -eq $Port)
$owned = @($listeners | Where-Object { $_.OwningProcess -eq $service.ProcessId -and $service.ProcessId -gt 0 })
if ($service.State -ne 'Running' -or -not $owned.Count) { throw ('TermService is not listening on TCP ' + $Port) }
# Test the actual bound address; do not assume IPv4 or all-interface binding.
$address = [string]$owned[0].LocalAddress
if ($address -eq '0.0.0.0') { $address = '127.0.0.1' }
if ($address -eq '::') { $address = '::1' }
$ip = [System.Net.IPAddress]::Parse($address)
$client = New-Object System.Net.Sockets.TcpClient($ip.AddressFamily)
$pending = $null
try {
$pending = $client.BeginConnect($ip, $Port, $null, $null)
if (-not $pending.AsyncWaitHandle.WaitOne(3000)) { throw 'Local TCP connection timed out.' }
$client.EndConnect($pending)
$stream = $client.GetStream()
$stream.ReadTimeout = 3000
$stream.WriteTimeout = 3000
# X.224 CR + RDP_NEG_REQ: TLS, CredSSP and CredSSP extended. No credentials.
[byte[]]$request = @(3,0,0,19,14,224,0,0,0,0,0,1,0,8,0,11,0,0,0)
$stream.Write($request, 0, $request.Length)
return (Read-RdpPacket $stream)
} finally {
$client.Close()
if ($pending) { $pending.AsyncWaitHandle.Close() }
}
}
function Save-RdpFailureDetails {
param([string]$Directory)
foreach ($logName in @('Microsoft-Windows-TerminalServices-RemoteConnectionManager/Operational','Microsoft-Windows-RemoteDesktopServices-RdpCoreTS/Operational','System')) {
try {
$events = Get-WinEvent -FilterHashtable @{LogName=$logName; StartTime=(Get-Date).AddHours(-2); Level=1,2,3} -MaxEvents 20 -ErrorAction Stop
$events | Select-Object TimeCreated,Id,ProviderName,Message | Format-List |
Out-File -LiteralPath (Join-Path $Directory (($logName -replace '[/\\]','-') + '.txt')) -Encoding UTF8 -Width 240
} catch { Write-RdpLog ('Event log note: ' + $_.Exception.Message) }
}
}
function Invoke-RdpAccessRecovery {
param([int]$PortOverride = 0)
$ErrorActionPreference = 'Stop'
$script:RdpRecoveryLog = $null
$directory = $null
try {
$principal = New-Object Security.Principal.WindowsPrincipal([Security.Principal.WindowsIdentity]::GetCurrent())
if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { throw 'Open Windows PowerShell AS ADMINISTRATOR on the affected VPS and paste again.' }
if ([Environment]::Is64BitOperatingSystem -and -not [Environment]::Is64BitProcess) { throw 'Use 64-bit Windows PowerShell, not Windows PowerShell (x86).' }
$edition = (Get-ItemProperty -LiteralPath 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion').EditionID
if ($edition -match '^(Core|Starter)') { throw 'This Windows edition does not support an incoming RDP host. No unsupported RDP wrapper will be installed.' }
$directory = Join-Path $env:ProgramData ('WinInstallBuilder\RdpRecovery\' + (Get-Date -Format 'yyyyMMdd-HHmmss') + '-' + [guid]::NewGuid().ToString('N'))
New-Item -Path $directory -ItemType Directory -ErrorAction Stop | Out-Null
$script:RdpRecoveryLog = Join-Path $directory 'repair.log'
Write-RdpLog ('RDP recovery 2026.9.19. Report/backup: ' + $directory)
Write-RdpLog 'RDP services will restart. Valid port/NLA/TLS values are preserved. No automatic reboot.'
$computer = Get-CimInstance Win32_ComputerSystem -OperationTimeoutSec 15 -ErrorAction Stop
if ($null -eq $computer.PartOfDomain) { throw 'Cannot determine domain membership; not safe to edit policy values.' }
$plan = @(Get-RdpRepairPlan -PortOverride $PortOverride -DomainJoined ([bool]$computer.PartOfDomain))
$plan | Export-Clixml -LiteralPath (Join-Path $directory 'registry-plan.xml')
$portPlan = @($plan | Where-Object { $_.Before.Name -eq 'PortNumber' })[0]
$port = [int]$portPlan.Desired
if ((Get-RdpNumber $portPlan.Before 1 65535 0) -eq 0 -and $PortOverride -eq 0) { Write-RdpLog 'WARNING: original port is missing/invalid. Using 3389; provider firewall/NAT must allow this port.' }
$services = @(Get-CimInstance Win32_Service -Filter "Name='TermService' OR Name='UmRdpService'" -OperationTimeoutSec 15 -ErrorAction Stop)
$services | Select-Object Name,State,StartMode,ProcessId | Export-Clixml -LiteralPath (Join-Path $directory 'services-before.xml')
$term = @($services | Where-Object Name -eq 'TermService')
if ($term.Count -ne 1) { throw 'TermService is missing. Registry defaults cannot restore missing Windows components.' }
$conflicts = @(Get-NetTCPConnection -State Listen -ErrorAction Stop | Where-Object { $_.LocalPort -eq $port -and $_.OwningProcess -ne $term[0].ProcessId })
if ($conflicts.Count) { throw ('TCP port ' + $port + ' is occupied by another process. Nothing was killed. PID(s): ' + (($conflicts.OwningProcess | Sort-Object -Unique) -join ', ')) }
$tasks = @(Get-ScheduledTask -TaskPath '\' -ErrorAction Stop | Where-Object TaskName -eq 'WinRdpNla')
foreach ($task in $tasks) {
if (-not (Test-RdpLegacyTask $task)) { throw 'WinRdpNla does not match the known installer task. Inspect it manually; no configuration changes made.' }
Export-ScheduledTask -TaskPath '\' -TaskName 'WinRdpNla' | Set-Content -LiteralPath (Join-Path $directory 'WinRdpNla.xml') -Encoding Unicode
}
$ruleName = 'WinInstallBuilder-RdpRecovery-TCP-' + $port
$description = 'WinInstallBuilder RDP recovery: scoped TCP rule for TermService.'
$rule = @(Get-NetFirewallRule -PolicyStore PersistentStore -ErrorAction Stop | Where-Object Name -eq $ruleName)
if ($rule.Count -and ($rule.Count -ne 1 -or $rule[0].Description -ne $description)) { throw 'Firewall rule name is already used by an unrecognized rule; no configuration changes made.' }
if ($rule.Count) {
$rule | Export-Clixml -LiteralPath (Join-Path $directory 'firewall-rule-before.xml')
$rule | Get-NetFirewallAddressFilter | Export-Clixml -LiteralPath (Join-Path $directory 'firewall-address-before.xml')
$rule | Get-NetFirewallPortFilter | Export-Clixml -LiteralPath (Join-Path $directory 'firewall-port-before.xml')
}
Backup-RdpRegistry $directory
# Every configuration mutation is below the successful backup barrier.
foreach ($task in $tasks) {
Disable-ScheduledTask -TaskPath '\' -TaskName 'WinRdpNla' | Out-Null
if ((Get-ScheduledTask -TaskPath '\' -TaskName 'WinRdpNla').State -eq 'Running') {
Stop-ScheduledTask -TaskPath '\' -TaskName 'WinRdpNla'
for ($attempt=0; $attempt -lt 20; $attempt++) {
if ((Get-ScheduledTask -TaskPath '\' -TaskName 'WinRdpNla').State -ne 'Running') { break }
Start-Sleep -Milliseconds 250
}
}
$checkTask = Get-ScheduledTask -TaskPath '\' -TaskName 'WinRdpNla'
if ($checkTask.Settings.Enabled -or $checkTask.State -eq 'Running') { throw 'The legacy task did not stop/disable. Registry repair aborted.' }
}
Write-RdpLog 'Legacy task checked. Continuing with actual RDP repair even if the task was absent/already disabled.'
Set-RdpRepairPlan $plan
$firewallArgs = @{ Enabled='True'; Direction='Inbound'; Action='Allow'; Profile='Any'; Protocol='TCP'; LocalPort=$port; Service='TermService'; Program="$env:SystemRoot\System32\svchost.exe"; EdgeTraversalPolicy='Block'; ErrorAction='Stop' }
if ($rule.Count) {
Set-NetFirewallRule -PolicyStore PersistentStore -Name $ruleName @firewallArgs | Out-Null
Write-RdpLog 'Existing recovery firewall rule enabled; its source-address restrictions are preserved.'
} else {
('Remove-NetFirewallRule -Name "' + $ruleName + '"') | Set-Content -LiteralPath (Join-Path $directory 'undo-new-firewall-rule.txt') -Encoding UTF8
New-NetFirewallRule -PolicyStore PersistentStore -Name $ruleName -DisplayName ('RDP recovery TCP ' + $port) -Description $description -RemoteAddress Any @firewallArgs | Out-Null
Write-RdpLog ('Added inbound TCP rule for TermService on port ' + $port + ', all source addresses. Other firewall rules/profiles unchanged; explicit blocks still take precedence.')
}
Write-RdpLog 'Restarting TermService and checking UmRdpService (active RDP sessions will disconnect)...'
Restart-RdpAccessServices
$probe = $null
for ($attempt=1; $attempt -le 3; $attempt++) {
try { $probe = Test-RdpLocalProtocol $port; break }
catch {
Write-RdpLog ('Local RDP attempt ' + $attempt + '/3: ' + $_.Exception.Message)
if ($attempt -eq 3) { throw }
Start-Sleep -Seconds 2
}
}
$after = @()
foreach ($item in $plan) {
$check = Get-RdpValue $item.Before.Path $item.Before.Name
$after += $check
if ($check.Kind -ne 'DWord' -or $check.Value -ne $item.Desired) { throw ('A setting reverted during repair: ' + $check.Path + '\' + $check.Name + '. Check GPO/MDM/provider agents.') }
}
$after | Export-Clixml -LiteralPath (Join-Path $directory 'registry-after.xml')
Write-RdpLog ('LOCAL_RDP_RESPONDING: ' + $probe + '. Connect to <SERVER_IP>:' + $port)
Write-RdpLog 'This verifies only the local RDP listener/negotiation, NOT external firewall/NAT reachability, TLS certificate trust or user sign-in. Try connecting from your own PC now.'
[pscustomobject]@{Status='LOCAL_RDP_RESPONDING';Port=$port;Report=$directory}
} catch {
$message = $_.Exception.Message
Write-RdpLog ('REPAIR_INCOMPLETE: ' + $message)
if ($directory) {
Write-RdpLog 'Changes may already be applied. They are not blindly rolled back; keep the console open. Send repair.log for analysis.'
Save-RdpFailureDetails $directory
}
[pscustomobject]@{Status='REPAIR_INCOMPLETE';Error=$message;Report=$directory}
}
}
Invoke-RdpAccessRecovery -PortOverride $RdpPort
}