From 14348a01810599379c15780aa7c0d9007771e548 Mon Sep 17 00:00:00 2001 From: PTah Date: Fri, 12 Jun 2026 19:05:29 +1000 Subject: [PATCH] =?UTF-8?q?feat:=20=D0=BB=D0=BE=D0=BA=D0=B0=D0=BB=D1=8C?= =?UTF-8?q?=D0=BD=D1=8B=D0=B5=20=D1=81=D0=BA=D1=80=D0=B8=D0=BF=D1=82=D1=8B?= =?UTF-8?q?=20=D0=BF=D0=BE=D1=87=D0=B8=D0=BD=D0=BA=D0=B8=20RDP/TLS=20?= =?UTF-8?q?=D0=B4=D0=BB=D1=8F=20Windows=20(fix-DAK-PC)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix-RdpTls.ps1 с фазами диагностики, cert, ACL, привязкой; самотесты; docs repair install и возврат TLS. Co-authored-by: Cursor --- .gitignore | 3 + README.md | 86 +++++++ docs/LOCAL-RUNBOOK.md | 112 +++++++++ docs/REPAIR-INSTALL.md | 80 +++++++ docs/RETURN-TO-TLS.md | 71 ++++++ docs/TROUBLESHOOTING.md | 54 +++++ scripts/Fix-RdpTls.ps1 | 175 ++++++++++++++ scripts/Invoke-RdpRepairSelfTest.ps1 | 138 +++++++++++ scripts/lib/RdpRepair.Common.ps1 | 336 +++++++++++++++++++++++++++ 9 files changed, 1055 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 docs/LOCAL-RUNBOOK.md create mode 100644 docs/REPAIR-INSTALL.md create mode 100644 docs/RETURN-TO-TLS.md create mode 100644 docs/TROUBLESHOOTING.md create mode 100644 scripts/Fix-RdpTls.ps1 create mode 100644 scripts/Invoke-RdpRepairSelfTest.ps1 create mode 100644 scripts/lib/RdpRepair.Common.ps1 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b4cbf28 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +*.log +report-*.txt +repair-*.log diff --git a/README.md b/README.md new file mode 100644 index 0000000..43774cf --- /dev/null +++ b/README.md @@ -0,0 +1,86 @@ +# fix-DAK-PC — локальная починка RDP/TLS на Windows 10/11 + +Набор скриптов для **локального** (с консоли проблемного ПК) восстановления RDP, когда клиент показывает «внутренняя ошибка», в журнале **1057** / **Schannel 36870**, TermService сбрасывает `SSLCertificateSHA1Hash`. + +Сценарий проверен на **DAK-PC** (Win10 Pro 19045): корень — сломанная привязка TLS-сертификата RDP; обход `SecurityLayer=0`; полное лечение — repair install + возврат TLS. + +## Быстрый старт (за компом DAK-PC) + +1. Скопируйте репозиторий или папку `scripts` на ПК (флешка, `git clone`, общая шара). +2. **PowerShell от имени администратора**. +3. Разрешите выполнение (один раз в этой сессии): + +```powershell +Set-ExecutionPolicy -Scope Process -ExecutionPolicy Bypass -Force +cd C:\path\to\fix-DAK-PC\scripts +``` + +4. **Самотесты** (обязательно): + +```powershell +.\Invoke-RdpRepairSelfTest.ps1 +``` + +5. **Полный интерактивный ремонт**: + +```powershell +.\Fix-RdpTls.ps1 +``` + +6. Если TLS не удаётся починить — **экстренный доступ** (без TLS): + +```powershell +.\Fix-RdpTls.ps1 -EmergencyRdpOnly +``` + +7. После **repair install** Windows и успешной привязки cert — **возврат TLS**: + +```powershell +.\Fix-RdpTls.ps1 -RestoreTls +``` + +## Структура + +| Файл | Назначение | +|------|------------| +| `scripts/Fix-RdpTls.ps1` | Главный сценарий: диагностика → очистка → cert → привязка → проверка | +| `scripts/Invoke-RdpRepairSelfTest.ps1` | Самотесты функций и синтаксиса | +| `scripts/lib/RdpRepair.Common.ps1` | Общие функции | +| `docs/REPAIR-INSTALL.md` | Repair install Windows (на месте) | +| `docs/RETURN-TO-TLS.md` | Возврат SecurityLayer=2 после починки cert | +| `docs/TROUBLESHOOTING.md` | События, типичные ошибки, GPO | +| `docs/LOCAL-RUNBOOK.md` | Пошаговый сценарий за консолью DAK-PC | + +## Фазы `Fix-RdpTls.ps1` + +| Фаза | Действие | +|------|----------| +| 0 | Самопроверка: админ, ОС, службы, модули | +| 1 | Диагностика → отчёт `%ProgramData%\RdpRepair\report-*.txt` | +| 2 | Очистка просроченных/битых cert в `Remote Desktop` и `My` | +| 3 | Нативный сброс (выкл/вкл RDP, очистка store) + опциональный reboot | +| 4 | Создание cert (цепочка провайдеров: Enhanced RSA → default) | +| 5 | ACL на приватный ключ (`NETWORK SERVICE`, `SYSTEM`) | +| 6 | Привязка (`reg` + WMI), проверка **Hash BEFORE/AFTER** | +| 7 | `-EmergencyRdpOnly`: SecurityLayer=0 | +| 8 | `-RestoreTls`: SecurityLayer=2 при успешной привязке | + +## Критерий успеха TLS + +После перезапуска `TermService`: + +- `SSLCertificateSHA1Hash` в реестре **совпадает** с thumbprint cert в store. +- События **1057** / **36870** не появляются при тестовом подключении RDP. +- `SecurityLayer` = **2** (как на рабочих ПК в домене). + +Если **Hash AFTER** пустой при любом cert — см. [docs/REPAIR-INSTALL.md](docs/REPAIR-INSTALL.md). + +## Клонирование + +```bash +git clone https://git.kalinamall.ru/PapaTramp/fix-DAK-PC.git +``` + +## Лицензия + +Внутреннее использование (PapaTramp / kalinamall). diff --git a/docs/LOCAL-RUNBOOK.md b/docs/LOCAL-RUNBOOK.md new file mode 100644 index 0000000..7ea28d7 --- /dev/null +++ b/docs/LOCAL-RUNBOOK.md @@ -0,0 +1,112 @@ +# Пошаговый runbook — за компом DAK-PC (локально) + +Выполняйте **на проблемном ПК** в PowerShell **от имени администратора**. + +## 0. Подготовка + +```powershell +# Скопируйте репозиторий, например: +# git clone https://git.kalinamall.ru/PapaTramp/fix-DAK-PC.git C:\Tools\fix-DAK-PC + +Set-ExecutionPolicy -Scope Process -ExecutionPolicy Bypass -Force +cd C:\Tools\fix-DAK-PC\scripts +``` + +## 1. Самотесты (обязательно) + +```powershell +.\Invoke-RdpRepairSelfTest.ps1 -IncludeAdminTests +``` + +Ожидание: `FAIL=0`. При ошибках — не продолжайте. + +## 2. Диагностика + +```powershell +.\Fix-RdpTls.ps1 -DiagnoseOnly +``` + +Отчёт: `%ProgramData%\RdpRepair\report-*.txt` +Лог: `%ProgramData%\RdpRepair\repair-*.log` + +Сохраните отчёт на флешку / в тикет. + +## 3. Полный автоматический ремонт TLS + +```powershell +.\Fix-RdpTls.ps1 +``` + +Скрипт спросит подтверждение на каждой фазе: + +1. Очистка cert + нативный сброс +2. Опционально **reboot** (если Windows должен сам создать cert) +3. Создание локального cert + ACL + привязка +4. Проверка **Hash AFTER** +5. Предложение `SecurityLayer=2` + +### Если после reboot (шаг 3.2) + +Снова с консоли: + +```powershell +cd C:\Tools\fix-DAK-PC\scripts +.\Invoke-RdpRepairSelfTest.ps1 -IncludeAdminTests +.\Fix-RdpTls.ps1 +``` + +## 4. Если Hash AFTER пустой (TermService отклоняет cert) + +### 4a. Срочный RDP (без TLS) + +```powershell +.\Fix-RdpTls.ps1 -EmergencyRdpOnly +``` + +Проверьте RDP с другого ПК. На клиенте: + +```powershell +Remove-Item "HKCU:\Software\Microsoft\Terminal Server Client\Servers\DAK-PC" -Recurse -Force -ErrorAction SilentlyContinue +``` + +### 4b. Repair install Windows + +См. [REPAIR-INSTALL.md](REPAIR-INSTALL.md) — `setup.exe` → обновление с сохранением файлов. + +После repair: + +```powershell +.\Fix-RdpTls.ps1 +``` + +## 5. Возврат нормального TLS + +**Только** когда `Hash AFTER` совпадает с thumbprint и RDP с TLS работает: + +```powershell +.\Fix-RdpTls.ps1 -RestoreTls +``` + +Подробно: [RETURN-TO-TLS.md](RETURN-TO-TLS.md). + +## 6. DISM / SFC (до или после repair) + +```powershell +DISM /Online /Cleanup-Image /RestoreHealth +sfc /scannow +``` + +Перезагрузка → снова `.\Fix-RdpTls.ps1`. + +## Критерии «готово» + +- [ ] `Cert store` — cert с `NotAfter` в будущем +- [ ] `SSLCertificateSHA1Hash` после старта TermService **не пустой** +- [ ] `SecurityLayer` = **2** +- [ ] События 1057 / 36870 не появляются при подключении RDP +- [ ] RDP с клиента без `-EmergencyRdpOnly` + +## Текущее состояние DAK-PC (на момент создания репо) + +- Работал обход: `SecurityLayer=0` +- Перед `RestoreTls` обязательно починить привязку cert или сделать repair install diff --git a/docs/REPAIR-INSTALL.md b/docs/REPAIR-INSTALL.md new file mode 100644 index 0000000..7c9f3a8 --- /dev/null +++ b/docs/REPAIR-INSTALL.md @@ -0,0 +1,80 @@ +# Repair install Windows — когда скрипты TLS не помогают + +## Когда нужен repair install + +Выполняйте, если **все** условия выполнены: + +1. `Fix-RdpTls.ps1` прошёл фазы 2–6, но **Hash AFTER** пустой после старта `TermService`. +2. В журнале **System** событие **1057**: «не удалось **создать** самозаверяющий сертификат» / «неправильный алгоритм». +3. `DISM /RestoreHealth` и `sfc /scannow` завершились без нарушений, но проблема осталась. +4. Импорт cert с другого ПК (PFX) тоже сбрасывается службой. +5. **Emergency** `SecurityLayer=0` даёт RDP, **SecurityLayer=2** — нет. + +Это указывает на повреждение компонентов **TermService / Schannel / cert enrollment**, а не на один просроченный файл. + +## Подготовка + +1. Сохраните отчёты: `%ProgramData%\RdpRepair\report-*.txt`. +2. Убедитесь в свободном месте на диске C: (**≥ 10 GB**). +3. Имейте установочный образ **той же редакции** Win10 (19045) или Media Creation Tool. +4. Доступ к ПК: физически или через RDP с `SecurityLayer=0` / WinRM. + +## Порядок (на месте, сохранение файлов) + +### 1. Скачать образ + +- [Media Creation Tool](https://www.microsoft.com/software-download/windows10) на флешку **или** +- ISO той же разрядности (x64), что и установленная система. + +### 2. Запуск repair + +1. Смонтировать ISO / вставить флешку. +2. Запустить `setup.exe` **от администратора**. +3. Выбрать **«Обновление»** (Upgrade) — **не** «Выборочная установка». +4. Сохранить личные файлы и приложения. +5. Дождаться завершения (перезагрузки). + +### 3. После repair + +В PowerShell **от администратора**: + +```powershell +cd C:\path\to\fix-DAK-PC\scripts +.\Invoke-RdpRepairSelfTest.ps1 +.\Fix-RdpTls.ps1 -SkipEmergency +``` + +Проверьте: + +```powershell +.\Fix-RdpTls.ps1 -DiagnoseOnly +``` + +Ожидание: + +- В store `Remote Desktop` — cert с `NotAfter` в будущем (часто нативный). +- После фазы 6: **Hash AFTER** = thumbprint. +- RDP с другого ПК **без** `SecurityLayer=0`. + +### 4. Возврат TLS + +```powershell +.\Fix-RdpTls.ps1 -RestoreTls +``` + +Подробнее: [RETURN-TO-TLS.md](RETURN-TO-TLS.md). + +## DISM / SFC до repair (если ещё не делали) + +```powershell +DISM /Online /Cleanup-Image /RestoreHealth +sfc /scannow +``` + +Перезагрузка, затем снова `Fix-RdpTls.ps1`. + +## Если repair install не помог + +- Чистая переустановка Windows с бэкапом данных. +- Проверка железа (диск, RAM). +- Эскалация в Microsoft Support с логами `%ProgramData%\RdpRepair`. diff --git a/docs/RETURN-TO-TLS.md b/docs/RETURN-TO-TLS.md new file mode 100644 index 0000000..49d6590 --- /dev/null +++ b/docs/RETURN-TO-TLS.md @@ -0,0 +1,71 @@ +# Возврат к нормальному TLS (SecurityLayer=2) + +## Контекст + +На DAK-PC временно работал RDP с **SecurityLayer=0** (шифрование RDP без TLS). На рабочих ПК домена (FVG-PC, NEW-ADMIN-PC) используется **SecurityLayer=2** (SSL/TLS) при пустом `SSLCertificateSHA1Hash` и нативном cert в store `Remote Desktop`. + +**Не включайте SecurityLayer=2**, пока не выполнены критерии ниже. + +## Критерии готовности + +| Проверка | Ожидание | +|----------|----------| +| Cert в `LocalMachine\Remote Desktop` | есть, `NotAfter` > сегодня | +| `SSLCertificateSHA1Hash` после старта TermService | **не пустой**, совпадает с thumbprint | +| События 1057 за 10 мин после теста RDP | **0** | +| Schannel 36870 | **0** | + +Проверка одной командой (локально на DAK-PC): + +```powershell +cd C:\path\to\fix-DAK-PC\scripts +.\Fix-RdpTls.ps1 -DiagnoseOnly +``` + +## Возврат TLS + +```powershell +.\Fix-RdpTls.ps1 -RestoreTls +``` + +Скрипт: + +1. Убедится, что cert и hash на месте (или попытается привязать). +2. Установит `SecurityLayer = 2`. +3. Перезапустит `TermService`. +4. Проверит hash **после** старта. +5. Выведет инструкцию сброса кэша RDP на клиенте. + +## На клиенте RDP (ваш ПК) + +```powershell +Remove-Item "HKCU:\Software\Microsoft\Terminal Server Client\Servers\DAK-PC" -Recurse -Force -ErrorAction SilentlyContinue +# или имя FQDN, если подключаетесь по FQDN +Remove-Item "HKCU:\Software\Microsoft\Terminal Server Client\Servers\DAK-PC.b26.kalinamall.ru" -Recurse -Force -ErrorAction SilentlyContinue +``` + +Подключитесь снова по RDP. + +## Если после RestoreTls RDP снова «внутренняя ошибка» + +1. Срочный доступ: + +```powershell +.\Fix-RdpTls.ps1 -EmergencyRdpOnly +``` + +2. Повторите **repair install** ([REPAIR-INSTALL.md](REPAIR-INSTALL.md)). +3. Не оставляйте SecurityLayer=2 без рабочего cert. + +## GPO + +`LicenseServers` / `LicensingMode` в домене **не были** причиной на DAK-PC (одинаковы на всех ПК). +Если админ меняет политики — после `gpupdate /force` снова запустите `-DiagnoseOnly`. + +## Значения SecurityLayer (справка) + +| Значение | Режим | +|----------|--------| +| 0 | Только RDP Security Layer (legacy) | +| 1 | Negotiate | +| 2 | SSL (TLS) — **норма для вашей сети** | diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md new file mode 100644 index 0000000..1ff39d0 --- /dev/null +++ b/docs/TROUBLESHOOTING.md @@ -0,0 +1,54 @@ +# Troubleshooting — RDP / TLS / сертификаты + +## События + +| ID | Источник | Значение | +|----|----------|----------| +| **1057** | RemoteConnectionManager / System | Ошибка SSL/TLS RDP; часто «неправильный алгоритм» или «не удалось создать самозаверяющий сертификат» | +| **36870** | Schannel | `SEC_E_ALGORITHM_MISMATCH` (0x8009030d) — нет общего алгоритма с клиентом или нет валидного cert | +| **258** | Operational | Прослушиватель RDP-Tcp запущен (норма) | +| **261** | Operational | Входящее соединение на listener (норма) | + +## Hash BEFORE есть, Hash AFTER пустой + +TermService **отверг** сертификат при старте. Возможные причины: + +- Просроченный cert (лечится заменой). +- Нет доступа к приватному ключу → фаза ACL в `Fix-RdpTls.ps1`. +- CNG vs CSP — скрипт пробует `RSA\MachineKeys`. +- Повреждение Schannel → repair install. + +## Нативный cert не создаётся после reboot + +Store `Remote Desktop` пустой после цикла «выкл RDP → очистка → вкл RDP → reboot»: + +- Запустите `Fix-RdpTls.ps1` фазы 4–6 локально. +- Если не помогло — repair install. + +## GPO Terminal Services + +Путь: `HKLM\SOFTWARE\Policies\Microsoft\Windows NT\Terminal Services` + +На DAK-PC и рабочих ПК может быть: + +- `LicenseServers` = 192.168.160.150 +- `LicensingMode` = 4 + +RDP при этом работал на других ПК — **не единственная причина** отказа DAK-PC. Убирать из GPO для Win10 Pro всё равно желательно (задача админа AD). + +Локальное удаление **откатывается** `gpupdate /force`. + +## Клиент «внутренняя ошибка» + +1. Сброс кэша cert на клиенте (см. RETURN-TO-TLS.md). +2. Проверка 1057/36870 на **сервере** в момент подключения. +3. Сравнение `SecurityLayer` с рабочим ПК. + +## WinRM vs RDP + +WinRM может работать при сломанном RDP TLS — это нормально. Починка RDP не требует локальной консоли, но **локальный** запуск скриптов надёжнее для cert/ACL. + +## Логи скриптов + +- `%ProgramData%\RdpRepair\report-*.txt` — диагностика +- `%ProgramData%\RdpRepair\repair-*.log` — ход ремонта diff --git a/scripts/Fix-RdpTls.ps1 b/scripts/Fix-RdpTls.ps1 new file mode 100644 index 0000000..83b21b4 --- /dev/null +++ b/scripts/Fix-RdpTls.ps1 @@ -0,0 +1,175 @@ +#Requires -Version 5.1 +#Requires -RunAsAdministrator +<# +.SYNOPSIS + Локальная починка RDP TLS / сертификата TermService. + +.PARAMETER DiagnoseOnly + Только диагностика и отчёт. + +.PARAMETER EmergencyRdpOnly + SecurityLayer=0 (без TLS) для срочного доступа. + +.PARAMETER RestoreTls + Вернуть SecurityLayer=2 после успешной привязки cert. + +.PARAMETER SkipReboot + Не предлагать reboot при нативном сбросе. + +.PARAMETER NonInteractive + Без вопросов (для автоматизации). $env:RDP_REPAIR_NONINTERACTIVE=1 +#> +[CmdletBinding()] +param( + [switch]$DiagnoseOnly, + [switch]$EmergencyRdpOnly, + [switch]$RestoreTls, + [switch]$SkipReboot, + [switch]$NonInteractive +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +if ($NonInteractive) { $env:RDP_REPAIR_NONINTERACTIVE = '1' } + +$libDir = Join-Path $PSScriptRoot 'lib' +. (Join-Path $libDir 'RdpRepair.Common.ps1') + +$logPath = Join-Path (Initialize-RdpRepairDataRoot) ("repair-{0:yyyyMMdd-HHmmss}.log" -f (Get-Date)) + +function Show-RdpRepairClientCacheHint { + Write-RdpRepairLog 'На клиенте RDP выполните:' 'INFO' $logPath + Write-RdpRepairLog ' Remove-Item "HKCU:\Software\Microsoft\Terminal Server Client\Servers\<имя-сервера>" -Recurse -Force' 'INFO' $logPath +} + +try { + Assert-RdpRepairAdministrator + Write-RdpRepairLog "=== Fix-RdpTls start $(Get-Date -Format o) ===" 'STEP' $logPath + Write-RdpRepairLog "Компьютер: $env:COMPUTERNAME" 'INFO' $logPath + + $report = Export-RdpRepairDiagnosticReport -LogPath $logPath + Write-RdpRepairLog "Диагностика сохранена: $report" 'OK' $logPath + + if ($DiagnoseOnly) { + Get-RdpRepairDiagnosticSnapshot | Format-List + exit 0 + } + + if ($EmergencyRdpOnly) { + Write-RdpRepairLog 'ЭКСТРЕННЫЙ РЕЖИМ: SecurityLayer=0 (RDP без TLS)' 'WARN' $logPath + Set-RdpRepairSecurityLayer -Layer 0 -LogPath $logPath + Show-RdpRepairClientCacheHint + Write-RdpRepairLog 'Проверьте RDP. Для TLS после repair install: .\Fix-RdpTls.ps1 -RestoreTls' 'OK' $logPath + exit 0 + } + + if ($RestoreTls) { + Write-RdpRepairLog 'Возврат SecurityLayer=2 (TLS)' 'STEP' $logPath + $certs = Get-RdpRepairRemoteDesktopCertificates + if ($certs.Count -eq 0) { + Write-RdpRepairLog 'No cert in Remote Desktop store - run full repair or repair install first' 'ERROR' $logPath + exit 2 + } + $valid = $certs | Where-Object { $_.NotAfter -gt (Get-Date) } | Select-Object -First 1 + if (-not $valid) { + Write-RdpRepairLog 'Все cert просрочены' 'ERROR' $logPath + exit 2 + } + $thumb = $valid.Thumbprint + if (-not (Get-RdpRepairSslCertificateHash)) { + Write-RdpRepairLog "Привязка $($valid.Thumbprint)..." 'INFO' $logPath + Set-RdpRepairCertificateBinding -Thumbprint $thumb -LogPath $logPath | Out-Null + } + $bind = Test-RdpRepairCertificateBindingPersisted -ExpectedThumbprint $thumb -LogPath $logPath + if (-not $bind.Persisted) { + Write-RdpRepairLog 'TermService does not keep hash - cannot restore TLS. See docs\REPAIR-INSTALL.md' 'ERROR' $logPath + exit 3 + } + Set-ItemProperty -Path $script:RdpRepairRdpTcpKey -Name SecurityLayer -Value 2 + Restart-RdpRepairTermService + Write-RdpRepairLog 'SecurityLayer=2, TermService перезапущен' 'OK' $logPath + Show-RdpRepairClientCacheHint + exit 0 + } + + # --- Полный интерактивный ремонт --- + Write-RdpRepairLog 'ФАЗА 1: текущее состояние' 'STEP' $logPath + $snap = Get-RdpRepairDiagnosticSnapshot + $snap | Format-List ComputerName, OsCaption, SecurityLayer, SslHash, CertCount, Events1057_24h, GpoLicenseSrv + + $expired = @($snap.Certificates | Where-Object { $_.NotAfter -lt (Get-Date) }) + if ($expired.Count -gt 0) { + Write-RdpRepairLog "Найдено просроченных cert: $($expired.Count)" 'WARN' $logPath + } + + if (-not (Confirm-RdpRepairContinue 'Продолжить: очистка cert и нативный сброс?')) { + exit 0 + } + + Write-RdpRepairLog 'ФАЗА 2: очистка stores + нативный сброс' 'STEP' $logPath + $native = Invoke-RdpRepairNativeReset -LogPath $logPath + if ($native.CertCount -gt 0) { + Write-RdpRepairLog "После сброса без reboot: нативный cert найден ($($native.CertCount))" 'OK' $logPath + $nativeCert = $native.Certificates | Select-Object -First 1 + $bindNative = Test-RdpRepairCertificateBindingPersisted -ExpectedThumbprint $nativeCert.Thumbprint -LogPath $logPath + if ($bindNative.Persisted) { + Write-RdpRepairLog 'Native cert accepted - you can use RestoreTls' 'OK' $logPath + if (Confirm-RdpRepairContinue 'Установить SecurityLayer=2 сейчас?') { + Set-ItemProperty -Path $script:RdpRepairRdpTcpKey -Name SecurityLayer -Value 2 + Restart-RdpRepairTermService + } + Show-RdpRepairClientCacheHint + exit 0 + } + } + + if (-not $SkipReboot -and (Confirm-RdpRepairContinue 'Нативный cert не прижился. Запланировать REBOOT через 60 с?')) { + Invoke-RdpRepairNativeReset -LogPath $logPath -Reboot | Out-Null + Write-RdpRepairLog 'После загрузки снова запустите: .\Fix-RdpTls.ps1' 'WARN' $logPath + exit 0 + } + + if (-not (Confirm-RdpRepairContinue 'ФАЗА 3: создать локальный cert и привязать?')) { + exit 0 + } + + Write-RdpRepairLog 'ФАЗА 3: создание cert' 'STEP' $logPath + Clear-RdpRepairCertificateStores -LogPath $logPath + $dns = @(Get-RdpRepairComputerDnsNames) + Write-RdpRepairLog "DNS: $($dns -join ', ')" 'INFO' $logPath + $cert = New-RdpRepairSelfSignedCertificate -DnsNames $dns -LogPath $logPath + + Write-RdpRepairLog 'ФАЗА 4: ACL + Remote Desktop store' 'STEP' $logPath + Set-RdpRepairPrivateKeyAcl -Certificate $cert -LogPath $logPath | Out-Null + Add-RdpRepairCertificateToRemoteDesktopStore -Certificate $cert + + Write-RdpRepairLog 'ФАЗА 5: привязка и проверка AFTER' 'STEP' $logPath + Set-RdpRepairCertificateBinding -Thumbprint $cert.Thumbprint -LogPath $logPath | Out-Null + $bind = Test-RdpRepairCertificateBindingPersisted -ExpectedThumbprint $cert.Thumbprint -LogPath $logPath + + if ($bind.Persisted) { + if (Confirm-RdpRepairContinue 'Привязка OK. Установить SecurityLayer=2?') { + Set-ItemProperty -Path $script:RdpRepairRdpTcpKey -Name SecurityLayer -Value 2 + Restart-RdpRepairTermService + } + Show-RdpRepairClientCacheHint + Write-RdpRepairLog '=== Ремонт TLS завершён успешно ===' 'OK' $logPath + exit 0 + } + + Write-RdpRepairLog '=== TermService отклоняет cert ===' 'ERROR' $logPath + Write-RdpRepairLog 'Варианты:' 'WARN' $logPath + Write-RdpRepairLog ' 1) docs\REPAIR-INSTALL.md - repair install Windows' 'WARN' $logPath + Write-RdpRepairLog ' 2) .\Fix-RdpTls.ps1 -EmergencyRdpOnly - RDP without TLS' 'WARN' $logPath + if (Confirm-RdpRepairContinue 'Включить Emergency SecurityLayer=0 сейчас?') { + Set-RdpRepairSecurityLayer -Layer 0 -LogPath $logPath + Show-RdpRepairClientCacheHint + } + exit 4 + +} catch { + Write-RdpRepairLog "FATAL: $($_.Exception.Message)" 'ERROR' $logPath + Write-RdpRepairLog $_.ScriptStackTrace 'ERROR' $logPath + exit 1 +} diff --git a/scripts/Invoke-RdpRepairSelfTest.ps1 b/scripts/Invoke-RdpRepairSelfTest.ps1 new file mode 100644 index 0000000..0f856ba --- /dev/null +++ b/scripts/Invoke-RdpRepairSelfTest.ps1 @@ -0,0 +1,138 @@ +#Requires -Version 5.1 +<# +.SYNOPSIS + Самотесты модуля RdpRepair (синтаксис, функции, dry-run логики). +#> +[CmdletBinding()] +param( + [switch]$IncludeAdminTests +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$script:SelfTestFailed = 0 +$script:SelfTestPassed = 0 + +function Assert-Test { + param( + [string]$Name, + [scriptblock]$Test + ) + try { + & $Test + $script:SelfTestPassed++ + Write-Host "[PASS] $Name" -ForegroundColor Green + } catch { + $script:SelfTestFailed++ + Write-Host "[FAIL] $Name - $($_.Exception.Message)" -ForegroundColor Red + } +} + +$libPath = Join-Path $PSScriptRoot 'lib\RdpRepair.Common.ps1' +$mainPath = Join-Path $PSScriptRoot 'Fix-RdpTls.ps1' +. $libPath + +Write-Host "`n=== RdpRepair SelfTest ===" -ForegroundColor Cyan + +Assert-Test 'RdpRepair.Common.ps1 exists' { + if (-not (Test-Path -LiteralPath $libPath)) { throw 'file missing' } +} + +Assert-Test 'Fix-RdpTls.ps1 exists' { + if (-not (Test-Path -LiteralPath $mainPath)) { throw 'file missing' } +} + +Assert-Test 'PowerShell parse RdpRepair.Common.ps1' { + $parseErrors = $null + $null = [System.Management.Automation.Language.Parser]::ParseFile($libPath, [ref]$null, [ref]$parseErrors) + if ($parseErrors.Count -gt 0) { throw (($parseErrors | ForEach-Object { $_.ToString() }) -join '; ') } +} + +Assert-Test 'PowerShell parse Fix-RdpTls.ps1' { + $parseErrors = $null + $null = [System.Management.Automation.Language.Parser]::ParseFile($mainPath, [ref]$null, [ref]$parseErrors) + if ($parseErrors.Count -gt 0) { throw (($parseErrors | ForEach-Object { $_.ToString() }) -join '; ') } +} + +Assert-Test 'Dot-source Common without throw' { + if (-not (Get-Command Initialize-RdpRepairDataRoot -ErrorAction SilentlyContinue)) { + throw 'Common module not loaded' + } +} + +Assert-Test 'Initialize-RdpRepairDataRoot returns path' { + $p = Initialize-RdpRepairDataRoot + if (-not (Test-Path -LiteralPath $p)) { throw 'dir not created' } +} + +Assert-Test 'Get-RdpRepairComputerDnsNames non-empty' { + $n = Get-RdpRepairComputerDnsNames + if ($n.Count -lt 1) { throw 'no dns names' } +} + +Assert-Test 'Get-RdpRepairDiagnosticSnapshot object shape' { + if (-not (Test-RdpRepairIsAdministrator)) { + Write-Host ' (skip snapshot fields - not admin)' -ForegroundColor DarkYellow + return + } + $s = Get-RdpRepairDiagnosticSnapshot + foreach ($prop in @('ComputerName', 'SecurityLayer', 'CertCount', 'TermService')) { + if (-not $s.PSObject.Properties[$prop]) { throw "missing property $prop" } + } +} + +Assert-Test 'Registry path constants well-formed' { + if ($script:RdpRepairRdpTcpKey -notmatch 'RDP-Tcp$') { throw 'bad rdp tcp key' } +} + +Assert-Test 'Export-RdpRepairDiagnosticReport writes file' { + if (-not (Test-RdpRepairIsAdministrator)) { + Write-Host ' (skip report write - not admin)' -ForegroundColor DarkYellow + return + } + $log = Join-Path $env:TEMP 'rdp-selftest.log' + $rp = Export-RdpRepairDiagnosticReport -LogPath $log + if (-not (Test-Path -LiteralPath $rp)) { throw 'report not written' } + if ((Get-Item $rp).Length -lt 50) { throw 'report too small' } +} + +Assert-Test 'Confirm-RdpRepairContinue noninteractive' { + $env:RDP_REPAIR_NONINTERACTIVE = '1' + if (-not (Confirm-RdpRepairContinue 'test')) { throw 'expected true' } + Remove-Item Env:\RDP_REPAIR_NONINTERACTIVE -ErrorAction SilentlyContinue +} + +Assert-Test 'SecurityLayer values documented (0,1,2)' { + foreach ($v in 0, 1, 2) { + if ($v -lt 0 -or $v -gt 2) { throw 'invalid' } + } +} + +Assert-Test 'docs REPAIR-INSTALL.md exists' { + $d = Join-Path (Split-Path $PSScriptRoot -Parent) 'docs\REPAIR-INSTALL.md' + if (-not (Test-Path -LiteralPath $d)) { throw 'missing' } +} + +Assert-Test 'docs RETURN-TO-TLS.md exists' { + $d = Join-Path (Split-Path $PSScriptRoot -Parent) 'docs\RETURN-TO-TLS.md' + if (-not (Test-Path -LiteralPath $d)) { throw 'missing' } +} + +if ($IncludeAdminTests -and (Test-RdpRepairIsAdministrator)) { + Write-Host "`n--- Admin-only tests ---" -ForegroundColor Cyan + Assert-Test 'TermService service exists' { + if (-not (Get-Service TermService -ErrorAction SilentlyContinue)) { throw 'TermService missing' } + } + Assert-Test 'Get-RdpRepairRemoteDesktopCertificates no throw' { + $null = Get-RdpRepairRemoteDesktopCertificates + } +} elseif ($IncludeAdminTests) { + Write-Host "`n(Warning: -IncludeAdminTests skipped - not running as Administrator)" -ForegroundColor Yellow +} + +Write-Host "`n=== Results: PASS=$script:SelfTestPassed FAIL=$script:SelfTestFailed ===" -ForegroundColor Cyan +if ($script:SelfTestFailed -gt 0) { + exit 1 +} +exit 0 diff --git a/scripts/lib/RdpRepair.Common.ps1 b/scripts/lib/RdpRepair.Common.ps1 new file mode 100644 index 0000000..246e30b --- /dev/null +++ b/scripts/lib/RdpRepair.Common.ps1 @@ -0,0 +1,336 @@ +#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 $list +} + +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 = $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 = $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 $_.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]$') +}