Загрузка данных


# 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
}