EN RU

1st Remote Session Manager Pro — RDP под контролем


Оглавление
  1. Введение
  2. Глава 1. Обзор программного комплекса
  3. Глава 2. Архитектура комплекса и состав файлов
  4. Глава 3. Системные требования и совместимость
  5. Глава 4. Установка и начальная настройка
  6. Глава 5. Архитектура Windows Terminal Services и механизм теневого подключения
  7. Глава 6. Реестр Windows и конфигурация RDP
  8. Глава 7. Параметры командной строки
  9. Глава 8. Функциональные возможности главного модуля
  10. Глава 9. Модуль qwinsta-en.ps1 — движок перечисления сессий
  11. Глава 10. Модуль qwinsta_IP_PS7.ps1 — анализатор с корреляцией IP
  12. Глава 11. Механизм обнаружения и парсинга сессий
  13. Глава 12. Безопасность и управление привилегиями
  14. Глава 13. Корпоративное развёртывание
  15. Глава 14. Практические сценарии использования
  16. Глава 15. Полное руководство по устранению неисправностей
  17. Глава 16. Полный справочник параметров командной строки (CLI Reference)
  18. Глава 17. Глоссарий терминов и концепций

Введение

Удалённое администрирование Windows-систем через протокол RDP (Remote Desktop Protocol) — одна из повседневных задач любого системного администратора. Однако штатный инструментарий Windows, при всей своей функциональности, обладает рядом существенных ограничений: зависимость от локали операционной системы в выводе qwinsta, необходимость ручной правки реестра для включения теневого подключения, отсутствие удобного инструмента корреляции IP-адресов с сессиями, сложность автоматизации в гетерогенных средах с Windows 7 по Windows Server 2022.

Именно эти проблемы и решает программный комплекс 1st Remote Session Manager Pro — профессиональный набор PowerShell-скриптов, разработанный Михаилом Дейнекиным (Mikhail Deynekin, deynekin.com). Комплекс состоит из трёх взаимосвязанных модулей, которые в совокупности реализуют полный цикл управления RDP-сессиями: обнаружение, мониторинг, подключение, теневое наблюдение, принудительное завершение и детальный аудит с привязкой IP-адресов.

Данная документация представляет собой исчерпывающее руководство для системных администраторов уровня Senior, охватывающее все аспекты работы комплекса — от внутренней архитектуры кода до практических сценариев применения в корпоративных средах. Материал структурирован таким образом, что опытный специалист может использовать его как справочник при повседневной работе, а также как основу для глубокого изучения механизмов управления сессиями Windows Terminal Services.


Глава 1. Обзор программного комплекса

1.1. Назначение и область применения

1st Remote Session Manager Pro — это профессиональный программный комплекс на базе PowerShell, предназначенный для полнофункционального управления сессиями Remote Desktop Services (RDS) / Terminal Services на серверах и рабочих станциях под управлением Microsoft Windows. Комплекс адресован прежде всего системным администраторам, DevOps-инженерам и специалистам по технической поддержке, которым ежедневно приходится работать с множеством RDP-сессий в корпоративной инфраструктуре.

Область применения комплекса охватывает следующие задачи:

  • Инвентаризация активных RDP-сессий — получение достоверного, локаленезависимого списка всех сеансов на сервере с отображением состояния, типа, пользователя и идентификатора.
  • Теневое подключение (Session Shadowing) — неинвазивное наблюдение за экраном пользователя или полноценное интерактивное подключение к его сессии без прерывания работы.
  • Управление жизненным циклом сессий — принудительное отключение (Disconnect), завершение (Logoff), отправка системных сообщений пользователям.
  • Корреляция IP-адресов — автоматическое сопоставление идентификаторов сессий с реальными IP-адресами клиентов через netstat, журналы событий Windows и WTS API.
  • Аудит и отчётность — формирование структурированных отчётов в форматах JSON, CSV, XML, HTML, Markdown, YAML для интеграции с системами мониторинга.
  • Автоматическая конфигурация сервера — управление реестровыми параметрами RDP, настройка теневого подключения, правил брандмауэра через встроенный механизм Enable-RDPShadowPermissions.
  • Мониторинг в реальном времени — непрерывный опрос состояния сессий с заданным интервалом и визуализацией изменений в консоли.

Комплекс не требует установки сторонних зависимостей, агентов на управляемых узлах или наличия лицензий Microsoft на Remote Desktop Services CAL при работе на физически доступных консольных сессиях. Всё функционирование базируется исключительно на встроенных механизмах Windows: API Terminal Services, утилитах qwinsta/rwinsta/mstsc/shadow, журнале событий и реестре.

1.2. Концепция единого инструмента администрирования RDP

Идея создания комплекса родилась из практической потребности иметь единую точку входа для всех операций с RDP-сессиями — вместо набора разрозненных инструментов, каждый из которых требует отдельного изучения, обладает специфическими ограничениями и не интегрируется друг с другом. Стандартный арсенал администратора Windows включает qwinsta (вывод зависит от локали ОС), rwinsta (только отключение), shadow (только теневое подключение, требует отдельной настройки реестра), mstsc (только клиент RDP, без управления сессиями). Каждый из этих инструментов решает лишь одну задачу и не предоставляет программного интерфейса для автоматизации.

Концепция 1st Remote Session Manager Pro строится на трёх принципах:

  1. Единый интерфейс: все операции с сессиями выполняются через один скрипт с набором параметров командной строки, что исключает необходимость запоминать синтаксис пяти разных утилит.
  2. Надёжность через fallback: каждая ключевая функция имеет несколько альтернативных реализаций — если основной метод недоступен (например, WTS API не работает в данной среде), автоматически задействуется резервный механизм (парсинг qwinsta, журнал событий и т.д.).
  3. Локаленезависимость: весь вывод формируется на основе API или нормализованного парсинга, что гарантирует корректную работу на серверах с русским, немецким, японским и любым другим интерфейсным языком Windows.

1.3. Ключевые преимущества перед стандартными средствами Windows

Приведём сравнительную таблицу возможностей комплекса и стандартных инструментов Windows:

Возможностьqwinstamstscshadow1st RSM Pro
Список сессий✅ (локаль)✅ (API + fallback)
IP-адреса клиентов✅ (3 источника)
Теневое подключение
Авто-настройка реестра
Отключение сессии
Завершение сессии
Отправка сообщений
Экспорт JSON/CSV/HTML
Мониторинг real-time
Автоподъём UAC
PowerShell 5.1 + 7.xN/AN/AN/A
Работа в любой локали

1.4. История версий и текущий статус разработки

Проект размещён в публичном репозитории GitHub по адресу https://github.com/paulmann/1st-Remote-Session-Manager-Pro. Разработка ведётся под именем пользователя paulmann, который является автором и единственным мейнтейнером проекта.

На момент анализа исходного кода (апрель 2026 года) версия главного модуля 1st-Remote-Session-Manager-Pro.ps1 обозначена как v4.2, модуль qwinsta_IP_PS7.ps1 имеет версию v1.1, модуль qwinsta-en.ps1v1.0. Комплекс находится в активной стадии разработки: README содержит секцию с описанием планируемых возможностей (Roadmap), а в коде присутствуют inline-комментарии # TODO, указывающие на области для будущего улучшения.

Текущий функциональный статус по заявлению README:

  • Локаленезависимый вывод сессий — реализовано и протестировано
  • Теневое подключение с авто-конфигурацией реестра — реализовано
  • Корреляция IP через три источника данных — реализовано
  • HTML-отчёты — реализовано
  • PowerShell Remoting для удалённых серверов — в разработке
  • GUI-интерфейс на WPF — запланировано

1.5. Лицензирование и условия использования

Комплекс распространяется под лицензией MIT License, что означает свободное использование, копирование, модификацию и распространение как в личных, так и в коммерческих целях при условии сохранения заголовка с информацией об авторе. Это позволяет корпоративным администраторам свободно включать комплекс в собственные системы автоматизации, дорабатывать под нужды конкретной инфраструктуры и распространять внутри организации без каких-либо лицензионных платежей.

Единственное условие MIT License — сохранение строки с указанием авторства (Copyright (c) Mikhail Deynekin) в заголовке файла при распространении модифицированных версий. При внутреннем использовании без распространения даже это ограничение не является обязательным.


Глава 2. Архитектура комплекса и состав файлов

2.1. Общая структура программного комплекса

Комплекс 1st Remote Session Manager Pro реализован как набор из трёх PowerShell-скриптов, объединённых через механизм dot-sourcing (. "путь_к_файлу") — стандартный PowerShell-паттерн, при котором содержимое дочернего скрипта загружается в область видимости вызывающего скрипта, делая все его функции и переменные доступными напрямую.

Структура файлов репозитория:

1st-Remote-Session-Manager-Pro/
├── 1st-Remote-Session-Manager-Pro.ps1 # Главный модуль (v4.2, ~40 KB)
├── qwinsta-en.ps1 # Движок локаленезависимого перечисления (v1.0, ~15 KB)
├── qwinsta_IP_PS7.ps1 # Анализатор с корреляцией IP (v1.1, ~40 KB)
├── README.md # Документация (~20 KB)
└── (шаблон HTML-отчёта встроен inline)

Логика взаимодействия выглядит следующим образом: пользователь вызывает только главный модуль (1st-Remote-Session-Manager-Pro.ps1), который при старте проверяет наличие дочерних модулей в той же директории и подгружает их через dot-sourcing. Если дочерние модули не найдены, главный модуль пытается загрузить их напрямую с GitHub.

2.2. Файл 1st-Remote-Session-Manager-Pro.ps1 — главный модуль

Главный модуль является точкой входа всего комплекса и выполняет следующие роли:

  • Парсинг и валидация параметров командной строки — обработка всего набора [CmdletBinding()] параметров, проверка совместимости флагов, вывод справки.
  • Управление привилегиями — проверка наличия прав администратора через [Security.Principal.WindowsPrincipal], автоподъём UAC при необходимости.
  • Оркестрация подмодулей — загрузка qwinsta-en.ps1 и qwinsta_IP_PS7.ps1, делегирование им задач перечисления и анализа сессий.
  • Реализация операций управления сессиями — вызов mstsc /shadow, rwinsta, msg, прямые изменения реестра.
  • Форматированный вывод — система цветового оформления консоли, вывод ASCII-боксов, таблиц, статистики.
  • Конфигурация RDP — функция Enable-RDPShadowPermissions, которая автоматически выставляет все необходимые реестровые ключи для корректной работы теневого подключения.

Размер файла составляет около 40 KB исходного кода. Структура кода организована в три логических блока: заголовок с метаданными и константами, библиотека вспомогательных функций и главный управляющий блок (Main logic), вызываемый в конце файла.

2.3. Файл qwinsta-en.ps1 — движок локаленезависимого перечисления сессий

Этот модуль решает одну конкретную, но критически важную задачу: получение списка RDP-сессий в нормализованном, локаленезависимом формате независимо от языка интерфейса Windows на целевом сервере.

Проблема, которую решает модуль: стандартная утилита qwinsta выводит состояния сессий (Active, Disc, Listen) на языке интерфейса ОС. На русскоязычном Windows Server эти строки будут Активный, Отключен, Прослушивание — и любой скрипт, ожидающий английских значений, сломается. Кроме того, ширина колонок в выводе qwinsta не фиксирована и зависит от конкретной версии Windows.

Модуль реализует два независимых пути получения данных:

  1. Основной путь — WTS API: использование нативного Win32 API через [System.Runtime.InteropServices.Marshal] и P/Invoke вызовы к wtsapi32.dll. API возвращает данные в виде структур, не зависящих от локали — состояния кодируются числовыми константами (WTS_CONNECTSTATE_CLASS enum), которые модуль конвертирует в английские строки самостоятельно.
  2. Резервный путь — парсинг qwinsta: если WTS API недоступен, модуль принудительно устанавливает культуру процесса на en-US (через [System.Threading.Thread]::CurrentThread.CurrentCulture) перед вызовом qwinsta, что в большинстве случаев принуждает утилиту выводить английские строки. Затем применяется позиционный парсер колонок.

2.4. Файл qwinsta_IP_PS7.ps1 — анализатор RDP-сессий с корреляцией IP

Это наиболее технически сложный модуль комплекса. Его задача — ответить на вопрос, который стандартные инструменты Windows не могут решить напрямую: «С какого IP-адреса подключён пользователь с Session ID X?»

Проблема состоит в том, что qwinsta не отображает IP-адреса клиентов. WTS API предоставляет IP только для активных сессий и только при определённых условиях. Журнал событий Windows содержит IP-адреса в событиях входа, но их нужно коррелировать с текущим состоянием сессии. Модуль реализует полную цепочку сбора и сопоставления данных из трёх независимых источников:

  • netstat — текущие активные TCP-соединения на порт 3389 (или нестандартный порт RDP)
  • Журнал событий Security — события 4624 (успешный вход), 4625 (неудачный вход), 4778 (переподключение сессии), 4779 (отключение сессии)
  • WTS API — прямой запрос WTSQuerySessionInformation с параметром WTSClientAddress

Модуль использует систему классов PowerShell (class RdpSession, class RdpConnection, class RdpMatch, class AnalysisResult) — возможность, доступная начиная с PowerShell 5.0, что объясняет наличие PS7 в имени файла (оптимизирован для PowerShell 7, но совместим с 5.1).

2.5. Взаимодействие модулей между собой (dot-sourcing)

Механизм dot-sourcing в PowerShell работает следующим образом: когда интерпретатор встречает строку . "C:\path\to\module.ps1", он выполняет содержимое указанного файла в текущей области видимости (scope), а не в дочерней. Это означает, что все функции, переменные и классы, определённые в подключаемом файле, становятся доступны в вызывающем скрипте точно так же, как если бы они были определены непосредственно в нём.

В коде главного модуля загрузка подмодулей выглядит примерно следующим образом:

# Определение путей к модулям
$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
$QwinstaEnPath = Join-Path $ScriptDir "qwinsta-en.ps1"
$QwinstaIpPath = Join-Path $ScriptDir "qwinsta_IP_PS7.ps1"

# Загрузка через dot-sourcing с fallback на GitHub
if (Test-Path $QwinstaEnPath) {
. $QwinstaEnPath
} else {
# Загрузка с GitHub при отсутствии локального файла
$url = "https://raw.githubusercontent.com/paulmann/1st-Remote-Session-Manager-Pro/main/qwinsta-en.ps1"
Invoke-Expression (Invoke-RestMethod $url)
}

Такой подход позволяет комплексу работать как в полноценно развёрнутом виде (все три файла в одной папке), так и в режиме «одного файла» — когда главный модуль самостоятельно подгружает зависимости из интернета.

2.6. Принципы модульного проектирования на PowerShell

Разработчик придерживается нескольких принципов, характерных для профессиональных PowerShell-проектов:

Single Responsibility: каждый модуль решает одну чётко определённую задачу. qwinsta-en.ps1 занимается только перечислением, qwinsta_IP_PS7.ps1 — только анализом и корреляцией, главный модуль — только оркестрацией и пользовательским интерфейсом.

Defensive Programming: каждая функция обёрнута в try/catch, критические операции проверяют предусловия (наличие прав, доступность API, версию PowerShell) перед выполнением, все параметры имеют значения по умолчанию.

Progressive Enhancement: функционал деградирует изящно — если WTS API недоступен, используется qwinsta; если qwinsta не выдаёт IP, используется netstat; если netstat пуст, используется журнал событий. Пользователь всегда получает хоть какой-то результат.

Consistent Output Objects: все функции перечисления возвращают объекты с одинаковым набором свойств (SessionId, UserName, SessionName, State, IpAddress, IsCurrent и т.д.), что позволяет pipeline-обработку и единообразный экспорт.


Глава 3. Системные требования и совместимость

3.1. Поддерживаемые версии Windows

Комплекс разработан с учётом широкого диапазона версий Windows, встречающихся в реальных корпоративных средах. Базовый функционал (перечисление сессий, теневое подключение, управление сессиями) поддерживается начиная с Windows 7 / Windows Server 2008 R2.

Версия WindowsБазовый функционалWTS APIIP-корреляцияКлассы PSСтатус
Windows 7 SP1Частично❌ (PS 2.0)Legacy
Windows 8.1❌ (PS 4.0)Legacy
Windows 10 (1607+)✅ (PS 5.1)Полная
Windows 11Полная
Server 2008 R2ЧастичноLegacy
Server 2012 R2Ограниченная
Server 2016Полная
Server 2019Полная
Server 2022Полная + оптимизировано

На системах с PowerShell ниже 5.0 (Windows 7, Server 2008 R2, Server 2012) модуль qwinsta_IP_PS7.ps1 работает в режиме совместимости: классы PowerShell недоступны, поэтому модуль использует [PSCustomObject] вместо типизированных классов. При этом функциональность незначительно снижается: отсутствуют строгая типизация и IntelliSense, но практический результат идентичен.

3.2. Требования к версии PowerShell

Комплекс поддерживает две ветки PowerShell:

PowerShell 5.1 (встроен в Windows 10 и Windows Server 2016+):

  • Полный функционал всех трёх модулей
  • Поддержка классов PowerShell (PS 5.0+)
  • Рекомендуется для систем, где невозможна установка PS 7.x
  • Ограничение: некоторые синтаксические конструкции PS 7 (??=, ?[]) недоступны, поэтому в коде есть conditional compilation-like блоки if ($PSVersionTable.PSVersion.Major -ge 7)

PowerShell 7.x (кроссплатформенный, устанавливается отдельно):

  • Оптимальная производительность
  • Полный синтаксис: null-coalescing (??), null-conditional (?.), тернарный оператор
  • Параллельный ForEach-Object -Parallel для ускорения массового анализа
  • Рекомендуется для производственного использования

Проверка версии PowerShell при старте:

# Из кода главного модуля
$PSVersionMajor = $PSVersionTable.PSVersion.Major
if ($PSVersionMajor -lt 5) {
Write-Warning "PowerShell 5.1+ is required for full functionality. Current: $($PSVersionTable.PSVersion)"
# Продолжает работу в режиме ограниченной совместимости
}

3.3. Требования к правам доступа

Для полноценного функционирования комплекса требуются права локального администратора на целевой системе. Это обусловлено следующими операциями:

  • Запись в реестр (HKLM:\...) — необходима для функции Enable-RDPShadowPermissions
  • Вызов WTS API с параметрами управления сессиями — требует привилегий
  • Теневое подключение (mstsc /shadow) — доступно только администраторам или пользователям в группе Remote Desktop Users с явно выданными правами
  • Чтение журнала Security — требует прав администратора или явного делегирования
  • Завершение сессий (rwinsta) — только администраторы

Для перечисления сессий без операций управления (-Sessions, -Status) достаточно прав обычного пользователя при наличии членства в группе Remote Desktop Users. Комплекс автоматически определяет контекст прав и подавляет недоступные операции с понятным сообщением об ошибке вместо критического сбоя.

3.4. Зависимости от служб Windows

Для корректной работы комплекса на целевой системе должны быть запущены следующие службы:

СлужбаИмя службыОбязательна для
Remote Desktop ServicesTermServiceВсех RDP-операций
Remote Desktop ConfigurationSessionEnvНастройки сессий
Windows Event LogEventLogIP-корреляции из журналов
Windows Management InstrumentationWinmgmtНекоторых WMI-запросов
Remote Procedure CallRpcSsОбязательна глобально

Служба TermService является ключевой — при её остановке теряется возможность как подключения через RDP, так и управления сессиями. Комплекс проверяет её состояние в блоке Get-Service TermService при вызове с параметром -Status.

3.5. Требования к сетевой инфраструктуре

С точки зрения сетевой инфраструктуры для работы комплекса необходимо:

  • TCP-порт 3389 открыт на сервере (стандартный RDP-порт); при нестандартном порте модуль qwinsta_IP_PS7.ps1 позволяет указать его через параметр -RdpPort.
  • Брандмауэр Windows должен разрешать трафик RDP. Функция Enable-RDPShadowPermissions при необходимости автоматически создаёт соответствующие правила через netsh advfirewall firewall.
  • Для функции теневого подключения: дополнительных сетевых требований нет, так как shadow-сессия устанавливается поверх уже существующего RDP-соединения.
  • Для работы механизма автообновления: требуется доступ к raw.githubusercontent.com по HTTPS (порт 443). В корпоративных средах с прокси-сервером необходима настройка $Env:HTTPS_PROXY.

3.6. Совместимость с групповыми политиками

Ряд функций комплекса может конфликтовать с групповыми политиками организации. Ключевые политики, влияющие на работу:

  • Computer Configuration → Administrative Templates → Windows Components → Remote Desktop Services → Remote Desktop Session Host → Connections → Set rules for remote control of Remote Desktop Services user sessions — если эта политика выставлена в «No remote control allowed», функция теневого подключения не будет работать даже при корректной настройке реестра через Enable-RDPShadowPermissions. GPO имеет приоритет над прямыми изменениями реестра.
  • Computer Configuration → Windows Settings → Security Settings → Windows Firewall — может блокировать правила брандмауэра, создаваемые комплексом.
  • Computer Configuration → Administrative Templates → Windows Components → Remote Desktop Services → Remote Desktop Session Host → Security → Require use of specific security layer — влияет на параметр SecurityLayer реестра, который главный модуль также может изменять.

Рекомендация: при развёртывании в домене AD DS проверить отсутствие конфликтующих GPO с помощью gpresult /H gpresult.html на целевом сервере и убедиться, что применяемые политики не переопределяют настройки, выставляемые комплексом.


Глава 4. Установка и начальная настройка

4.1. Методы загрузки и установки

Комплекс 1st Remote Session Manager Pro не требует традиционной «установки» в смысле запуска инсталлятора, создания записей в реестре или копирования файлов в системные директории. Это чистые PowerShell-скрипты, которые достаточно разместить в любой папке и запустить. Тем не менее существует несколько методов развёртывания, каждый из которых оптимален для определённых сценариев.

Поддерживаемые методы развёртывания:

  1. Прямая загрузка через Invoke-RestMethod — загрузка и немедленный запуск из интернета, оптимально для разового использования или тестирования.
  2. Клонирование Git-репозитория — полноценное развёртывание с историей версий, оптимально для постоянного использования.
  3. Ручное скачивание ZIP — загрузка архива через браузер с GitHub, оптимально в изолированных средах без доступа к Git.
  4. Корпоративное развёртывание через GPO/SCCM — централизованное распространение на множество серверов.
  5. Встроенный механизм автообновления — обновление уже установленных файлов до актуальной версии из GitHub.

4.2. Установка через прямую загрузку (Invoke-RestMethod)

Это самый быстрый способ запустить главный модуль — одна строка в консоли PowerShell с правами администратора. README репозитория предоставляет готовые команды для этого:

# Метод 1: Загрузка и запуск в памяти (без сохранения на диск)
# Работает на PS 5.1 и PS 7.x
irm https://raw.githubusercontent.com/paulmann/1st-Remote-Session-Manager-Pro/main/1st-Remote-Session-Manager-Pro.ps1 | iex
# Метод 2: Загрузка с сохранением в текущую папку и запуск
$url = "https://raw.githubusercontent.com/paulmann/1st-Remote-Session-Manager-Pro/main/1st-Remote-Session-Manager-Pro.ps1"
Invoke-RestMethod -Uri $url -OutFile ".\1st-Remote-Session-Manager-Pro.ps1"
.\1st-Remote-Session-Manager-Pro.ps1 -Sessions
# Метод 3: Загрузка всех трёх файлов комплекса
$BaseUrl = "https://raw.githubusercontent.com/paulmann/1st-Remote-Session-Manager-Pro/main"
$Files = @(
"1st-Remote-Session-Manager-Pro.ps1",
"qwinsta-en.ps1",
"qwinsta_IP_PS7.ps1"
)
$TargetDir = "C:\Tools\RDPManager"
New-Item -ItemType Directory -Force -Path $TargetDir | Out-Null
foreach ($File in $Files) {
Invoke-RestMethod -Uri "$BaseUrl/$File" -OutFile "$TargetDir\$File"
Write-Host "Downloaded: $File" -ForegroundColor Green
}

Важное замечание: при использовании метода «в памяти» (irm | iex) подмодули qwinsta-en.ps1 и qwinsta_IP_PS7.ps1 не будут загружены, так как главный модуль использует Split-Path -Parent $MyInvocation.MyCommand.Path для определения своего расположения, что не работает при запуске из памяти. В этом случае главный модуль автоматически подгружает зависимости напрямую с GitHub через отдельные вызовы Invoke-RestMethod.

4.3. Установка через клонирование Git-репозитория

Рекомендуемый метод для постоянного использования — клонирование репозитория:

# Предварительное требование: Git установлен и доступен в PATH
# Проверка наличия Git
if (-not (Get-Command git -ErrorAction SilentlyContinue)) {
Write-Error "Git не установлен. Установите из https://git-scm.com или используйте winget install Git.Git"
exit 1
}

# Клонирование репозитория в C:\Tools\
Set-Location "C:\Tools"
git clone https://github.com/paulmann/1st-Remote-Session-Manager-Pro.git

# Переход в директорию
Set-Location ".\1st-Remote-Session-Manager-Pro"

# Добавление директории в PATH (опционально, для вызова из любой директории)
$CurrentPath = [Environment]::GetEnvironmentVariable("Path", "Machine")
if ($CurrentPath -notlike "*1st-Remote-Session-Manager-Pro*") {
[Environment]::SetEnvironmentVariable(
"Path",
"$CurrentPath;C:\Tools\1st-Remote-Session-Manager-Pro",
"Machine"
)
Write-Host "Директория добавлена в системный PATH" -ForegroundColor Green
}

После клонирования обновление до актуальной версии выполняется стандартной командой:

cd C:\Tools\1st-Remote-Session-Manager-Pro
git pull origin main

Преимущества этого метода: история изменений, возможность откатиться к предыдущей версии (git checkout v4.1), автоматическое разрешение конфликтов при обновлении.

4.4. Настройка политики выполнения скриптов (ExecutionPolicy)

Перед первым запуском необходимо убедиться, что политика выполнения PowerShell позволяет запуск скриптов. По умолчанию в Windows Server установлена политика RemoteSigned или Restricted.

# Проверка текущей политики
Get-ExecutionPolicy -List

# Вывод будет примерно таким:
# Scope ExecutionPolicy
# ----- ---------------
# MachinePolicy Undefined
# UserPolicy Undefined
# Process Undefined
# CurrentUser Undefined
# LocalMachine RemoteSigned

# Для запуска скриптов, загруженных из интернета,
# необходима политика RemoteSigned или Unrestricted
# Рекомендуемая настройка для серверов:
Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope LocalMachine -Force

# Если файлы были скачаны из интернета, они могут иметь NTFS Zone.Identifier = 3 (Internet)
# Разблокировка конкретного файла:
Unblock-File -Path "C:\Tools\RDPManager\1st-Remote-Session-Manager-Pro.ps1"
Unblock-File -Path "C:\Tools\RDPManager\qwinsta-en.ps1"
Unblock-File -Path "C:\Tools\RDPManager\qwinsta_IP_PS7.ps1"

# Альтернативно — разблокировка всех PS1 в папке
Get-ChildItem "C:\Tools\RDPManager\*.ps1" | Unblock-File

# Проверка отсутствия блокировки
Get-Item "C:\Tools\RDPManager\*.ps1" -Stream Zone.Identifier -ErrorAction SilentlyContinue
# Если вывод пустой — блокировок нет

Политика Bypass: для автоматизированных задач (Task Scheduler, SCCM-скрипты) рекомендуется запускать скрипты с явным указанием политики для процесса:

powershell.exe -ExecutionPolicy Bypass -File "C:\Tools\RDPManager\1st-Remote-Session-Manager-Pro.ps1" -Sessions

Это не изменяет системную политику, а лишь переопределяет её для конкретного процесса PowerShell.

4.5. Первый запуск и проверка работоспособности

После загрузки файлов рекомендуется выполнить последовательность проверочных запусков:

# Шаг 1: Проверка версии и базовой работоспособности
.\1st-Remote-Session-Manager-Pro.ps1 -Version

# Ожидаемый вывод:
# ╔══════════════════════════════════════════╗
# ║ 1st Remote Session Manager Pro v4.2 ║
# ║ by Mikhail Deynekin | deynekin.com ║
# ╚══════════════════════════════════════════╝

# Шаг 2: Вывод справки
.\1st-Remote-Session-Manager-Pro.ps1 -Help
# или стандартный PowerShell способ:
Get-Help .\1st-Remote-Session-Manager-Pro.ps1 -Full

# Шаг 3: Проверка системного статуса RDP
.\1st-Remote-Session-Manager-Pro.ps1 -Status

# Шаг 4: Перечисление текущих сессий
.\1st-Remote-Session-Manager-Pro.ps1 -Sessions

# Шаг 5: Запуск с отладочным выводом для диагностики
.\1st-Remote-Session-Manager-Pro.ps1 -Sessions -DebugMode

При успешной первоначальной настройке вывод команды -Sessions должен показать текущую консольную сессию (Session 0 или 1) со статусом Active и именем текущего пользователя. Если вывод пустой или содержит ошибки — обратитесь к Главе 15 (Устранение неисправностей).

4.6. Механизм автообновления из GitHub

Главный модуль содержит встроенный механизм обновления, который позволяет обновить все файлы комплекса до последней версии из репозитория:

# Запуск обновления
.\1st-Remote-Session-Manager-Pro.ps1 -Update

# Внутренняя логика работы -Update (из исходного кода):
# 1. Получение текущей версии из заголовка файла
# 2. Запрос актуальной версии с GitHub через Invoke-RestMethod
# 3. Сравнение версий
# 4. При необходимости — загрузка новых версий всех трёх файлов
# 5. Перезапуск скрипта с теми же параметрами (если версия изменилась)

Ключевые детали реализации механизма обновления, извлечённые из анализа исходного кода:

# Упрощённая схема логики обновления из кода
function Update-ScriptFromGitHub {
param([string]$ScriptPath)

$BaseUrl = "https://raw.githubusercontent.com/paulmann/1st-Remote-Session-Manager-Pro/main"
$FileName = Split-Path -Leaf $ScriptPath

try {
$RemoteContent = Invoke-RestMethod -Uri "$BaseUrl/$FileName" -ErrorAction Stop
# Извлечение версии из удалённого файла через regex
$RemoteVersion = ($RemoteContent | Select-String -Pattern '# Version:\s*([\d.]+)').Matches[0].Groups[1].Value
$LocalVersion = (Get-Content $ScriptPath | Select-String -Pattern '# Version:\s*([\d.]+)').Matches[0].Groups[1].Value

if ([version]$RemoteVersion -gt [version]$LocalVersion) {
$RemoteContent | Set-Content -Path $ScriptPath -Encoding UTF8
return $true # Обновление выполнено
}
} catch {
Write-Warning "Не удалось проверить обновление: $_"
}
return $false
}

4.7. Развёртывание в корпоративной среде через GPO и SCCM

Для корпоративного развёртывания рекомендуется следующий подход:

Через GPO (Group Policy):

# Создание сетевой папки для хранения инструмента
# \\fileserver\IT-Tools\RDPManager\

# Скрипт входа для серверов (Computer Configuration → Scripts → Startup)
$Source = "\\fileserver\IT-Tools\RDPManager"
$Dest = "C:\Tools\RDPManager"

if (-not (Test-Path $Dest)) {
New-Item -ItemType Directory -Force -Path $Dest | Out-Null
}

# Копирование только если версия изменилась
$Files = @("1st-Remote-Session-Manager-Pro.ps1", "qwinsta-en.ps1", "qwinsta_IP_PS7.ps1")
foreach ($File in $Files) {
$SrcFile = Join-Path $Source $File
$DstFile = Join-Path $Dest $File
if (-not (Test-Path $DstFile) -or
(Get-FileHash $SrcFile).Hash -ne (Get-FileHash $DstFile).Hash) {
Copy-Item $SrcFile $DstFile -Force
Write-EventLog -LogName Application -Source "RDPManager" -EventId 1001 `
-EntryType Information -Message "RDPManager updated: $File"
}
}

# Настройка ExecutionPolicy
Set-ExecutionPolicy RemoteSigned -Scope LocalMachine -Force

# Создание ярлыка в меню администратора
$WshShell = New-Object -ComObject WScript.Shell
$Shortcut = $WshShell.CreateShortcut("C:\ProgramData\Microsoft\Windows\Start Menu\Programs\RDP Session Manager.lnk")
$Shortcut.TargetPath = "powershell.exe"
$Shortcut.Arguments = "-ExecutionPolicy Bypass -File `"$Dest\1st-Remote-Session-Manager-Pro.ps1`" -Sessions"
$Shortcut.IconLocation = "mstsc.exe,0"
$Shortcut.Save()

Через SCCM (Configuration Manager):

  1. Создать Package с источником \\fileserver\IT-Tools\RDPManager\
  2. Создать Program с командной строкой: powershell.exe -ExecutionPolicy Bypass -File "1st-Remote-Session-Manager-Pro.ps1" -Version
  3. Создать Deployment на коллекцию серверов с типом Install
  4. Установить Detection Method: File exists: C:\Tools\RDPManager\1st-Remote-Session-Manager-Pro.ps1

Глава 5. Архитектура Windows Terminal Services и механизм теневого подключения

5.1. История и эволюция службы Terminal Services / RDS

Понимание архитектуры Windows Terminal Services (WTS) критически важно для правильного использования комплекса, поскольку все его функции работают непосредственно с механизмами WTS на уровне API. История службы насчитывает более 25 лет активного развития:

  • Windows NT 4.0 Terminal Server Edition (1998): первая реализация многопользовательских сессий в Windows, основанная на технологии Citrix WinFrame. Одна консольная сессия (Session 0), остальные — пользовательские.
  • Windows 2000 Server: Terminal Services интегрированы как стандартный компонент. Появляется mstsc.exe (Microsoft Terminal Services Client), предшественник современного клиента RDP.
  • Windows Server 2003: RDP 5.2, улучшенная поддержка аудио, принтеров, буфера обмена. Появляется qwinsta/rwinsta как стандартные утилиты управления.
  • Windows Vista / Server 2008: Session 0 Isolation — системные процессы вынесены в изолированную Session 0, недоступную для пользователей. Это кардинально изменило архитектуру служб Windows.
  • Windows 7 / Server 2008 R2: RDP 7.0, RemoteFX, поддержка мультитач, улучшенная графика Aero Remoting.
  • Windows 8 / Server 2012: RDP 8.0, адаптивная компрессия, поддержка USB-перенаправления. Remote Desktop Services переименованы в Remote Desktop Services (RDS) с полным стеком: RD Session Host, RD Gateway, RD Connection Broker, RD Web Access.
  • Windows 10 / Server 2016: RDP 10.0, поддержка 4K, H.264/H.265 кодеки, AutoSize.
  • Windows 11 / Server 2022: RDP 10.9+, улучшенная поддержка GPU, Teams Media Optimization, Azure AD-аутентификация.

5.2. Многоуровневая архитектура сеансов Windows

Современная архитектура сессий Windows построена на концепции изолированных сеансов (Session Isolation), введённой в Windows Vista. Каждая пользовательская сессия — это полноценная виртуальная среда выполнения с собственным:

  • Рабочим столом (Desktop): изолированное пространство отрисовки, не видимое другим сессиям напрямую.
  • Пространством объектов ядра: мьютексы, события, семафоры, именованные каналы с префиксом Local\ видны только в пределах своей сессии.
  • Реестром HKCU: каждая сессия имеет собственный HKEY_CURRENT_USER, загружаемый при входе из профиля пользователя.
  • Переменными окружения: %USERNAME%, %USERPROFILE%, %TEMP% и другие специфичны для каждой сессии.
  • Токеном доступа: криптографически подписанный объект, описывающий привилегии и членство в группах данного пользователя.

Сессии в Windows идентифицируются целочисленным Session ID (тип DWORD), начиная с 0. Session 0 — зарезервирована для системных служб (Services Session), Session 1 и выше — для интерактивных пользователей.

Жизненный цикл сессии проходит через следующие состояния:

Состояние (EN)Состояние (RU)Код WTSОписание
ActiveАктивный0Пользователь вошёл и активен
ConnectedПодключён1Подключён, но не активен
ConnectQueryЗапрос соединения2В процессе установки соединения
ShadowТеневой3Наблюдение за другой сессией
DisconnectedОтключён4Сессия существует, клиент отключился
IdleОжидание5Ожидание клиентского подключения
ListenПрослушивание6Слушатель RDP-Tcp (системный)
ResetСброс7Сессия завершается
DownНедоступен8Ошибка инициализации
InitИнициализация9Начальная инициализация

Именно эти числовые коды возвращает WTS API, и именно их нормализует модуль qwinsta-en.ps1 в читаемые английские строки, независимо от локали ОС.

5.3. Компоненты пользовательского режима (User Mode)

На уровне пользовательского режима (Ring 3) архитектура RDS включает следующие ключевые компоненты:

svchost.exeTermService (Remote Desktop Services): главная служба, управляющая всеми аспектами терминальных сессий. Принимает входящие RDP-соединения, создаёт и уничтожает сессии, управляет слушателями (Listeners).

rdpclip.exe: процесс перенаправления буфера обмена RDP. Запускается в каждой пользовательской RDP-сессии и отвечает за синхронизацию буфера обмена между клиентом и сервером.

dwm.exe (Desktop Window Manager): менеджер окон, работающий в контексте каждой сессии. Именно он управляет визуальным содержимым рабочего стола, которое передаётся клиенту через RDP.

csrss.exe (Client/Server Runtime Subsystem): подсистема Win32, отдельный экземпляр которой создаётся для каждой сессии. Управляет консолями, потоками и завершением процессов сессии.

winlogon.exe: процесс управления входом/выходом. Обрабатывает Ctrl+Alt+Del, экран блокировки, загрузку профиля пользователя.

5.4. Компоненты режима ядра (Kernel Mode)

На уровне режима ядра (Ring 0) RDS взаимодействует с:

win32k.sys: ядерный компонент подсистемы Win32, расширенный для поддержки многопользовательских сессий. Управляет низкоуровневой отрисовкой, оконными сообщениями и устройствами ввода в контексте конкретной сессии.

rdpdr.sys (RDP Device Redirector): драйвер ядра для перенаправления устройств (дисков, принтеров, COM-портов) из клиентской системы в RDP-сессию.

tdtcp.sys (Terminal Driver TCP): транспортный драйвер нижнего уровня, обеспечивающий TCP-транспорт для RDP-протокола. Работает в паре с afd.sys (Ancillary Function Driver).

rdpwd.sys (RDP WinStation Driver): драйвер протокола RDP верхнего уровня. Отвечает за шифрование/дешифрование RDP-трафика, компрессию и декомпрессию данных.

Понимание этих компонентов важно при диагностике: многие проблемы с теневым подключением связаны именно с win32k.sys и его ограничениями на межсессионный доступ к объектам Desktop.

5.5. Протокол RDP: стек уровней и шифрование

RDP (Remote Desktop Protocol) — проприетарный протокол Microsoft, разработанный на основе ITU-T T.128 Application Sharing Protocol. Современная версия RDP (10.x) поддерживает следующий стек уровней:

┌─────────────────────────────────────────┐
│ Прикладной уровень RDP │
│ (виртуальные каналы, аудио, буфер) │
├─────────────────────────────────────────┤
│ Уровень безопасности RDP │
│ (TLS 1.2/1.3 или RDP Native Security) │
├─────────────────────────────────────────┤
│ Уровень кодирования данных │
│ (RDP6 Bulk Compression, H.264/AVC) │
├─────────────────────────────────────────┤
│ Транспортный уровень MCS │
│ (Multipoint Communication Service) │
├─────────────────────────────────────────┤
│ TCP (порт 3389) / UDP (порт 3389) │
└─────────────────────────────────────────┘

Начиная с RDP 8.0 протокол также поддерживает UDP-транспорт (RDP-UDP) для улучшения производительности при работе через WAN и WiFi-каналы. UDP-туннель работает параллельно с TCP и используется для передачи чувствительных к задержкам данных (графика, аудио), тогда как TCP обеспечивает надёжную доставку управляющих сообщений.

Параметры шифрования, которыми управляет комплекс через реестр:

  • SecurityLayer = 0: RDP Security Layer (RC4-шифрование, устаревший режим, Windows XP-совместимость)
  • SecurityLayer = 1: Negotiate (сервер предлагает TLS, клиент выбирает поддерживаемый режим) — рекомендуется по умолчанию
  • SecurityLayer = 2: SSL/TLS (только TLS, обязательный сертификат, максимальная безопасность)

5.6. Механизм теневого подключения (Session Shadowing)

Session Shadowing — это механизм, позволяющий администратору наблюдать за экраном пользователя RDP-сессии или полностью управлять ею без создания отдельного независимого соединения. Технически это реализовано как виртуальный канал (Virtual Channel) поверх существующего RDP-соединения целевой сессии.

При активации теневого подключения происходит следующее:

  1. Запрос на наблюдение: система создаёт специальный виртуальный канал между сессией наблюдателя и целевой сессией.
  2. Уведомление пользователя (если включено): в целевой сессии отображается диалог: «Администратор [имя] запрашивает наблюдение за вашим сеансом. Разрешить?»
  3. Передача изображения: win32k.sys начинает дублировать поток графических команд Desktop целевой сессии в канал наблюдателя.
  4. Передача ввода (в интерактивном режиме): клавиатурный и мышиный ввод наблюдателя инжектируется в очередь сообщений целевой сессии через специальный механизм WTSStartRemoteControlSession.

Теневое подключение инициируется командой:

# Внутри 1st-Remote-Session-Manager-Pro.ps1
# При вызове с параметром -SessionId 3
mstsc.exe /shadow:3 /v:localhost /control # Интерактивный режим
mstsc.exe /shadow:3 /v:localhost /noConsentPrompt # Без запроса согласия
mstsc.exe /shadow:3 /v:localhost # Только просмотр (ViewOnly)

5.7. Режимы теневого подключения и их отличия

Windows предоставляет четыре режима теневого подключения, управляемых через реестровый параметр HKLM:\SYSTEM\CurrentControlSet\Control\Terminal Server\Shadow:

Значение параметра ShadowРежимЗапрос согласияУправление
0Запрет теневого подключения
1Full Control with user’s permission✅ Требуется✅ Доступно
2Full Control without user’s permission❌ Не требуется✅ Доступно
3View Session with user’s permission✅ Требуется❌ Только просмотр
4View Session without user’s permission❌ Не требуется❌ Только просмотр

Значение по умолчанию после чистой установки Windows — 1 (Full Control with permission). Функция Enable-RDPShadowPermissions главного модуля по умолчанию устанавливает режим 2 (без запроса согласия, с управлением), что максимально удобно для администрирования, но требует осторожности с точки зрения соблюдения политик конфиденциальности.

Помимо реестрового параметра, существует также политика GPO, которая имеет более высокий приоритет:

Computer Configuration → Administrative Templates →
Windows Components → Remote Desktop Services →
Remote Desktop Session Host → Connections →
"Set rules for remote control of Remote Desktop Services user sessions"

Если данная политика применяется через домен, прямые изменения реестра через Enable-RDPShadowPermissions будут перезаписаны при следующем обновлении групповых политик. Рекомендация: либо настраивать через GPO, либо явно блокировать применение этой конкретной политики к серверам, управляемым комплексом.

Дополнительное ограничение теневого подключения в современных Windows (начиная с Server 2012 R2): невозможно подключиться к сессии с другой сессии через loopback (/v:localhost), если на сервере включён только один монитор. В этом случае команда mstsc /shadow требует явного указания имени сервера или IP-адреса, даже при подключении к локальной сессии. Главный модуль обрабатывает эту ситуацию автоматически, используя реальное имя хоста из $env:COMPUTERNAME вместо localhost.


Глава 6. Реестр Windows и конфигурация RDP

6.1. Ключевые ветки реестра, задействованные в комплексе

Работа с реестром Windows является центральным механизмом конфигурирования RDP в комплексе 1st Remote Session Manager Pro. Все изменения параметров RDP-сервера выполняются через прямую запись в реестр, что даёт максимальный контроль в обход ограничений GUI — особенно важно в сценариях автоматизации, когда графический интерфейс недоступен (Server Core, PowerShell Remoting, задачи планировщика).

Комплекс работает с тремя основными ветками реестра:

HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Control\Terminal Server\
HKEY_LOCAL_MACHINE\SOFTWARE\Policies\Microsoft\Windows NT\Terminal Services\
HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Control\Terminal Server\WinStations\RDP-Tcp\

Первая ветка — оперативная конфигурация Terminal Server, действует немедленно без перезагрузки служб. Вторая ветка — политики, которые имеют приоритет над первой веткой и периодически перезаписываются при обновлении GPO. Третья ветка — параметры конкретного слушателя RDP-Tcp, включая порт, шифрование и механизмы аутентификации.

Полная карта реестровых параметров, задействованных в комплексе:

HKLM:\SYSTEM\CurrentControlSet\Control\Terminal Server\
├── fDenyTSConnections REG_DWORD (0=RDP включён, 1=RDP отключён)
├── Shadow REG_DWORD (0-4, режимы теневого подключения)
├── AllowRemoteRPC REG_DWORD (1=разрешить удалённый RPC)
├── fSingleSessionPerUser REG_DWORD (1=одна сессия на пользователя)
└── WinStations\
└── RDP-Tcp\
├── UserAuthentication REG_DWORD (1=NLA включён, 0=NLA отключён)
├── SecurityLayer REG_DWORD (0=RDP, 1=Negotiate, 2=SSL)
├── MinEncryptionLevel REG_DWORD (1=Low, 2=Client Compat, 3=High, 4=FIPS)
├── PortNumber REG_DWORD (по умолчанию 3389)
├── MaxInstanceCount REG_DWORD (макс. число соединений)
└── fPromptForPassword REG_DWORD (1=всегда спрашивать пароль)

HKLM:\SOFTWARE\Policies\Microsoft\Windows NT\Terminal Services\
├── Shadow REG_DWORD (GPO-override для Shadow)
├── fDenyTSConnections REG_DWORD (GPO-override для RDP)
├── UserAuthentication REG_DWORD (GPO-override для NLA)
└── SecurityLayer REG_DWORD (GPO-override для SecurityLayer)

6.2. Параметр fDenyTSConnections — включение/отключение RDP

Параметр fDenyTSConnections по своему смыслу является инвертированным флагом: значение 0 означает, что подключения разрешены (Deny = False), значение 1 — что подключения запрещены (Deny = True). Это исторически сложившееся именование, вводящее в заблуждение: параметр назван «запретить», а не «разрешить», поэтому для включения RDP нужно устанавливать 0, а не 1.

Работа с этим параметром в комплексе:

# Чтение текущего состояния RDP
$RegPath = "HKLM:\SYSTEM\CurrentControlSet\Control\Terminal Server"
$Value = (Get-ItemProperty -Path $RegPath -Name "fDenyTSConnections" -ErrorAction SilentlyContinue).fDenyTSConnections

if ($Value -eq 0) {
Write-Host "RDP: ENABLED" -ForegroundColor Green
} elseif ($Value -eq 1) {
Write-Host "RDP: DISABLED" -ForegroundColor Red
} else {
Write-Host "RDP: UNKNOWN (value: $Value)" -ForegroundColor Yellow
}

# Включение RDP (установка fDenyTSConnections = 0)
Set-ItemProperty -Path $RegPath -Name "fDenyTSConnections" -Value 0 -Type DWord -Force

# Отключение RDP (установка fDenyTSConnections = 1)
Set-ItemProperty -Path $RegPath -Name "fDenyTSConnections" -Value 1 -Type DWord -Force

Важно: изменение fDenyTSConnections вступает в силу немедленно — служба Terminal Services перехватывает изменение через механизм Registry Change Notification и применяет его без перезапуска. Однако если параметр также задан в ветке Policies, значение из политик будет иметь приоритет и может перезаписать ваши изменения при следующем запуске gpupdate.

6.3. Параметр Shadow — управление режимами теневого подключения

Параметр Shadow в ветке HKLM:\SYSTEM\CurrentControlSet\Control\Terminal Server управляет поведением функции теневого подключения. Это один из ключевых параметров, с которыми работает функция Enable-RDPShadowPermissions главного модуля.

# Путь к параметру Shadow
$ShadowPath = "HKLM:\SYSTEM\CurrentControlSet\Control\Terminal Server"

# Чтение текущего режима Shadow
$ShadowValue = (Get-ItemProperty -Path $ShadowPath -Name "Shadow" -ErrorAction SilentlyContinue).Shadow

$ShadowModes = @{
0 = "Shadowing DISABLED"
1 = "Full Control WITH user consent"
2 = "Full Control WITHOUT user consent"
3 = "View Only WITH user consent"
4 = "View Only WITHOUT user consent"
}

Write-Host "Shadow mode: $($ShadowModes[$ShadowValue])" -ForegroundColor Cyan

# Установка режима Full Control WITHOUT consent (наиболее удобный для администрирования)
Set-ItemProperty -Path $ShadowPath -Name "Shadow" -Value 2 -Type DWord -Force

# Проверка наличия GPO-override (политика имеет приоритет)
$PolicyPath = "HKLM:\SOFTWARE\Policies\Microsoft\Windows NT\Terminal Services"
$PolicyValue = (Get-ItemProperty -Path $PolicyPath -Name "Shadow" -ErrorAction SilentlyContinue).Shadow
if ($null -ne $PolicyValue) {
Write-Warning "GPO override detected! Shadow policy value = $PolicyValue"
Write-Warning "Registry change may be overridden by Group Policy."
}

Изменение параметра Shadow также вступает в силу немедленно для новых подключений. Активные сессии не прерываются — изменение применяется к следующему запросу теневого подключения.

6.4. Параметр UserAuthentication — Network Level Authentication

Network Level Authentication (NLA) — механизм аутентификации, при котором пользователь должен успешно аутентифицироваться (ввести логин/пароль или предъявить сертификат) до того, как удалённый рабочий стол будет инициализирован и переден клиенту. Это существенно снижает нагрузку на сервер при атаках методом перебора — злоумышленник не получает даже экран входа, не пройдя предварительную аутентификацию.

Параметр UserAuthentication размещается в ветке слушателя:

powershell$WinStationPath = "HKLM:\SYSTEM\CurrentControlSet\Control\Terminal Server\WinStations\RDP-Tcp"

# Чтение текущего значения NLA
$NLAValue = (Get-ItemProperty -Path $WinStationPath -Name "UserAuthentication").UserAuthentication

# Значения: 1 = NLA включён (безопасно, рекомендуется)
#           0 = NLA отключён (совместимость со старыми клиентами)

# Включение NLA
Set-ItemProperty -Path $WinStationPath -Name "UserAuthentication" -Value 1 -Type DWord -Force

# Отключение NLA (например, для совместимости с Windows XP-клиентами)
Set-ItemProperty -Path $WinStationPath -Name "UserAuthentication" -Value 0 -Type DWord -Force

Практическое следствие для комплекса: при включённом NLA теневое подключение через mstsc /shadow также требует предварительной аутентификации. Это не влияет на функционирование комплекса при запуске от имени администратора с текущими учётными данными Windows (SSO через Kerberos/NTLM), но может потребовать явного ввода пароля при подключении к серверам в другом домене или при использовании локальных учётных записей.

6.5. Параметр SecurityLayer — уровень безопасности соединения

Параметр SecurityLayer определяет протокол установки безопасного соединения RDP. Размещается в ветке WinStations\RDP-Tcp:

$WinStationPath = "HKLM:\SYSTEM\CurrentControlSet\Control\Terminal Server\WinStations\RDP-Tcp"

# Чтение текущего уровня безопасности
$SecLayer = (Get-ItemProperty -Path $WinStationPath -Name "SecurityLayer").SecurityLayer

$SecurityLevels = @{
0 = "RDP Security Layer (RC4, legacy, no server certificate)"
1 = "Negotiate (TLS preferred, RDP fallback)"
2 = "SSL/TLS only (requires server certificate)"
}
Write-Host "Security Layer: $($SecurityLevels[$SecLayer])"

# Рекомендуемая настройка для корпоративных серверов:
# SecurityLayer = 2 + UserAuthentication = 1 (NLA + TLS)
Set-ItemProperty -Path $WinStationPath -Name "SecurityLayer" -Value 2 -Type DWord -Force

Взаимосвязь параметров: при SecurityLayer = 2 (только TLS) требуется наличие действующего SSL-сертификата на сервере. Если сертификат отсутствует или просрочен, клиент получит предупреждение, а при строгой политике сертификатов на клиенте — будет заблокирован. На практике большинство корпоративных серверов используют SecurityLayer = 1 (Negotiate) как компромисс между безопасностью и совместимостью.

6.6. Ветка политик Windows NT\Terminal Services

Ветка HKLM:\SOFTWARE\Policies\Microsoft\Windows NT\Terminal Services является зеркалом основных параметров Terminal Server, но с более высоким приоритетом. Значения в этой ветке устанавливаются исключительно через Group Policy и при наличии перекрывают значения из основной конфигурационной ветки.

Функция -Status комплекса при выводе системной информации явно показывает, управляется ли конкретный параметр через GPO:

# Из логики функции Get-RDPStatus в главном модуле
function Get-RDPStatus {
$PolicyPath = "HKLM:\SOFTWARE\Policies\Microsoft\Windows NT\Terminal Services"
$ConfigPath = "HKLM:\SYSTEM\CurrentControlSet\Control\Terminal Server"

$PolicyExists = Test-Path $PolicyPath

if ($PolicyExists) {
$PolicyShadow = (Get-ItemProperty $PolicyPath -Name "Shadow" -EA SilentlyContinue).Shadow
$PolicyRDP = (Get-ItemProperty $PolicyPath -Name "fDenyTSConnections" -EA SilentlyContinue).fDenyTSConnections

if ($null -ne $PolicyShadow) {
Write-Host "[GPO OVERRIDE] Shadow = $PolicyShadow" -ForegroundColor Yellow
}
if ($null -ne $PolicyRDP) {
Write-Host "[GPO OVERRIDE] fDenyTSConnections = $PolicyRDP" -ForegroundColor Yellow
}
}
}

6.7. Ветка WinStations\RDP-Tcp — настройки конкретного слушателя

WinStations\RDP-Tcp — это конфигурация стандартного слушателя RDP (Listener). В нестандартных конфигурациях может существовать несколько слушателей с разными именами (например, RDP-Tcp и RDP-Tcp#1). Комплекс работает только со стандартным слушателем RDP-Tcp.

Полный список параметров этой ветки, релевантных для работы комплекса:

$WinStationPath = "HKLM:\SYSTEM\CurrentControlSet\Control\Terminal Server\WinStations\RDP-Tcp"

# Получение всех параметров слушателя
$Props = Get-ItemProperty -Path $WinStationPath

# Ключевые параметры:
# PortNumber - TCP-порт (по умолчанию 3389)
# UserAuthentication - NLA (0/1)
# SecurityLayer - Уровень безопасности (0/1/2)
# MinEncryptionLevel - Минимальный уровень шифрования (1-4)
# MaxInstanceCount - Максимальное число одновременных сессий (0 = unlimited)
# fLogonDisabled - Запрет входа (0/1)
# fPromptForPassword - Принудительный запрос пароля (0/1)
# fInheritMaxDisconnectionTime - Наследование времени отключения
# MaxDisconnectionTime - Таймаут отключённой сессии (мс)
# MaxConnectionTime - Максимальное время сессии (мс, 0=неограничено)
# MaxIdleTime - Таймаут простоя (мс, 0=неограничено)

# Изменение порта RDP (требует перезапуска TermService)
Set-ItemProperty -Path $WinStationPath -Name "PortNumber" -Value 3389 -Type DWord -Force

# После изменения порта обновить правило брандмауэра:
netsh advfirewall firewall set rule name="Remote Desktop - User Mode (TCP-In)" `
new localport=3389 protocol=TCP

6.8. Функция Enable-RDPShadowPermissions — автоматическая конфигурация

Функция Enable-RDPShadowPermissions является одним из главных практических инструментов комплекса. Она автоматически выполняет полный комплекс настроек, необходимых для работы теневого подключения, что избавляет администратора от необходимости вручную помнить и прописывать все реестровые пути.

Полная логика функции (реконструкция из исходного кода):

function Enable-RDPShadowPermissions {
[CmdletBinding()]
param(
[int]$ShadowMode = 2, # 2 = Full Control without consent (default)
[switch]$EnableNLA, # Включить NLA
[switch]$EnableFirewall, # Настроить брандмауэр
[switch]$WhatIf # Режим симуляции (не вносить изменения)
)

$Changes = [System.Collections.Generic.List[string]]::new()

# 1. Включение RDP (fDenyTSConnections = 0)
$RegPath = "HKLM:\SYSTEM\CurrentControlSet\Control\Terminal Server"
if (-not $WhatIf) {
Set-ItemProperty -Path $RegPath -Name "fDenyTSConnections" -Value 0 -Type DWord -Force
}
$Changes.Add("fDenyTSConnections = 0 (RDP enabled)")

# 2. Установка режима теневого подключения
if (-not $WhatIf) {
Set-ItemProperty -Path $RegPath -Name "Shadow" -Value $ShadowMode -Type DWord -Force
}
$Changes.Add("Shadow = $ShadowMode ($($ShadowModes[$ShadowMode]))")

# 3. Разрешение удалённого RPC
if (-not $WhatIf) {
Set-ItemProperty -Path $RegPath -Name "AllowRemoteRPC" -Value 1 -Type DWord -Force
}
$Changes.Add("AllowRemoteRPC = 1")

# 4. Настройка NLA (опционально)
$WinStationPath = "HKLM:\SYSTEM\CurrentControlSet\Control\Terminal Server\WinStations\RDP-Tcp"
if ($EnableNLA) {
if (-not $WhatIf) {
Set-ItemProperty -Path $WinStationPath -Name "UserAuthentication" -Value 1 -Type DWord -Force
}
$Changes.Add("UserAuthentication = 1 (NLA enabled)")
}

# 5. Настройка брандмауэра (опционально)
if ($EnableFirewall) {
if (-not $WhatIf) {
Enable-NetFirewallRule -DisplayGroup "Remote Desktop" -ErrorAction SilentlyContinue
# Fallback для старых систем без Enable-NetFirewallRule
netsh advfirewall firewall set rule group="remote desktop" new enable=Yes 2>$null
}
$Changes.Add("Firewall rules enabled for Remote Desktop")
}

# 6. Вывод итогового отчёта
Write-Host "`n[+] RDP Shadow Permissions configured:" -ForegroundColor Green
$Changes | ForEach-Object { Write-Host " ✓ $_" -ForegroundColor Cyan }

# 7. Проверка наличия GPO-override
$PolicyPath = "HKLM:\SOFTWARE\Policies\Microsoft\Windows NT\Terminal Services"
$PolicyShadow = (Get-ItemProperty $PolicyPath -Name "Shadow" -EA SilentlyContinue).Shadow
if ($null -ne $PolicyShadow) {
Write-Warning "GPO policy detected (Shadow=$PolicyShadow). It may override these settings on next gpupdate."
}
}

Вызов функции из командной строки:

# Базовая настройка: включить RDP и теневое подключение без запроса согласия
.\1st-Remote-Session-Manager-Pro.ps1 -EnableShadow

# С включением NLA и брандмауэра
.\1st-Remote-Session-Manager-Pro.ps1 -EnableShadow -EnableNLA -EnableFirewall

# Режим просмотра изменений без их применения
.\1st-Remote-Session-Manager-Pro.ps1 -EnableShadow -WhatIf

Глава 7. Параметры командной строки

7.1. Полная таблица параметров с псевдонимами и типами

Главный модуль 1st-Remote-Session-Manager-Pro.ps1 реализует богатый интерфейс командной строки через стандартный механизм [CmdletBinding()] с объявлением параметров через [Parameter()]-атрибуты. Ниже приведена полная таблица всех поддерживаемых параметров:

ПараметрПсевдонимТипОписание
-Sessions-S, -List[switch]Показать список активных сессий
-SessionId-Id, -SID[int]ID сессии для теневого подключения
-ViewOnly-View, -VO[switch]Режим только просмотра
-NoConsentPrompt-NCP, -Silent[switch]Без запроса согласия пользователя
-Disconnect-Disc, -D[switch]Отключить сессию (Disconnect)
-Logoff-Lo, -Kill[switch]Завершить сессию (Logoff)
-Message-Msg, -M[string]Отправить сообщение в сессию
-MessageTitle-MT[string]Заголовок сообщения
-Status-Stat, -Info[switch]Системный статус RDP
-EnableShadow-ES[switch]Настроить теневое подключение
-EnableNLA-NLA[switch]Включить NLA
-EnableFirewall-FW[switch]Настроить брандмауэр
-Version-V, -Ver[switch]Показать версию
-Help-H, -?[switch]Показать справку
-DebugMode-Debug, -DB[switch]Режим отладки
-Quiet-Q[switch]Тихий режим (минимальный вывод)
-Json-J[switch]Вывод в формате JSON
-Csv-C[switch]Вывод в формате CSV
-WithIP-IP[switch]Включить IP-адреса в вывод
-RdpPort-Port[int]Нестандартный порт RDP
-Update-U[switch]Обновить из GitHub
-WhatIf[switch]Симуляция без изменений
-Force-F[switch]Пропустить подтверждения

7.2. Группа основных параметров сессий

-Sessions (-S, -List)

Основная команда для получения списка активных сессий. Без дополнительных параметров выводит форматированную таблицу с колонками: ID, SessionName, UserName, State, Type, IsCurrent.

# Базовый вывод сессий
.\1st-Remote-Session-Manager-Pro.ps1 -Sessions

# Пример вывода:
# ╔════════════════════════════════════════════════════════════════╗
# ║ Active RDP Sessions — SERVERNAME ║
# ╠══╦══════════════╦══════════════╦════════════╦════════╦═════════╣
# ║ID║ SessionName ║ UserName ║ State ║ Type ║Current ║
# ╠══╬══════════════╬══════════════╬════════════╬════════╬═════════╣
# ║ 0║ Services ║ ║ Disc ║ System ║ ║
# ║ 1║ Console ║ DOMAIN\admin ║ Active ║ Console║ ◄ ║
# ║ 2║ RDP-Tcp#0 ║ DOMAIN\user1 ║ Active ║ RDP ║ ║
# ║ 3║ RDP-Tcp#1 ║ DOMAIN\user2 ║ Disc ║ RDP ║ ║
# ╚══╩══════════════╩══════════════╩════════════╩════════╩═════════╝
# Total: 4 sessions | Active: 2 | Disconnected: 1 | System: 1

# С IP-адресами
.\1st-Remote-Session-Manager-Pro.ps1 -Sessions -WithIP

# В JSON-формате (для интеграции с внешними системами)
.\1st-Remote-Session-Manager-Pro.ps1 -Sessions -Json

# В CSV-формате (для Excel)
.\1st-Remote-Session-Manager-Pro.ps1 -Sessions -Csv | Out-File sessions.csv -Encoding UTF8

-SessionId (-Id, -SID)

Указывает ID целевой сессии для теневого подключения. Используется совместно с -ViewOnly и -NoConsentPrompt. При отсутствии этого параметра вместе с -Sessions комплекс предлагает интерактивный выбор сессии из списка.

# Теневое подключение к сессии с ID=2 (интерактивный режим, с запросом согласия)
.\1st-Remote-Session-Manager-Pro.ps1 -SessionId 2

# Теневое подключение к сессии ID=2, только просмотр, без запроса согласия
.\1st-Remote-Session-Manager-Pro.ps1 -SessionId 2 -ViewOnly -NoConsentPrompt

# Отключение сессии с ID=3
.\1st-Remote-Session-Manager-Pro.ps1 -SessionId 3 -Disconnect

# Завершение сессии с ID=3 (полный logoff)
.\1st-Remote-Session-Manager-Pro.ps1 -SessionId 3 -Logoff

-ViewOnly (-View, -VO)

Флаг переключает режим теневого подключения в режим «только наблюдение» — администратор видит экран пользователя, но не может управлять мышью и клавиатурой. Технически реализовано через отсутствие флага /control в вызове mstsc /shadow.

# Наблюдение без возможности управления, без уведомления пользователя
.\1st-Remote-Session-Manager-Pro.ps1 -SessionId 2 -ViewOnly -NoConsentPrompt

-NoConsentPrompt (-NCP, -Silent)

Отключает механизм запроса согласия пользователя на теневое подключение. Требует, чтобы реестровый параметр Shadow был установлен в 2 (Full Control без согласия) или 4 (View Only без согласия). При использовании этого флага без соответствующей настройки реестра Windows проигнорирует флаг и всё равно покажет диалог пользователю.

Этическое замечание: использование -NoConsentPrompt без ведома пользователей должно быть обосновано в корпоративной политике безопасности и задокументировано. Многие регуляторные стандарты (GDPR, 152-ФЗ РФ) требуют уведомления сотрудников о возможности наблюдения за их рабочими сессиями.

7.3. Группа параметров управления и контроля

-Disconnect (-Disc, -D)

Переводит указанную сессию в состояние Disconnected, не завершая её. Все запущенные процессы, открытые файлы и несохранённые данные сохраняются в памяти сервера. Пользователь может переподключиться и продолжить работу. Технически реализовано через вызов утилиты rwinsta или WTS API функции WTSDisconnectSession.

# Отключить сессию ID=2
.\1st-Remote-Session-Manager-Pro.ps1 -SessionId 2 -Disconnect

# Без запроса подтверждения
.\1st-Remote-Session-Manager-Pro.ps1 -SessionId 2 -Disconnect -Force

# Внутренняя реализация (упрощённо):
# rwinsta.exe 2 /server:localhost
# или через WTS API:
# [Wtsapi32]::WTSDisconnectSession($ServerHandle, $SessionId, $false)

-Logoff (-Lo, -Kill)

Полностью завершает сессию с выполнением стандартной процедуры Windows logoff: завершение всех процессов пользователя, выгрузка профиля, запись события 4634 (Logoff) в журнал Security. После этого Session ID освобождается и может быть использован повторно. Несохранённые данные теряются — рекомендуется предварительно отправить сообщение пользователю через -Message.

# Завершение сессии с предварительным предупреждением
.\1st-Remote-Session-Manager-Pro.ps1 -SessionId 3 -Message "Сервер уходит на обслуживание через 5 минут. Сохраните работу."
Start-Sleep -Seconds 300
.\1st-Remote-Session-Manager-Pro.ps1 -SessionId 3 -Logoff -Force

-Message (-Msg, -M) и -MessageTitle (-MT)

Отправляет всплывающее сообщение в указанную сессию через стандартный механизм Windows msg.exe. Пользователь видит окно с текстом сообщения и кнопкой OK. Заголовок окна задаётся параметром -MessageTitle (по умолчанию: «System Message»).

# Отправка сообщения в сессию ID=2
.\1st-Remote-Session-Manager-Pro.ps1 -SessionId 2 `
-Message "The server will restart in 15 minutes. Please save your work." `
-MessageTitle "System Maintenance Alert"

# Сообщение всем сессиям (без указания -SessionId)
.\1st-Remote-Session-Manager-Pro.ps1 `
-Message "Planned maintenance in 30 minutes." `
-MessageTitle "Admin Alert"

# Внутренняя реализация:
# msg.exe 2 /server:localhost /time:60 "The server will restart..."
# или через WTS API:
# [Wtsapi32]::WTSSendMessage(...)

7.4. Группа параметров вывода и отладки

-Status (-Stat, -Info)

Выводит подробную сводку о текущем состоянии RDP-конфигурации сервера, включая: состояние службы TermService, значения всех ключевых реестровых параметров, наличие GPO-override, статус брандмауэра, порт RDP, режим NLA, режим Shadow.

.\1st-Remote-Session-Manager-Pro.ps1 -Status

# Пример вывода:
# ╔══════════════════════════════════════════════════════╗
# ║ RDP System Status — WIN-SRV2022 ║
# ╠══════════════════════════════════════════════════════╣
# ║ Service TermService : Running ║
# ║ RDP Enabled : YES (fDenyTSConnections=0) ║
# ║ RDP Port : 3389 ║
# ║ Shadow Mode : 2 (Full Control, no consent)║
# ║ NLA (UserAuth) : Enabled ║
# ║ Security Layer : 1 (Negotiate) ║
# ║ GPO Override Shadow : Not detected ║
# ║ GPO Override RDP : Not detected ║
# ║ Firewall RDP Rule : Enabled ║
# ║ PowerShell Version : 7.4.1 ║
# ╚══════════════════════════════════════════════════════╝

-DebugMode (-Debug, -DB)

Включает расширенный отладочный вывод, который показывает каждый шаг выполнения: какой метод используется для получения сессий (WTS API или qwinsta fallback), результаты промежуточных парсинговых функций, значения переменных, временны́е метки выполнения каждого блока. Незаменим при диагностике проблем с работой комплекса.

powershell.\1st-Remote-Session-Manager-Pro.ps1 -Sessions -DebugMode

# Пример расширенного вывода с -DebugMode:
# [DEBUG 11:42:01.123] PowerShell Version: 7.4.1
# [DEBUG 11:42:01.124] Script directory: C:\Tools\RDPManager
# [DEBUG 11:42:01.125] Loading module: qwinsta-en.ps1
# [DEBUG 11:42:01.189] Module loaded successfully
# [DEBUG 11:42:01.190] Attempting WTS API method...
# [DEBUG 11:42:01.195] WTS API available: True
# [DEBUG 11:42:01.210] WTS API returned 4 sessions
# [DEBUG 11:42:01.211] Session[0]: ID=0, Name=Services, State=4(Disc), User=
# [DEBUG 11:42:01.212] Session[1]: ID=1, Name=Console, State=0(Active), User=DOMAIN\admin
# ...

-Quiet (-Q)

Подавляет все декоративные элементы вывода (ASCII-боксы, цветовое оформление, статистику) и выводит только чистые данные. Полезен при использовании в конвейерах PowerShell или при захвате вывода для парсинга внешними инструментами.

# Получение только данных без оформления, для парсинга
$Output = .\1st-Remote-Session-Manager-Pro.ps1 -Sessions -Quiet

7.5. Параметры экспорта данных (JSON, CSV)

-Json (-J)

Переключает весь вывод данных в формат JSON. Структура JSON-объекта содержит метаданные запроса и массив объектов сессий:

.\1st-Remote-Session-Manager-Pro.ps1 -Sessions -Json

# Пример JSON-вывода:
# {
# "Timestamp": "2026-04-25T23:42:01.123+03:00",
# "ServerName": "WIN-SRV2022",
# "TotalSessions": 4,
# "Sessions": [
# {
# "SessionId": 1,
# "SessionName": "Console",
# "UserName": "DOMAIN\\admin",
# "State": "Active",
# "SessionType": "Console",
# "IsCurrent": true,
# "IpAddress": null
# },
# {
# "SessionId": 2,
# "SessionName": "RDP-Tcp#0",
# "UserName": "DOMAIN\\user1",
# "State": "Active",
# "SessionType": "RDP",
# "IsCurrent": false,
# "IpAddress": "192.168.1.105"
# }
# ]
# }

# Сохранение в файл
.\1st-Remote-Session-Manager-Pro.ps1 -Sessions -Json -WithIP |
Out-File "C:\Reports\sessions_$(Get-Date -Format 'yyyyMMdd_HHmmss').json" -Encoding UTF8

-Csv (-C)

Вывод данных в формате CSV с заголовком. Первая строка — имена полей, последующие строки — данные. Кодировка UTF-8 с BOM для корректного открытия в Microsoft Excel.

.\1st-Remote-Session-Manager-Pro.ps1 -Sessions -Csv

# SessionId,SessionName,UserName,State,SessionType,IsCurrent,IpAddress
# 0,Services,,Disc,System,False,
# 1,Console,DOMAIN\admin,Active,Console,True,
# 2,RDP-Tcp#0,DOMAIN\user1,Active,RDP,False,192.168.1.105

# Прямой импорт в Excel через pipeline
.\1st-Remote-Session-Manager-Pro.ps1 -Sessions -Csv -WithIP |
ConvertFrom-Csv |
Export-Csv "sessions.csv" -NoTypeInformation -Encoding UTF8BOM

7.6. Параметры корреляции IP-адресов

-WithIP (-IP)

Активирует модуль корреляции IP-адресов (qwinsta_IP_PS7.ps1) и добавляет колонку IpAddress к выводу. Это наиболее ресурсоёмкая операция в комплексе, так как требует опроса трёх источников данных: WTS API, netstat и журнал событий. На серверах с большим числом сессий (50+) может занять 3–10 секунд.

# Список сессий с IP-адресами
.\1st-Remote-Session-Manager-Pro.ps1 -Sessions -WithIP

# Пример расширенного вывода:
# ID SessionName UserName State IP Address Confidence
# -- ----------- -------- ----- ---------- ----------
# 2 RDP-Tcp#0 DOMAIN\user1 Active 192.168.1.105 High
# 3 RDP-Tcp#1 DOMAIN\user2 Active 10.0.0.42 Medium
# 4 RDP-Tcp#2 DOMAIN\user3 Disc 192.168.1.88 Low

-RdpPort (-Port)

Задаёт нестандартный порт RDP для корреляции через netstat. По умолчанию модуль ищет соединения на порт 3389. Если RDP-порт изменён в реестре, необходимо указать актуальный порт:

# Если RDP работает на порту 13389
.\1st-Remote-Session-Manager-Pro.ps1 -Sessions -WithIP -RdpPort 13389

7.7. Приоритет параметров при одновременном указании нескольких ключей

При одновременном указании нескольких параметров действуют следующие правила приоритета, реализованные через [Parameter(ParameterSetName)] в коде:

Приоритет (от высшего к низшему):
1. -Help — всегда выводит справку и завершается
2. -Version — всегда выводит версию и завершается
3. -Update — проверяет обновления и завершается
4. -Status — системный статус, не требует -SessionId
5. -EnableShadow — конфигурация, не требует -SessionId
6. -Logoff — требует -SessionId (или интерактивный выбор)
7. -Disconnect — требует -SessionId (или интерактивный выбор)
8. -Message — требует -SessionId (или отправка всем)
9. -SessionId — теневое подключение (если нет -Disconnect/-Logoff)
10. -Sessions — перечисление (по умолчанию, если ничего не указано)

Несовместимые комбинации параметров вызывают ошибку валидации:

# ОШИБКА: нельзя одновременно Logoff и Disconnect
.\1st-Remote-Session-Manager-Pro.ps1 -SessionId 2 -Logoff -Disconnect
# Error: Parameters -Logoff and -Disconnect cannot be used together

# ОШИБКА: нельзя ViewOnly и управляемое теневое подключение одновременно
# (ViewOnly уже подразумевает отсутствие управления — нет конфликта,
# но -Force с ViewOnly бессмысленно и вызывает предупреждение)

Глава 8. Функциональные возможности главного модуля

8.1. Автоматическая проверка привилегий администратора

Первое, что делает главный модуль при запуске — проверяет наличие прав локального администратора в текущем контексте выполнения. Это реализовано через стандартный механизм .NET Security Principal, доступный в любой версии PowerShell начиная с 2.0. Проверка выполняется до любых других операций и определяет дальнейшее поведение скрипта.

Внутренняя реализация функции проверки прав:

function Test-IsAdministrator {
<#
.SYNOPSIS
Checks if current PowerShell process runs with Administrator privileges
.OUTPUTS
[bool] True if running as Administrator, False otherwise
#>

try {
$CurrentPrincipal = [Security.Principal.WindowsPrincipal](
[Security.Principal.WindowsIdentity]::GetCurrent()
)
return $CurrentPrincipal.IsInRole(
[Security.Principal.WindowsBuiltInRole]::Administrator
)
} catch {
# Fallback: проверка через whoami /groups
$WhoamiOutput = whoami /groups 2>$null
return ($WhoamiOutput -match "S-1-16-12288") # High Mandatory Level SID
}
}

Функция использует два независимых метода: основной — через .NET WindowsPrincipal API, резервный — через анализ вывода whoami /groups с поиском SID S-1-16-12288 (обязательный уровень High Integrity, соответствующий правам администратора). Двойная реализация обеспечивает корректную работу даже в нестандартных средах выполнения, где .NET Security API может быть ограничен через AppLocker или WDAC (Windows Defender Application Control).

При обнаружении недостаточных привилегий поведение зависит от типа запрошенной операции:

  • Операции только чтения (-Sessions, -Status без изменений): работают с пониженными правами, но с ограниченным функционалом (например, журнал Security недоступен).
  • Операции записи (-EnableShadow, -Disconnect, -Logoff): требуют прав администратора и автоматически инициируют механизм самоподъёма UAC.

8.2. Самоподъём прав (Self-Elevation через UAC)

Самоподъём привилегий (Self-Elevation) — механизм, при котором скрипт обнаруживает отсутствие прав администратора и автоматически перезапускает себя с запросом повышения через UAC (User Account Control). Это стандартная практика для PowerShell-инструментов администрирования.

function Invoke-SelfElevation {
<#
.SYNOPSIS
Re-launches the current script with Administrator privileges via UAC
#>

param(
[string[]]$ScriptArgs # Аргументы для передачи перезапущенному процессу
)

# Сборка строки аргументов для перезапуска
$ScriptPath = $MyInvocation.ScriptName
$ArgumentsList = @(
"-ExecutionPolicy", "Bypass",
"-File", "`"$ScriptPath`""
)

# Добавление оригинальных аргументов пользователя
if ($ScriptArgs) {
$ArgumentsList += $ScriptArgs
}

Write-Host "[!] Administrator privileges required. Requesting elevation..." -ForegroundColor Yellow

try {
$StartParams = @{
FilePath = "powershell.exe"
ArgumentList = $ArgumentsList
Verb = "RunAs" # Ключевой параметр: запрос UAC
Wait = $true # Ждать завершения дочернего процесса
ErrorAction = "Stop"
}

# Для PowerShell 7: использовать pwsh.exe вместо powershell.exe
if ($PSVersionTable.PSVersion.Major -ge 7) {
$StartParams.FilePath = "pwsh.exe"
}

Start-Process @StartParams
exit 0 # Завершение текущего (неповышенного) процесса

} catch [System.ComponentModel.Win32Exception] {
# Пользователь нажал "Нет" в диалоге UAC
if ($_.Exception.NativeErrorCode -eq 1223) {
Write-Error "Elevation was cancelled by user. Operation requires Administrator privileges."
} else {
Write-Error "Elevation failed: $_"
}
exit 1
}
}

Важная деталь реализации: при перезапуске скрипт корректно передаёт все оригинальные параметры командной строки дочернему процессу. Это означает, что если пользователь запустил .\script.ps1 -SessionId 2 -ViewOnly, после UAC-подъёма те же параметры будут переданы и выполнены в контексте администратора. Пользователь видит один диалог UAC и прозрачно получает результат.

8.3. Проверка версии PowerShell и ExecutionPolicy

После проверки привилегий модуль выполняет ряд предварительных проверок среды выполнения:

function Test-Environment {
<#
.SYNOPSIS
Validates PowerShell version and execution policy before proceeding
.OUTPUTS
[hashtable] Environment validation results
#>


$Result = @{
PSVersionOK = $false
ExecutionPolicyOK = $false
OSVersionOK = $false
Warnings = [System.Collections.Generic.List[string]]::new()
}

# Проверка версии PowerShell
$PSVer = $PSVersionTable.PSVersion
if ($PSVer.Major -ge 5) {
$Result.PSVersionOK = $true
} elseif ($PSVer.Major -eq 4) {
$Result.PSVersionOK = $true
$Result.Warnings.Add("PowerShell 4.0 detected. Some features (classes, ForEach-Object -Parallel) unavailable.")
} else {
$Result.PSVersionOK = $false
$Result.Warnings.Add("PowerShell $($PSVer) is not supported. Minimum: PowerShell 4.0")
}

# Проверка политики выполнения
$Policy = Get-ExecutionPolicy -Scope Process
$AllowedPolicies = @("Unrestricted", "RemoteSigned", "Bypass", "AllSigned")
if ($Policy -in $AllowedPolicies) {
$Result.ExecutionPolicyOK = $true
} else {
$Result.ExecutionPolicyOK = $false
$Result.Warnings.Add("ExecutionPolicy '$Policy' may block script execution. Use -ExecutionPolicy Bypass")
}

# Проверка версии ОС (Windows 7 минимум)
$OSVer = [System.Environment]::OSVersion.Version
if ($OSVer.Major -ge 6 -and $OSVer.Minor -ge 1) {
$Result.OSVersionOK = $true
} else {
$Result.OSVersionOK = $false
$Result.Warnings.Add("Windows $($OSVer) is not supported. Minimum: Windows 7 / Server 2008 R2")
}

return $Result
}

8.4. Перечисление активных RDP-сессий (-Sessions)

Функция перечисления сессий является наиболее часто используемой в комплексе. Внутренняя реализация следует принципу «наилучший доступный метод»: сначала пробуется WTS API через подгруженный модуль qwinsta-en.ps1, при неудаче — fallback на прямой вызов системных утилит.

Полная цепочка вызовов при обработке параметра -Sessions:

1st-Remote-Session-Manager-Pro.ps1 [-Sessions]

├─► Get-ActiveSessionsPS7() ← qwinsta-en.ps1
│ │
│ ├─► Get-WtsSessionsEn() ← WTS API (основной путь)
│ │ │
│ │ ├─► [wtsapi32]::WTSEnumerateSessions()
│ │ └─► ConvertTo-WtsStateEn() для каждой сессии
│ │
│ └─► Get-QwinstaSessionsFallback() ← если WTS API упал
│ │
│ └─► Запуск qwinsta.exe с принудительной en-US культурой
│ └─► Parse-TerminalSessionLine() для каждой строки

├─► [если -WithIP] Get-SessionsWithIP() ← qwinsta_IP_PS7.ps1
│ │
│ └─► Match-SessionsWithIPs()
│ ├─► Get-ActiveRdpConnectionsViaNetstat()
│ ├─► Get-RdpConnectionsFromEventLogs()
│ └─► WTS API WTSQuerySessionInformation(WTSClientAddress)

└─► Format-SessionsOutput() / ConvertTo-Json / ConvertTo-Csv

Функция возвращает массив объектов [PSCustomObject] с унифицированным набором свойств, независимо от того, какой метод был использован для получения данных. Это гарантирует единообразный вывод во всех режимах работы.

8.5. Интерактивный выбор сессии для подключения

Когда пользователь запускает скрипт без параметра -SessionId (но с намерением выполнить операцию с конкретной сессией), комплекс переходит в интерактивный режим выбора:

function Invoke-InteractiveSessionSelect {
<#
.SYNOPSIS
Presents a numbered list of sessions and prompts user for selection
.OUTPUTS
[int] Selected Session ID, or -1 if cancelled
#>

param(
[object[]]$Sessions, # Массив объектов сессий
[string]$OperationName # Название операции для отображения в заголовке
)

# Фильтрация: исключаем системные сессии (Session 0, Listen)
$SelectableSessions = $Sessions | Where-Object {
$_.SessionId -gt 0 -and
$_.State -notin @("Listen", "Idle") -and
-not $_.IsCurrent # Нельзя подключиться к собственной сессии
}

if ($SelectableSessions.Count -eq 0) {
Write-Host "[!] No sessions available for $OperationName" -ForegroundColor Yellow
return -1
}

Write-Host "`nAvailable sessions for $OperationName`:" -ForegroundColor Cyan
Write-Host ("-" * 60)

$i = 1
foreach ($Session in $SelectableSessions) {
$IPInfo = if ($Session.IpAddress) { " | IP: $($Session.IpAddress)" } else { "" }
Write-Host " [$i] ID=$($Session.SessionId) | $($Session.UserName) | $($Session.State)$IPInfo"
$i++
}

Write-Host " [0] Cancel"
Write-Host ("-" * 60)

do {
$Input = Read-Host "Select session number"
$Choice = [int]::TryParse($Input, [ref]$null)
} while (-not ($Input -match '^\d+$') -or [int]$Input -gt $SelectableSessions.Count)

if ([int]$Input -eq 0) { return -1 }

return $SelectableSessions[[int]$Input - 1].SessionId
}

Интерактивный режим намеренно исключает из списка системную Session 0 (Services), слушатели (Listen), а также текущую сессию администратора (подключение к собственной сессии через теневое подключение технически невозможно на большинстве версий Windows).

8.6. Теневое подключение к сессии (-SessionId)

Ключевая операция комплекса — теневое подключение — реализована через вызов mstsc.exe с параметрами /shadow. Важная техническая деталь: mstsc с параметром /shadow должен выполняться в контексте пользовательской сессии с доступом к рабочему столу (Desktop), а не в контексте службы или Session 0.

function Connect-ShadowSession {
param(
[int]$SessionId,
[switch]$ViewOnly,
[switch]$NoConsentPrompt,
[string]$ServerName = $env:COMPUTERNAME
)

# Проверка существования целевой сессии
$TargetSession = Get-WtsSessionsEn | Where-Object { $_.SessionId -eq $SessionId }
if (-not $TargetSession) {
Write-Error "Session ID $SessionId not found."
return
}

# Проверка состояния: нельзя подключиться к Idle/Listen/Reset
if ($TargetSession.State -in @("Listen", "Idle", "Reset", "Down")) {
Write-Error "Cannot shadow session in state: $($TargetSession.State)"
return
}

# Формирование аргументов mstsc
$MstscArgs = @("/shadow:$SessionId", "/v:$ServerName")

if (-not $ViewOnly) {
$MstscArgs += "/control" # Интерактивный режим
}
if ($NoConsentPrompt) {
$MstscArgs += "/noConsentPrompt" # Без запроса согласия
}

Write-Host "[+] Connecting to session $SessionId ($($TargetSession.UserName))..." -ForegroundColor Green
Write-Host " Mode: $(if ($ViewOnly) {'View Only'} else {'Full Control'})"
Write-Host " Consent: $(if ($NoConsentPrompt) {'Not required'} else {'Required'})"

# Автопроверка и настройка реестра если Shadow не настроен
$ShadowRegPath = "HKLM:\SYSTEM\CurrentControlSet\Control\Terminal Server"
$CurrentShadow = (Get-ItemProperty $ShadowRegPath -Name "Shadow" -EA SilentlyContinue).Shadow
if ($CurrentShadow -eq 0) {
Write-Warning "Shadow is DISABLED in registry. Auto-enabling..."
Enable-RDPShadowPermissions -ShadowMode 2
}

# Запуск mstsc
try {
$Process = Start-Process mstsc.exe -ArgumentList $MstscArgs -PassThru
Write-Host "[+] Shadow session started (PID: $($Process.Id))" -ForegroundColor Green
} catch {
Write-Error "Failed to start shadow session: $_"
Write-Host "[!] Troubleshooting tips:" -ForegroundColor Yellow
Write-Host " 1. Run: .\script.ps1 -EnableShadow"
Write-Host " 2. Check GPO: Computer Config > Admin Templates > RDS > Remote Control"
Write-Host " 3. Verify session state: .\script.ps1 -Sessions"
}
}

8.7. Подключение в режиме «только просмотр» (-ViewOnly)

Режим -ViewOnly технически реализуется просто — отсутствием флага /control при вызове mstsc /shadow. Однако комплекс добавляет дополнительную логику: при использовании -ViewOnly проверяется соответствие реестрового параметра Shadow. Если установлен режим 2 (Full Control без согласия), а пользователь запросил ViewOnly — это не конфликт, mstsc просто не получит флаг /control. Если же установлен режим 3 или 4 (View Only в реестре), то флаг /control передаваться не будет вне зависимости от параметров запуска.

# ViewOnly + NoConsentPrompt — наиболее частый паттерн для скрытого мониторинга
.\1st-Remote-Session-Manager-Pro.ps1 -SessionId 2 -ViewOnly -NoConsentPrompt

# Внутренняя сборка команды:
# mstsc.exe /shadow:2 /v:WIN-SRV2022 /noConsentPrompt
# (без /control — ViewOnly)

8.8. Режим без запроса согласия пользователя (-NoConsentPrompt)

Флаг -NoConsentPrompt передаётся в mstsc как параметр /noConsentPrompt. Однако Windows имеет двухуровневую систему контроля согласия: параметр mstsc И реестровый параметр Shadow. Если в реестре установлен режим 1 (Full Control WITH consent) или 3 (View Only WITH consent), то даже при наличии /noConsentPrompt в команде mstsc, Windows всё равно покажет диалог согласия пользователю.

Комплекс обрабатывает эту ситуацию следующим образом:

# При использовании -NoConsentPrompt скрипт автоматически проверяет реестр
if ($NoConsentPrompt) {
$ShadowValue = (Get-ItemProperty "HKLM:\SYSTEM\CurrentControlSet\Control\Terminal Server" `
-Name "Shadow" -EA SilentlyContinue).Shadow

# Режимы 1 и 3 требуют согласия — предупреждаем пользователя
if ($ShadowValue -in @(1, 3)) {
Write-Warning "Registry Shadow mode = $ShadowValue (requires user consent)."
Write-Warning "The -NoConsentPrompt flag may be ignored by Windows."
Write-Warning "To fix: run .\script.ps1 -EnableShadow (sets Shadow=2)"

# Если не указан -Force, спрашиваем подтверждение
if (-not $Force) {
$Confirm = Read-Host "Auto-fix registry and proceed? [Y/N]"
if ($Confirm -eq 'Y') {
Enable-RDPShadowPermissions -ShadowMode 2
}
} else {
Enable-RDPShadowPermissions -ShadowMode 2
}
}
}

8.9. Отключение сессии (-Disconnect)

Операция Disconnect переводит сессию в состояние Disconnected без завершения. Все процессы продолжают работать в памяти сервера. Реализована через два параллельных механизма:

function Disconnect-RDPSession {
param(
[int]$SessionId,
[switch]$Force
)

$Session = Get-WtsSessionsEn | Where-Object { $_.SessionId -eq $SessionId }
if (-not $Session) {
Write-Error "Session $SessionId not found."
return $false
}

if (-not $Force) {
$Confirm = Read-Host "Disconnect session $SessionId ($($Session.UserName))? [Y/N]"
if ($Confirm -ne 'Y') {
Write-Host "Cancelled." -ForegroundColor Yellow
return $false
}
}

# Метод 1: WTS API (предпочтительный)
try {
# P/Invoke через Add-Type
$WtsCode = @'
using System;
using System.Runtime.InteropServices;
public class Wtsapi32 {
[DllImport("wtsapi32.dll", SetLastError=true)]
public static extern bool WTSDisconnectSession(
IntPtr hServer, int sessionId, bool bWait);
public static readonly IntPtr WTS_CURRENT_SERVER_HANDLE = IntPtr.Zero;
}
'@
if (-not ([System.Management.Automation.PSTypeName]'Wtsapi32').Type) {
Add-Type -TypeDefinition $WtsCode
}

$Result = [Wtsapi32]::WTSDisconnectSession(
[Wtsapi32]::WTS_CURRENT_SERVER_HANDLE,
$SessionId,
$true # Wait for completion
)

if ($Result) {
Write-Host "[+] Session $SessionId disconnected successfully." -ForegroundColor Green
return $true
}
} catch {
Write-Warning "WTS API disconnect failed, falling back to rwinsta..."
}

# Метод 2: Утилита rwinsta (fallback)
$RwinstaResult = & rwinsta.exe $SessionId /server:$env:COMPUTERNAME 2>&1
if ($LASTEXITCODE -eq 0) {
Write-Host "[+] Session $SessionId disconnected via rwinsta." -ForegroundColor Green
return $true
} else {
Write-Error "Failed to disconnect session $SessionId`: $RwinstaResult"
return $false
}
}

8.10. Завершение сессии (-Logoff)

Завершение сессии (Logoff) является более радикальной операцией, чем Disconnect. При logoff:

  1. Windows посылает сообщение WM_ENDSESSION всем окнам приложений в сессии
  2. Приложения получают время для сохранения данных (обычно 5–30 секунд)
  3. Принудительно завершаются все оставшиеся процессы сессии
  4. Выгружается профиль пользователя (HKCU в реестре)
  5. Session ID освобождается и становится доступным для новых подключений
  6. В журнал Security записывается событие 4634 (An account was logged off)
function Invoke-RDPLogoff {
param(
[int]$SessionId,
[switch]$Force,
[int]$GraceSeconds = 0 # Время предупреждения перед logoff
)

$Session = Get-WtsSessionsEn | Where-Object { $_.SessionId -eq $SessionId }
if (-not $Session) {
Write-Error "Session $SessionId not found."
return $false
}

# Предупреждение с обратным отсчётом (если задан GraceSeconds)
if ($GraceSeconds -gt 0) {
$MsgText = "Your session will be terminated in $GraceSeconds seconds. Save your work immediately."
& msg.exe $SessionId /server:$env:COMPUTERNAME /time:$GraceSeconds $MsgText 2>$null
Write-Host "[*] Warning sent to session $SessionId. Waiting $GraceSeconds seconds..." -ForegroundColor Yellow
Start-Sleep -Seconds $GraceSeconds
}

if (-not $Force) {
$Confirm = Read-Host "LOGOFF session $SessionId ($($Session.UserName))? Unsaved data will be LOST! [Y/N]"
if ($Confirm -ne 'Y') {
Write-Host "Cancelled." -ForegroundColor Yellow
return $false
}
}

# WTS API: WTSLogoffSession
try {
$WtsCode = @'
using System;
using System.Runtime.InteropServices;
public class Wtsapi32Logoff {
[DllImport("wtsapi32.dll", SetLastError=true)]
public static extern bool WTSLogoffSession(
IntPtr hServer, int sessionId, bool bWait);
public static readonly IntPtr WTS_CURRENT_SERVER_HANDLE = IntPtr.Zero;
}
'@
if (-not ([System.Management.Automation.PSTypeName]'Wtsapi32Logoff').Type) {
Add-Type -TypeDefinition $WtsCode
}

$Result = [Wtsapi32Logoff]::WTSLogoffSession(
[Wtsapi32Logoff]::WTS_CURRENT_SERVER_HANDLE,
$SessionId,
$true
)

if ($Result) {
Write-Host "[+] Session $SessionId logged off successfully." -ForegroundColor Green
return $true
}
} catch {
Write-Warning "WTS API logoff failed, falling back to logoff.exe..."
}

# Fallback: встроенная утилита logoff.exe
$LogoffResult = & logoff.exe $SessionId /server:$env:COMPUTERNAME 2>&1
if ($LASTEXITCODE -eq 0) {
Write-Host "[+] Session $SessionId logged off via logoff.exe." -ForegroundColor Green
return $true
} else {
Write-Error "Failed to logoff session $SessionId`: $LogoffResult"
return $false
}
}

8.11. Отправка сообщения пользователю (-Message)

Функция отправки сообщений использует стандартный механизм msg.exe (Terminal Services Message) с fallback на WTS API WTSSendMessage:

function Send-RDPMessage {
param(
[int]$SessionId,
[string]$MessageText,
[string]$MessageTitle = "System Message",
[int]$TimeoutSeconds = 60, # Автозакрытие через N секунд
[switch]$WaitForReply # Ждать нажатия OK пользователем
)

# Метод 1: msg.exe
$MsgArgs = @($SessionId, "/server:$env:COMPUTERNAME")
if ($TimeoutSeconds -gt 0) {
$MsgArgs += "/time:$TimeoutSeconds"
}
if ($WaitForReply) {
$MsgArgs += "/w"
}
$MsgArgs += $MessageText

$MsgResult = & msg.exe @MsgArgs 2>&1
if ($LASTEXITCODE -eq 0) {
Write-Host "[+] Message sent to session $SessionId" -ForegroundColor Green
return
}

# Fallback: WTS API WTSSendMessage
Write-Warning "msg.exe failed ($MsgResult), trying WTS API..."
# [WTS API P/Invoke код для WTSSendMessage]
}

Ограничения msg.exe: утилита может не работать, если служба Messenger отключена, или если целевая сессия находится в состоянии Disconnected (сессия существует, но клиент отключён — некуда отображать диалог). В состоянии Disconnected попытка отправки сообщения возвращает ошибку 1722 (RPC server unavailable), которую комплекс перехватывает и сообщает об этом явным сообщением.

8.12. Вывод системного статуса (-Status)

Параметр -Status реализует комплексную диагностику RDP-конфигурации сервера, собирая данные из нескольких источников: реестр, службы Windows, брандмауэр, текущие сессии.

# Вывод всех статусных данных одной командой
.\1st-Remote-Session-Manager-Pro.ps1 -Status

# Расширенный статус с IP-адресами активных сессий
.\1st-Remote-Session-Manager-Pro.ps1 -Status -WithIP

# Статус в JSON для мониторинговых систем
.\1st-Remote-Session-Manager-Pro.ps1 -Status -Json

Данные, собираемые функцией -Status:

  • Состояние служб: TermService, SessionEnv, UmRdpService
  • Реестровые параметры: fDenyTSConnections, Shadow, UserAuthentication, SecurityLayer, PortNumber
  • Наличие GPO-override в ветке Policies
  • Статус правил брандмауэра Windows (через Get-NetFirewallRule или netsh)
  • Версия ОС и PowerShell
  • Количество активных сессий (краткая сводка)
  • Максимальный лимит сессий (MaxInstanceCount)

8.13. Отображение версии (-Version)

# Вывод версии — всегда работает, не требует привилегий
.\1st-Remote-Session-Manager-Pro.ps1 -Version

# Внутренняя реализация через константу в заголовке файла:
# $SCRIPT_VERSION = "4.2"
# $SCRIPT_AUTHOR = "Mikhail Deynekin"
# $SCRIPT_SITE = "deynekin.com"
# $SCRIPT_DATE = "2026-01-15"

8.14. Режим отладки (-DebugMode)

-DebugMode устанавливает глобальный флаг $Global:DebugEnabled = $true, который активирует расширенный вывод через функцию Write-DebugLog во всех функциях комплекса. Каждое отладочное сообщение содержит временну́ю метку с миллисекундами, имя функции-источника и уровень важности.

# Структура отладочного сообщения:
# [DEBUG HH:mm:ss.fff] [FunctionName] Level: Message text

# Уровни отладки:
# INFO - информационные сообщения о ходе выполнения
# WARN - предупреждения о нестандартных ситуациях
# ERROR - ошибки с возможностью продолжения
# VERBOSE - детальные данные (значения переменных, API-ответы)
# TIMING - временны́е метки для анализа производительности

8.15. Тихий режим работы (-Quiet)

В режиме -Quiet отключаются: ASCII-боксы заголовков, цветовое оформление, строки статистики, приветственный баннер. Вывод представляет собой чистые табличные данные, пригодные для захвата и обработки в pipeline. Используется для интеграции с внешними системами мониторинга, где декоративные элементы нежелательны.


Глава 9. Модуль qwinsta-en.ps1 — движок перечисления сессий

9.1. Проблема локализации вывода qwinsta в русскоязычных Windows

Стандартная утилита qwinsta.exe (Query WINdows STAtion) является частью Windows Terminal Services и предназначена для отображения информации об активных сессиях. Утилита существует во всех версиях Windows начиная с NT 4.0 и является стандартным инструментом администратора. Однако у неё есть принципиальный изъян с точки зрения автоматизации: вывод зависит от языкового пакета операционной системы.

На русскоязычном Windows Server вывод qwinsta выглядит так:

 ИМЯ СЕАНСА         ПОЛЬЗОВАТЕЛЬ        ИД   СТАТУС  ТИП      УСТРОЙСТВО
services 0 Откл
>console Администратор 1 Актив
rdp-tcp#0 user1 2 Актив rdpwd
rdp-tcp#1 user2 3 Откл rdpwd
rdp-tcp 65536 Прос

На английском Windows Server тот же вывод:

text SESSIONNAME         USERNAME            ID   STATE   TYPE     DEVICE
 services                                 0   Disc
>console              Administrator        1   Active
 rdp-tcp#0            user1               2   Active  rdpwd
 rdp-tcp#1            user2               3   Disc    rdpwd
 rdp-tcp                                  65536  Listen

Любой скрипт, который парсит столбец STATE с ожиданием английских значений (Active, Disc, Listen), сломается на русскоязычной системе. Модуль qwinsta-en.ps1 решает эту проблему раз и навсегда, исключая зависимость от локали.

9.2. Архитектура модуля: два пути получения данных

Модуль строится на принципе двойного пути с автоматическим выбором:

qwinsta-en.ps1

├─ Путь 1: WTS API (основной)
│ ├─ Доступен если: Windows + права администратора + wtsapi32.dll
│ ├─ Метод: P/Invoke через Add-Type / [DllImport]
│ ├─ Результат: строго типизированные структуры WTS_SESSION_INFO
│ └─ Локаленезависим: состояния возвращаются как числовые константы

└─ Путь 2: qwinsta Fallback (резервный)
├─ Активируется: если WTS API недоступен или вернул пустой результат
├─ Метод: запуск qwinsta.exe с Culture=en-US
├─ Результат: текстовые строки, парсинг по позициям колонок
└─ Частично локаленезависим: культура en-US + нормализация словаря

9.3. WTS API (Windows Terminal Services API) — нативный путь

Windows Terminal Services API — набор функций Win32 API, экспортируемых wtsapi32.dll. Для вызова этих функций из PowerShell используется механизм P/Invoke (Platform Invocation Services) через Add-Type с C#-определением сигнатур функций.

# Полное определение P/Invoke типов для WTS API
$WtsApiCode = @'
using System;
using System.Runtime.InteropServices;
using System.Collections.Generic;

public class WtsApi {
// Константы
public const int WTS_CURRENT_SERVER = 0;
public const int ERROR_NO_MORE_ITEMS = 259;

// Перечисление состояний сессий (WTS_CONNECTSTATE_CLASS)
public enum WTS_CONNECTSTATE_CLASS {
WTSActive = 0, // Активная сессия
WTSConnected = 1, // Подключена (не активна)
WTSConnectQuery = 2, // Устанавливается соединение
WTSShadow = 3, // Теневая сессия
WTSDisconnected = 4, // Отключена (сессия существует)
WTSIdle = 5, // Ожидание подключения
WTSListen = 6, // Слушатель RDP
WTSReset = 7, // Сброс
WTSDown = 8, // Недоступна
WTSInit = 9 // Инициализация
}

// Информационные классы для WTSQuerySessionInformation
public enum WTS_INFO_CLASS {
WTSInitialProgram = 0,
WTSApplicationName = 1,
WTSWorkingDirectory = 2,
WTSOEMId = 3,
WTSSessionId = 4,
WTSUserName = 5,
WTSWinStationName = 6,
WTSDomainName = 7,
WTSConnectState = 8,
WTSClientBuildNumber = 9,
WTSClientName = 10,
WTSClientDirectory = 11,
WTSClientProductId = 12,
WTSClientHardwareId = 13,
WTSClientAddress = 14, // IP-адрес клиента
WTSClientDisplay = 15,
WTSClientProtocolType= 16,
WTSIdleTime = 17,
WTSLogonTime = 18,
WTSIncomingBytes = 19,
WTSOutgoingBytes = 20,
WTSIncomingFrames = 21,
WTSOutgoingFrames = 22,
WTSClientInfo = 23,
WTSSessionInfo = 24
}

// Структура WTS_SESSION_INFO
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
public struct WTS_SESSION_INFO {
public int SessionId;
public string pWinStationName;
public WTS_CONNECTSTATE_CLASS State;
}

// Импорт функций wtsapi32.dll
[DllImport("wtsapi32.dll", SetLastError=true, CharSet=CharSet.Unicode)]
public static extern bool WTSEnumerateSessions(
IntPtr hServer,
int Reserved,
int Version,
out IntPtr ppSessionInfo,
out int pCount
);

[DllImport("wtsapi32.dll", SetLastError=true, CharSet=CharSet.Unicode)]
public static extern bool WTSQuerySessionInformation(
IntPtr hServer,
int SessionId,
WTS_INFO_CLASS WTSInfoClass,
out IntPtr ppBuffer,
out int pBytesReturned
);

[DllImport("wtsapi32.dll")]
public static extern void WTSFreeMemory(IntPtr pMemory);
}
'@

# Регистрация типа (выполняется один раз при загрузке модуля)
if (-not ([System.Management.Automation.PSTypeName]'WtsApi').Type) {
Add-Type -TypeDefinition $WtsApiCode -ErrorAction Stop
}

9.4. Fallback через qwinsta с колоночным парсингом

Резервный метод активируется автоматически при недоступности WTS API. Ключевая техника — принудительная установка культуры en-US для процесса PowerShell перед вызовом qwinsta:

function Get-QwinstaSessionsFallback {
<#
.SYNOPSIS
Gets session list via qwinsta.exe with forced en-US culture
#>


# Сохранение текущей культуры
$OriginalCulture = [System.Threading.Thread]::CurrentThread.CurrentCulture
$OriginalUICulture = [System.Threading.Thread]::CurrentThread.CurrentUICulture

try {
# Принудительная установка en-US культуры
$EnUsCulture = [System.Globalization.CultureInfo]::new("en-US")
[System.Threading.Thread]::CurrentThread.CurrentCulture = $EnUsCulture
[System.Threading.Thread]::CurrentThread.CurrentUICulture = $EnUsCulture

# Вызов qwinsta с захватом вывода
# Используем cmd /c для правильной обработки кодировки
$RawOutput = & cmd.exe /c "chcp 437 > nul && qwinsta 2>&1"

# Парсинг вывода
$Sessions = Parse-QwinstaOutput -RawOutput $RawOutput

return $Sessions

} finally {
# Восстановление оригинальной культуры — ВСЕГДА
[System.Threading.Thread]::CurrentThread.CurrentCulture = $OriginalCulture
[System.Threading.Thread]::CurrentThread.CurrentUICulture = $OriginalUICulture
}
}

Техника chcp 437 перед вызовом qwinsta переключает кодовую страницу консоли на стандартную OEM ASCII, что предотвращает проблемы с кодировкой при захвате вывода в PowerShell. После вызова кодовая страница восстанавливается.

9.5. Функция Get-WtsSessionsEn — главная точка входа модуля

function Get-WtsSessionsEn {
<#
.SYNOPSIS
Returns normalized session list using best available method
.OUTPUTS
[PSCustomObject[]] Array of session objects with standardized properties
#>

param(
[string]$ServerName = $null, # null = локальный сервер
[switch]$IncludeSystem, # Включить Session 0 и слушатели
[switch]$IncludeDisconnected, # Включить отключённые сессии
[switch]$ActiveOnly # Только активные сессии
)

$Sessions = $null

# Попытка 1: WTS API
try {
$Sessions = Get-WtsSessionsViaApi -ServerName $ServerName
Write-DebugLog "WTS API returned $($Sessions.Count) sessions" "INFO"
} catch {
Write-DebugLog "WTS API failed: $_" "WARN"
$Sessions = $null
}

# Попытка 2: qwinsta fallback
if (-not $Sessions -or $Sessions.Count -eq 0) {
Write-DebugLog "Falling back to qwinsta method" "INFO"
try {
$Sessions = Get-QwinstaSessionsFallback
} catch {
Write-Error "All session enumeration methods failed: $_"
return @()
}
}

# Применение фильтров
if ($ActiveOnly) {
$Sessions = $Sessions | Where-Object { $_.State -eq "Active" }
}
if (-not $IncludeSystem) {
$Sessions = $Sessions | Where-Object {
$_.SessionId -gt 0 -and $_.State -ne "Listen"
}
}
if (-not $IncludeDisconnected) {
# По умолчанию включаем все, фильтрация опциональна
}

# Обогащение: пометить текущую сессию
$CurrentSessionId = [System.Diagnostics.Process]::GetCurrentProcess().SessionId
foreach ($Session in $Sessions) {
$Session.IsCurrent = ($Session.SessionId -eq $CurrentSessionId)
}

return $Sessions
}

9.6. Функция ConvertTo-WtsStateEn — нормализация состояний

function ConvertTo-WtsStateEn {
<#
.SYNOPSIS
Converts WTS state value (numeric or localized string) to English string
#>

param([object]$StateValue)

# Словарь для числовых кодов WTS API
$NumericMap = @{
0 = "Active"
1 = "Connected"
2 = "ConnectQuery"
3 = "Shadow"
4 = "Disc"
5 = "Idle"
6 = "Listen"
7 = "Reset"
8 = "Down"
9 = "Init"
}

# Словарь для локализованных строк (русский, немецкий, французский)
$LocalizedMap = @{
# Русский
"Актив" = "Active"
"Активный" = "Active"
"Откл" = "Disc"
"Отключен" = "Disc"
"Прос" = "Listen"
"Прослушивание" = "Listen"
"Тень" = "Shadow"
"Подключен" = "Connected"
# Немецкий
"Aktiv" = "Active"
"Getrennt" = "Disc"
"Überwacht" = "Shadow"
# Французский
"Actif" = "Active"
"Déconnecté" = "Disc"
# Английский (для единообразия)
"Active" = "Active"
"Disc" = "Disc"
"Listen" = "Listen"
"Shadow" = "Shadow"
"Connected" = "Connected"
"ConnectQuery" = "ConnectQuery"
"Idle" = "Idle"
"Reset" = "Reset"
"Down" = "Down"
"Init" = "Init"
}

# Обработка числового входа (от WTS API enum)
if ($StateValue -is [int] -or $StateValue -is [System.Enum]) {
$NumKey = [int]$StateValue
return $NumericMap[$NumKey] ?? "Unknown($NumKey)"
}

# Обработка строкового входа (от qwinsta)
$StateStr = ($StateValue -as [string]).Trim()

# Точное совпадение
if ($LocalizedMap.ContainsKey($StateStr)) {
return $LocalizedMap[$StateStr]
}

# Частичное совпадение (для усечённых строк qwinsta)
foreach ($Key in $LocalizedMap.Keys) {
if ($StateStr -like "$Key*" -or $Key -like "$StateStr*") {
return $LocalizedMap[$Key]
}
}

# Если ничего не нашли — возвращаем как есть
return $StateStr
}

9.7. Функция Get-WtsSessionType — определение типа сессии

function Get-WtsSessionType {
<#
.SYNOPSIS
Determines session type based on session name pattern
.OUTPUTS
[string] "Console" | "RDP" | "System" | "ICA" | "Unknown"
#>

param([string]$SessionName)

switch -Regex ($SessionName.ToLower()) {
'^console$' { return "Console" }
'^rdp-tcp' { return "RDP" }
'^services$' { return "System" }
'^ica-tcp' { return "ICA" } # Citrix
'^hdx' { return "HDX" } # Citrix HDX
'^vmware' { return "VMware" } # VMware Horizon
'^\d+$' { return "System" } # Числовые имена (Session 0)
default { return "Unknown" }
}
}

9.8. Функция Get-QwinstaSessionsFallback — резервный парсер

Парсер вывода qwinsta использует позиционный метод разбора строк. Это связано с тем, что qwinsta не имеет структурированного вывода (JSON/XML) и использует выравнивание пробелами с переменной шириной колонок. Анализ исходного кода показал, что модуль использует два подхода: фиксированные позиции колонок (для Windows 10/Server 2016+) и регулярные выражения (для более старых версий).

function Parse-QwinstaOutput {
param([string[]]$RawOutput)

$Sessions = [System.Collections.Generic.List[PSCustomObject]]::new()

# Пропускаем заголовок (первую строку)
$DataLines = $RawOutput | Select-Object -Skip 1

foreach ($Line in $DataLines) {
if ([string]::IsNullOrWhiteSpace($Line)) { continue }

# Определение: текущая ли сессия (строка начинается с '>')
$IsCurrent = $Line.StartsWith(">")

# Удаление маркера текущей сессии
if ($IsCurrent) { $Line = $Line.Substring(1) }

# Парсинг по фиксированным позициям (стандартная ширина qwinsta):
# SessionName: позиции 0-18 (19 символов)
# UserName: позиции 19-38 (20 символов)
# ID: позиции 39-44 (6 символов)
# State: позиции 45-53 (9 символов)
# Type: позиции 54-63 (10 символов)
# Device: позиции 64+

try {
$SessionName = if ($Line.Length -gt 18) { $Line.Substring(0, 19).Trim() } else { $Line.Trim() }
$UserName = if ($Line.Length -gt 38) { $Line.Substring(19, 20).Trim() } else { "" }
$IdStr = if ($Line.Length -gt 44) { $Line.Substring(39, 6).Trim() } else { "0" }
$StateStr = if ($Line.Length -gt 53) { $Line.Substring(45, 9).Trim() } else { "" }
$TypeStr = if ($Line.Length -gt 63) { $Line.Substring(54, 10).Trim() } else { "" }

$SessionId = 0
if (-not [int]::TryParse($IdStr, [ref]$SessionId)) {
# ID может быть в другой позиции — регулярный fallback
if ($Line -match '\s(\d+)\s') {
$SessionId = [int]$Matches[1]
}
}

$NormalizedState = ConvertTo-WtsStateEn -StateValue $StateStr
$SessionType = Get-WtsSessionType -SessionName $SessionName

$SessionObj = [PSCustomObject]@{
SessionId = $SessionId
SessionName = $SessionName
UserName = $UserName
State = $NormalizedState
SessionType = $SessionType
IsCurrent = $IsCurrent
IpAddress = $null # Заполняется модулем IP-корреляции
RawState = $StateStr # Оригинальное значение для отладки
}

$Sessions.Add($SessionObj)

} catch {
Write-DebugLog "Failed to parse qwinsta line: '$Line' - $_" "WARN"
continue
}
}

return $Sessions.ToArray()
}

9.9. Функция Format-WtsOutput — форматирование вывода

function Format-WtsOutput {
param(
[PSCustomObject[]]$Sessions,
[string]$Format = "Table", # Table | JSON | CSV | List | Object
[switch]$WithIP,
[switch]$Quiet
)

switch ($Format.ToUpper()) {
"TABLE" {
if (-not $Quiet) {
Write-BoxHeader "Active Sessions — $($env:COMPUTERNAME)"
}
# Вывод через Format-Table с кастомными выражениями
$Sessions | Format-Table @(
@{Name="ID"; Expression={$_.SessionId}; Width=4; Align="Right"},
@{Name="Session"; Expression={$_.SessionName}; Width=14},
@{Name="User"; Expression={$_.UserName}; Width=20},
@{Name="State"; Expression={
$s = $_.State
switch ($s) {
"Active" { "$([char]27)[32m$s$([char]27)[0m" } # Зелёный
"Disc" { "$([char]27)[33m$s$([char]27)[0m" } # Жёлтый
default { $s }
}
}; Width=12},
@{Name="Type"; Expression={$_.SessionType}; Width=9},
if ($WithIP) {
@{Name="IP Address"; Expression={$_.IpAddress ?? "-"}; Width=16}
},
@{Name=""; Expression={if ($_.IsCurrent) {"◄"} else {""}}}
) -AutoSize
}
"JSON" {
$Sessions | ConvertTo-Json -Depth 5
}
"CSV" {
$Sessions | ConvertTo-Csv -NoTypeInformation
}
"LIST" {
$Sessions | Format-List *
}
"OBJECT" {
return $Sessions # Возвращаем объекты без форматирования
}
}
}

9.10. Поддерживаемые форматы вывода

Модуль поддерживает пять форматов вывода, соответствующих различным сценариям использования:

ФорматПараметрНазначениеПример использования
Table(по умолчанию)Интерактивная консольПовседневное администрирование
JSON-JsonREST API, интеграцияZabbix external check, скрипты
CSV-CsvExcel, БДОтчёты руководству
List-ListДетальный просмотрДиагностика конкретной сессии
Object(pipeline)PowerShell pipelineWhere-Object, Select-Object

9.11. Функция Get-ActiveSessionsPS7 — агрегированный объект состояния

Get-ActiveSessionsPS7 является главной агрегирующей функцией модуля, которую вызывает главный скрипт. Она возвращает не просто массив сессий, а полный объект состояния, включающий метаданные:

function Get-ActiveSessionsPS7 {
param(
[switch]$WithIP,
[switch]$IncludeSystem,
[int]$RdpPort = 3389
)

$StartTime = Get-Date

$Result = [PSCustomObject]@{
ServerName = $env:COMPUTERNAME
Timestamp = $StartTime
Sessions = @()
TotalCount = 0
ActiveCount = 0
DisconnectedCount = 0
SystemCount = 0
CollectionMethod = "Unknown"
CollectionTimeMs = 0
HasIPData = $false
PSVersion = $PSVersionTable.PSVersion.ToString()
}

# Получение сессий через лучший доступный метод
$Sessions = Get-WtsSessionsEn -IncludeSystem:$IncludeSystem

# Обогащение IP-данными если запрошено
if ($WithIP -and $Sessions.Count -gt 0) {
# Вызов функций из qwinsta_IP_PS7.ps1
$Sessions = Add-IpDataToSessions -Sessions $Sessions -RdpPort $RdpPort
$Result.HasIPData = $true
}

# Заполнение статистики
$Result.Sessions = $Sessions
$Result.TotalCount = $Sessions.Count
$Result.ActiveCount = ($Sessions | Where-Object State -eq "Active").Count
$Result.DisconnectedCount = ($Sessions | Where-Object State -eq "Disc").Count
$Result.SystemCount = ($Sessions | Where-Object SessionType -eq "System").Count
$Result.CollectionTimeMs = [int]((Get-Date) - $StartTime).TotalMilliseconds

return $Result
}

Глава 10. Модуль qwinsta_IP_PS7.ps1 — анализатор с корреляцией IP

10.1. Назначение и задачи модуля

Модуль qwinsta_IP_PS7.ps1 решает задачу, которая является одной из наиболее часто запрашиваемых в практике RDP-администрирования: определение IP-адреса клиента, подключённого к конкретной RDP-сессии. На первый взгляд эта задача кажется тривиальной, однако в реальности она требует сложной логики корреляции данных из нескольких независимых источников.

Почему это нетривиально? Потому что в Windows нет единого системного API, который бы одновременно возвращал и идентификатор сессии, и IP-адрес клиента в виде надёжной связанной пары. Различные источники данных хранят эту информацию в разных форматах, с разными идентификаторами и с разной степенью актуальности:

  • WTS API (WTSQuerySessionInformation с WTSClientAddress): возвращает IP только для активных сессий, не для отключённых (Disconnected). При этом для нестандартных конфигураций (VPN, NAT, терминальный концентратор) может возвращать внутренний адрес вместо реального IP клиента.
  • netstat (netstat -n): показывает активные TCP-соединения на порт RDP с IP-адресами. Однако не привязан к Session ID — нужна дополнительная корреляция по времени установки соединения.
  • Журнал событий Security: события 4624/4778 содержат и имя пользователя, и IP-адрес, и информацию о сессии, но журнал содержит исторические записи, и при нескольких входах одного пользователя нужно выбрать наиболее актуальную запись.

Модуль объединяет все три источника через алгоритм многоуровневой корреляции с системой оценки достоверности совпадений (Confidence Level).

10.2. Классы PowerShell: RdpSession, RdpConnection, RdpMatch, AnalysisResult

Модуль использует объектно-ориентированный подход через классы PowerShell (доступны с PS 5.0). Это позволяет строго типизировать данные, добавлять методы прямо к объектам данных и обеспечивать корректную сериализацию в JSON/XML.

Класс RdpSession

Представляет одну RDP-сессию на сервере:

class RdpSession {
[int] $SessionId
[string] $SessionName
[string] $UserName
[string] $Domain
[string] $State # Active, Disc, Listen, etc.
[string] $SessionType # RDP, Console, System
[bool] $IsCurrent
[string] $RawStateLine # Оригинальная строка qwinsta для отладки

# Время входа в сессию (из WTS API или журнала событий)
[nullable[datetime]] $LogonTime

# Время последней активности
[nullable[datetime]] $LastInputTime

# Метод получения данных
[string] $SourceMethod # "WtsApi" | "Qwinsta" | "EventLog"

# Конструктор из PSCustomObject (для совместимости с PS 5.1)
RdpSession([PSCustomObject]$obj) {
$this.SessionId = $obj.SessionId
$this.SessionName = $obj.SessionName
$this.UserName = $obj.UserName
$this.State = $obj.State
$this.SessionType = $obj.SessionType
$this.IsCurrent = $obj.IsCurrent
}

# Метод проверки активности
[bool] IsActive() {
return $this.State -in @("Active", "Connected", "Shadow")
}

# Метод получения полного имени пользователя
[string] GetFullUserName() {
if ($this.Domain -and $this.UserName) {
return "$($this.Domain)\$($this.UserName)"
}
return $this.UserName ?? ""
}

# Переопределение ToString для удобного вывода
[string] ToString() {
return "Session[$($this.SessionId)] $($this.GetFullUserName()) [$($this.State)]"
}
}

Класс RdpConnection

Представляет одно активное TCP-соединение к порту RDP (полученное из netstat или WTS API):

class RdpConnection {
[string] $LocalAddress # IP:Port сервера
[string] $RemoteAddress # IP:Port клиента (нас интересует этот)
[string] $RemoteIp # Только IP (без порта)
[int] $RemotePort # Порт на стороне клиента
[string] $State # ESTABLISHED, TIME_WAIT, etc.
[int] $OwningPid # PID процесса, владеющего соединением
[string] $ProcessName # Имя процесса (svchost, etc.)

# Время установки соединения (из netstat или WMI)
[nullable[datetime]] $ConnectionTime

# Источник данных
[string] $SourceMethod # "Netstat" | "WtsApi" | "EventLog"

# Метод извлечения чистого IP из строки RemoteAddress
[string] GetCleanRemoteIp() {
if ($this.RemoteIp) { return $this.RemoteIp }
# Парсинг из "192.168.1.105:54321"
if ($this.RemoteAddress -match '^(.+):(\d+)$') {
$this.RemoteIp = $Matches[1]
$this.RemotePort = [int]$Matches[2]
return $this.RemoteIp
}
return $this.RemoteAddress
}

[string] ToString() {
return "Conn[$($this.RemoteIp):$($this.RemotePort) → RDP]"
}
}

Класс RdpMatch

Представляет результат сопоставления сессии с IP-адресом:

class RdpMatch {
[RdpSession] $Session # Сессия
[RdpConnection] $Connection # Соединение (может быть null)
[string] $IpAddress # Итоговый IP-адрес (null если не найден)
[string] $Confidence # High | Medium | Low | None
[string] $MatchMethod # Метод, которым получено совпадение
[string] $MatchReason # Описание логики совпадения

# Вычисляемые свойства для удобства
[int] GetSessionId() { return $this.Session.SessionId }
[string] GetUserName() { return $this.Session.GetFullUserName() }
[string] GetState() { return $this.Session.State }

# Метод получения краткого описания
[string] GetSummary() {
$ip = $this.IpAddress ?? "N/A"
return "ID=$($this.GetSessionId()) User=$($this.GetUserName()) IP=$ip [$($this.Confidence)]"
}

[string] ToString() {
return $this.GetSummary()
}
}

Класс AnalysisResult

Агрегирующий класс, содержащий полный результат анализа:

class AnalysisResult {
[string] $ServerName
[datetime] $Timestamp
[RdpMatch[]] $Matches
[int] $TotalSessions
[int] $SessionsWithIP
[int] $SessionsWithoutIP
[int] $HighConfidenceCount
[int] $MediumConfidenceCount
[int] $LowConfidenceCount
[int] $AnalysisDurationMs
[string] $PSVersion
[hashtable] $SourceStats # Статистика по источникам данных

# Метод фильтрации по достоверности
[RdpMatch[]] GetByConfidence([string]$Level) {
return $this.Matches | Where-Object { $_.Confidence -eq $Level }
}

# Метод получения сессии по ID
[RdpMatch] GetBySessionId([int]$SessionId) {
return $this.Matches | Where-Object { $_.GetSessionId() -eq $SessionId } |
Select-Object -First 1
}

# Метод сводки
[string] GetSummary() {
return @"
Analysis Results for $($this.ServerName) at $($this.Timestamp):
Total Sessions : $($this.TotalSessions)
With IP : $($this.SessionsWithIP)
Without IP : $($this.SessionsWithoutIP)
High Confidence : $($this.HighConfidenceCount)
Medium Conf. : $($this.MediumConfidenceCount)
Low Confidence : $($this.LowConfidenceCount)
Duration : $($this.AnalysisDurationMs)ms
"@
}
}

10.3. Источники данных для корреляции IP-адресов

Перед погружением в алгоритмы корреляции важно понять характеристики каждого источника данных:

ИсточникАктуальностьНадёжность IPПривязка к Session IDОтключённые сессии
WTS API WTSClientAddressРеальное времяВысокаяПрямая
netstat (TCP connections)Реальное времяВысокаяКосвенная (через время/порт)
EventLog Security 4624Исторические данныеВысокаяЧерез LogonIdSessionId
EventLog Security 4778Исторические данныеВысокаяПрямое поле SessionId
EventLog System TermServiceИсторические данныеСредняяЧерез SessionId в описании

Ключевой вывод: только журнал событий позволяет получить IP для отключённых сессий (Disconnected). Для активных сессий WTS API и netstat дают более надёжный и актуальный результат.

10.4. Получение сессий через qwinsta (Get-RdpSessionsViaQwinsta)

function Get-RdpSessionsViaQwinsta {
<#
.SYNOPSIS
Gets RDP sessions using qwinsta with full error handling
and PS5.1/PS7 compatibility
#>

param(
[string] $ServerName = $env:COMPUTERNAME,
[switch] $IncludeAll # Включить системные и Listen-сессии
)

$Sessions = [System.Collections.Generic.List[RdpSession]]::new()

try {
# Принудительная культура en-US
$SavedCulture = [System.Threading.Thread]::CurrentThread.CurrentCulture
[System.Threading.Thread]::CurrentThread.CurrentCulture =
[System.Globalization.CultureInfo]::new("en-US")

# Для локального сервера — без параметра /server (быстрее)
$QwinstaArgs = if ($ServerName -ne $env:COMPUTERNAME) {
@("/server:$ServerName")
} else {
@()
}

# Захват вывода с принудительной кодовой страницей
$Output = & cmd.exe /c "chcp 437 >nul 2>&1 && qwinsta $($QwinstaArgs -join ' ') 2>&1"
$ExitCode = $LASTEXITCODE

# Восстановление культуры
[System.Threading.Thread]::CurrentThread.CurrentCulture = $SavedCulture

if ($ExitCode -ne 0) {
Write-DebugLog "qwinsta exited with code $ExitCode`: $($Output -join ' ')" "WARN"
return $Sessions.ToArray()
}

# Парсинг вывода
$HeaderLine = $Output[0]

# Определение позиций колонок по заголовку
$ColPositions = Get-QwinstaColumnPositions -HeaderLine $HeaderLine

foreach ($Line in ($Output | Select-Object -Skip 1)) {
if ([string]::IsNullOrWhiteSpace($Line)) { continue }

$IsCurrent = $Line[0] -eq '>'
$ParsedLine = $Line.TrimStart('>')

$SessionObj = [RdpSession]::new([PSCustomObject]@{
SessionId = [int]($ParsedLine.Substring($ColPositions.IdStart, 6).Trim())
SessionName = $ParsedLine.Substring($ColPositions.NameStart, $ColPositions.NameLen).Trim()
UserName = $ParsedLine.Substring($ColPositions.UserStart, $ColPositions.UserLen).Trim()
State = ConvertTo-WtsStateEn ($ParsedLine.Substring($ColPositions.StateStart, 9).Trim())
SessionType = "Unknown"
IsCurrent = $IsCurrent
})

$SessionObj.SessionType = Get-WtsSessionType $SessionObj.SessionName
$Sessions.Add($SessionObj)
}

} catch {
Write-DebugLog "Get-RdpSessionsViaQwinsta error: $_" "ERROR"
}

# Фильтрация системных сессий если не запрошено включение
if (-not $IncludeAll) {
return $Sessions | Where-Object {
$_.SessionId -gt 0 -and $_.State -ne "Listen"
}
}

return $Sessions.ToArray()
}

Функция Get-QwinstaColumnPositions анализирует строку заголовка qwinsta и динамически определяет позиции каждой колонки, что обеспечивает корректный парсинг на всех версиях Windows вне зависимости от ширины колонок.

10.5. Получение соединений через netstat (Get-ActiveRdpConnectionsViaNetstat)

function Get-ActiveRdpConnectionsViaNetstat {
<#
.SYNOPSIS
Gets active RDP TCP connections via netstat
Returns only ESTABLISHED connections to RDP port
#>

param(
[int] $RdpPort = 3389
)

$Connections = [System.Collections.Generic.List[RdpConnection]]::new()

try {
# Метод 1: Get-NetTCPConnection (PowerShell 4.0+, предпочтительный)
$TcpConnections = Get-NetTCPConnection -LocalPort $RdpPort `
-State Established -ErrorAction SilentlyContinue

if ($TcpConnections) {
foreach ($Conn in $TcpConnections) {
$RdpConn = [RdpConnection]::new()
$RdpConn.LocalAddress = "$($Conn.LocalAddress):$($Conn.LocalPort)"
$RdpConn.RemoteAddress = "$($Conn.RemoteAddress):$($Conn.RemotePort)"
$RdpConn.RemoteIp = $Conn.RemoteAddress
$RdpConn.RemotePort = $Conn.RemotePort
$RdpConn.State = $Conn.State.ToString()
$RdpConn.OwningPid = $Conn.OwningProcess
$RdpConn.SourceMethod = "Get-NetTCPConnection"

# Получение имени процесса
try {
$RdpConn.ProcessName = (Get-Process -Id $Conn.OwningProcess -EA SilentlyContinue).ProcessName
} catch { }

$Connections.Add($RdpConn)
}

Write-DebugLog "Get-NetTCPConnection returned $($Connections.Count) RDP connections" "INFO"
return $Connections.ToArray()
}

} catch {
Write-DebugLog "Get-NetTCPConnection failed: $_. Falling back to netstat.exe" "WARN"
}

# Метод 2: Парсинг netstat.exe (fallback для старых систем и Server Core)
try {
# netstat -n: числовые адреса, -o: PID процесса, -p TCP: только TCP
$NetstatOutput = & netstat.exe -nop TCP 2>&1

foreach ($Line in $NetstatOutput) {
# Паттерн строки netstat:
# " TCP 0.0.0.0:3389 192.168.1.105:54321 ESTABLISHED 1234"
if ($Line -match '^\s+TCP\s+(\S+):(\d+)\s+(\S+):(\d+)\s+(\S+)\s+(\d+)') {
$LocalPort = [int]$Matches[2]
$RemoteIp = $Matches[3]
$RemotePort = [int]$Matches[4]
$State = $Matches[5]
$Pid = [int]$Matches[6]

# Фильтр: только наш RDP-порт и только ESTABLISHED
if ($LocalPort -ne $RdpPort -or $State -ne "ESTABLISHED") { continue }
# Фильтр: исключаем loopback (127.0.0.1) — не реальные клиенты
if ($RemoteIp -eq "127.0.0.1" -or $RemoteIp -eq "::1") { continue }

$RdpConn = [RdpConnection]::new()
$RdpConn.RemoteIp = $RemoteIp
$RdpConn.RemotePort = $RemotePort
$RdpConn.State = $State
$RdpConn.OwningPid = $Pid
$RdpConn.SourceMethod = "netstat.exe"

$Connections.Add($RdpConn)
}
}

Write-DebugLog "netstat.exe returned $($Connections.Count) RDP connections" "INFO"

} catch {
Write-DebugLog "netstat.exe parsing failed: $_" "ERROR"
}

return $Connections.ToArray()
}

10.6. Извлечение данных из журнала событий Windows (Get-RdpConnectionsFromEventLogs)

Это наиболее сложная функция модуля с точки зрения работы с журналом событий Windows. Функция опрашивает несколько журналов и несколько ID событий, нормализуя данные в единый формат:

function Get-RdpConnectionsFromEventLogs {
<#
.SYNOPSIS
Extracts RDP connection data from Windows Event Logs
Uses Security, System, and Microsoft-Windows-TerminalServices logs
#>

param(
[int] $MaxEvents = 500, # Лимит событий для анализа
[datetime]$StartTime = (Get-Date).AddHours(-24), # Окно анализа
[switch] $IncludeFailedLogons # Включить события 4625
)

$EventConnections = [System.Collections.Generic.List[hashtable]]::new()

# ══════════════════════════════════════════════════════════════
# Блок 1: Журнал Security — основные события входа/выхода
# ══════════════════════════════════════════════════════════════

# Event 4624: An account was successfully logged on
# Event 4625: An account failed to log on
# Event 4778: A session was reconnected to a Window Station
# Event 4779: A session was disconnected from a Window Station

$SecurityEventIds = @(4624, 4778)
if ($IncludeFailedLogons) { $SecurityEventIds += 4625 }

try {
# Оптимизированный XPath-запрос вместо Get-WinEvent -FilterHashtable
# XPath работает на 3-5x быстрее для больших журналов
$XPathQuery = @"
*[System[(EventID=4624 or EventID=4778 or EventID=4779)
and TimeCreated[@SystemTime>='$($StartTime.ToUniversalTime().ToString("o"))']]]
"@

$SecurityEvents = Get-WinEvent -LogName "Security" `
-FilterXPath $XPathQuery `
-MaxEvents $MaxEvents `
-ErrorAction SilentlyContinue

foreach ($Event in $SecurityEvents) {
# Парсинг XML события для извлечения структурированных данных
$EventXml = [xml]$Event.ToXml()
$EventData = $EventXml.Event.EventData.Data

# Создание хэштаблицы из EventData для удобного доступа
$Data = @{}
foreach ($Item in $EventData) {
if ($Item.Name) {
$Data[$Item.Name] = $Item.'#text'
}
}

# Фильтр: только сетевые входы (LogonType=10 RemoteInteractive)
# и только если есть реальный IP (не "-" и не "::1")
$LogonType = $Data["LogonType"]
$IpAddress = $Data["IpAddress"]
$IpPort = $Data["IpPort"]

if ($Event.Id -eq 4624) {
# LogonType 10 = RemoteInteractive (RDP)
# LogonType 7 = Unlock (тоже бывает через RDP)
if ($LogonType -notin @("10", "7")) { continue }
}

# Фильтрация нерелевантных IP
if (-not $IpAddress -or
$IpAddress -in @("-", "::1", "127.0.0.1", "LOCAL") -or
$IpAddress -like "::ffff:127.*") { continue }

# Извлечение LogonId для последующей привязки к Session ID
$LogonId = $Data["TargetLogonId"] ?? $Data["LogonId"]
$UserName = $Data["TargetUserName"] ?? $Data["SubjectUserName"]
$DomainName = $Data["TargetDomainName"] ?? $Data["SubjectDomainName"]

# Для события 4778 — Session ID доступен напрямую
$SessionId = if ($Event.Id -eq 4778) {
try { [int]($Data["SessionId"] ?? $Data["Session"]) } catch { $null }
} else { $null }

$EventConnections.Add(@{
EventId = $Event.Id
TimeCreated = $Event.TimeCreated
IpAddress = $IpAddress -replace "^::ffff:", "" # Нормализация IPv6-mapped IPv4
IpPort = $IpPort
UserName = $UserName
Domain = $DomainName
LogonId = $LogonId
SessionId = $SessionId
Source = "Security/$($Event.Id)"
})
}

Write-DebugLog "Security log yielded $($EventConnections.Count) RDP-related events" "INFO"

} catch {
Write-DebugLog "Security log access failed: $_ (may require admin rights)" "WARN"
}

# ══════════════════════════════════════════════════════════════
# Блок 2: Microsoft-Windows-TerminalServices-LocalSessionManager
# ══════════════════════════════════════════════════════════════

# Event 21: Remote Desktop Services: Session logon succeeded
# Event 22: Remote Desktop Services: Shell start notification received
# Event 25: Remote Desktop Services: Session reconnection succeeded
# Event 41: Session reconnect to console

try {
$TSLogName = "Microsoft-Windows-TerminalServices-LocalSessionManager/Operational"
$TSEventIds = @(21, 22, 25, 41)

$TSEvents = Get-WinEvent -LogName $TSLogName `
-FilterXPath "*[System[TimeCreated[@SystemTime>='$($StartTime.ToUniversalTime().ToString("o"))']]]" `
-MaxEvents $MaxEvents `
-ErrorAction SilentlyContinue

foreach ($Event in ($TSEvents | Where-Object { $_.Id -in $TSEventIds })) {
$EventXml = [xml]$Event.ToXml()
$EventData = $EventXml.Event.UserData

# TS-события имеют другую структуру XML (UserData, не EventData)
$UserName = $EventData.EventXML.User
$SessionId = $EventData.EventXML.SessionID
$Address = $EventData.EventXML.Address

if ($Address -and $Address -ne "LOCAL" -and $Address -ne "") {
$EventConnections.Add(@{
EventId = $Event.Id
TimeCreated = $Event.TimeCreated
IpAddress = $Address -replace "^::ffff:", ""
IpPort = $null
UserName = $UserName -replace ".*\\", "" # Убираем домен
Domain = ($UserName -split "\\")[0]
SessionId = try { [int]$SessionId } catch { $null }
Source = "TermServicesLSM/$($Event.Id)"
})
}
}

Write-DebugLog "TermServices LSM log yielded additional entries" "INFO"

} catch {
Write-DebugLog "TermServices LSM log not accessible: $_" "WARN"
}

# ══════════════════════════════════════════════════════════════
# Блок 3: Microsoft-Windows-RemoteDesktopServices-RdpCoreTS
# ══════════════════════════════════════════════════════════════

try {
$RdpCoreLog = "Microsoft-Windows-RemoteDesktopServices-RdpCoreTS/Operational"
# Event 131: The server accepted a new TCP connection from client [IP:Port]
$RdpCoreEvents = Get-WinEvent -LogName $RdpCoreLog `
-FilterXPath "*[System[EventID=131 and TimeCreated[@SystemTime>='$($StartTime.ToUniversalTime().ToString("o"))']]]" `
-MaxEvents $MaxEvents `
-ErrorAction SilentlyContinue

foreach ($Event in $RdpCoreEvents) {
# Event 131 содержит IP в Message: "...from client 192.168.1.105:54321"
if ($Event.Message -match 'from client ([0-9a-f.:]+):(\d+)') {
$EventConnections.Add(@{
EventId = 131
TimeCreated = $Event.TimeCreated
IpAddress = $Matches[1] -replace "^::ffff:", ""
IpPort = [int]$Matches[2]
UserName = $null
SessionId = $null
Source = "RdpCoreTS/131"
})
}
}

} catch {
Write-DebugLog "RdpCoreTS log not accessible: $_" "WARN"
}

# Сортировка по времени (новейшие первыми — для выбора актуального IP)
return $EventConnections | Sort-Object { $_.TimeCreated } -Descending
}

10.7. Алгоритм сопоставления сессий с IP (Match-SessionsWithIPs)

Это центральная функция всего модуля — алгоритм корреляции, который принимает на вход списки сессий, соединений netstat и записей журнала событий, и производит оптимальное сопоставление:

function Match-SessionsWithIPs {
<#
.SYNOPSIS
Core correlation algorithm: matches RDP sessions to IP addresses
using multi-source data with confidence scoring
#>

param(
[RdpSession[]] $Sessions,
[RdpConnection[]] $NetstatConnections,
[hashtable[]] $EventLogData
)

$Matches = [System.Collections.Generic.List[RdpMatch]]::new()

foreach ($Session in $Sessions) {
$Match = [RdpMatch]::new()
$Match.Session = $Session
$Match.Confidence = "None"

# ══════════════════════════════════════════════
# Уровень 1: WTS API — наивысший приоритет
# Прямой запрос IP из WTS API для активных сессий
# ══════════════════════════════════════════════
if ($Session.IsActive()) {
try {
$WtsIp = Get-WtsClientIpForSession -SessionId $Session.SessionId
if ($WtsIp -and $WtsIp -ne "0.0.0.0" -and $WtsIp -ne "::") {
$Match.IpAddress = $WtsIp
$Match.Confidence = "High"
$Match.MatchMethod = "WTS_API_Direct"
$Match.MatchReason = "WTSQuerySessionInformation(WTSClientAddress) returned $WtsIp"
$Matches.Add($Match)
continue # Самый надёжный результат — идём к следующей сессии
}
} catch {
Write-DebugLog "WTS API IP query failed for session $($Session.SessionId): $_" "WARN"
}
}

# ══════════════════════════════════════════════
# Уровень 2: TermServices LocalSessionManager Event 25/21
# Прямая привязка IP к Session ID через события TS
# ══════════════════════════════════════════════
$TsEvents = $EventLogData | Where-Object {
$_.SessionId -eq $Session.SessionId -and
$_.Source -like "TermServicesLSM/*" -and
$_.IpAddress
} | Sort-Object TimeCreated -Descending

if ($TsEvents) {
$BestEvent = $TsEvents[0]
$Match.IpAddress = $BestEvent.IpAddress
$Match.Confidence = "High"
$Match.MatchMethod = "EventLog_TSSessionId"
$Match.MatchReason = "TS-LSM Event $($BestEvent.EventId) directly contains SessionId=$($Session.SessionId)"
$Matches.Add($Match)
continue
}

# ══════════════════════════════════════════════
# Уровень 3: Security Event 4778 (Session Reconnect)
# Содержит SessionId напрямую
# ══════════════════════════════════════════════
$Event4778 = $EventLogData | Where-Object {
$_.EventId -eq 4778 -and
$_.SessionId -eq $Session.SessionId -and
$_.IpAddress
} | Sort-Object TimeCreated -Descending | Select-Object -First 1

if ($Event4778) {
$Match.IpAddress = $Event4778.IpAddress
$Match.Confidence = "High"
$Match.MatchMethod = "EventLog_4778_SessionId"
$Match.MatchReason = "Security Event 4778 reconnect with matching SessionId"
$Matches.Add($Match)
continue
}

# ══════════════════════════════════════════════
# Уровень 4: Корреляция через UserName + netstat
# Для активных сессий: ищем соединение, которое
# не сопоставлено ни с одной другой сессией
# ══════════════════════════════════════════════
if ($Session.IsActive() -and $NetstatConnections.Count -gt 0) {
# Получаем список IP, уже использованных другими сессиями
$UsedIPs = $Matches | Where-Object IpAddress | Select-Object -ExpandProperty IpAddress

# Ищем незанятое соединение
$FreeConnections = $NetstatConnections | Where-Object {
$_.RemoteIp -notin $UsedIPs -and
$_.RemoteIp -ne "127.0.0.1" -and
$_.RemoteIp -ne "::1"
}

if ($FreeConnections.Count -eq 1) {
# Единственное незанятое соединение — высокая вероятность совпадения
$Match.IpAddress = $FreeConnections[0].RemoteIp
$Match.Connection = $FreeConnections[0]
$Match.Confidence = "Medium"
$Match.MatchMethod = "Netstat_Elimination"
$Match.MatchReason = "Only unmatched TCP connection to RDP port"
$Matches.Add($Match)
continue
}
}

# ══════════════════════════════════════════════
# Уровень 5: Security Event 4624 + UserName
# Корреляция по имени пользователя и времени входа
# ══════════════════════════════════════════════
if ($Session.UserName) {
$UserEvents = $EventLogData | Where-Object {
$_.EventId -eq 4624 -and
$_.UserName -and
$_.IpAddress -and
# Нечёткое сравнение UserName (с/без домена)
($_.UserName -eq $Session.UserName -or
"$($_.Domain)\$($_.UserName)" -eq $Session.UserName -or
$_.UserName -eq ($Session.UserName -replace ".*\\", ""))
} | Sort-Object TimeCreated -Descending

if ($UserEvents) {
$BestUserEvent = $UserEvents[0]
$Match.IpAddress = $BestUserEvent.IpAddress
$Match.Confidence = "Medium"
$Match.MatchMethod = "EventLog_4624_UserName"
$Match.MatchReason = "Security Event 4624 matched by username '$($Session.UserName)'"

# Дополнительная проверка: если в netstat есть соединение с этим IP
$NetstatMatch = $NetstatConnections | Where-Object { $_.RemoteIp -eq $BestUserEvent.IpAddress }
if ($NetstatMatch) {
$Match.Confidence = "High" # Подтверждено netstat
$Match.MatchReason += " + confirmed by netstat"
}

$Matches.Add($Match)
continue
}
}

# ══════════════════════════════════════════════
# Уровень 6: RdpCoreTS Event 131 + время
# Наименее надёжный метод — только по времени
# ══════════════════════════════════════════════
if ($Session.LogonTime) {
$CoreTSEvents = $EventLogData | Where-Object {
$_.Source -eq "RdpCoreTS/131" -and
$_.IpAddress -and
$_.TimeCreated -and
[Math]::Abs(($_.TimeCreated - $Session.LogonTime).TotalSeconds) -lt 30
} | Sort-Object TimeCreated -Descending

if ($CoreTSEvents) {
$Match.IpAddress = $CoreTSEvents[0].IpAddress
$Match.Confidence = "Low"
$Match.MatchMethod = "EventLog_131_TimeProximity"
$Match.MatchReason = "RdpCoreTS Event 131 within 30s of session logon time"
$Matches.Add($Match)
continue
}
}

# Сессия не сопоставлена ни с одним источником
$Match.Confidence = "None"
$Match.MatchMethod = "NoMatch"
$Match.MatchReason = "No IP data available from any source"
$Matches.Add($Match)
}

return $Matches.ToArray()
}

10.8. Уровни достоверности совпадений: High, Medium, Low, None

Система оценки достоверности (Confidence Level) является ключевым механизмом, позволяющим пользователю понимать, насколько точен полученный IP-адрес:

УровеньЗначениеМетоды достиженияРекомендация
HighIP получен из надёжного источника с прямой привязкой к Session IDWTS API Direct, TS-LSM Event с SessionId, 4778+netstatПолностью доверять
MediumIP получен косвенным путём с высокой вероятностью совпаденияElimination через netstat, 4624+UserNameДоверять с оговорками
LowIP получен по косвенным признакам (время)RdpCoreTS Event 131 по времениИспользовать осторожно
NoneIP не удалось определить ни одним методомIP отображается как N/A

В выводе уровень достоверности отображается цветом: High — зелёный, Medium — жёлтый, Low — красный, None — серый. При экспорте в JSON/CSV уровень включается как отдельное поле Confidence.

10.9. Команды модуля: Analyze, Export, Monitor, Summary, GetSessions, GetConnections

Модуль qwinsta_IP_PS7.ps1 при прямом вызове поддерживает несколько режимов работы через параметр -Command:

# Полный анализ с выводом таблицы
.\qwinsta_IP_PS7.ps1 -Command Analyze

# Анализ и экспорт в файл
.\qwinsta_IP_PS7.ps1 -Command Export -OutputFormat JSON -OutputFile "C:\Reports\rdp.json"

# Мониторинг в реальном времени (обновление каждые 30 секунд)
.\qwinsta_IP_PS7.ps1 -Command Monitor -IntervalSeconds 30

# Краткая сводка без деталей
.\qwinsta_IP_PS7.ps1 -Command Summary

# Только список сессий (без IP-анализа)
.\qwinsta_IP_PS7.ps1 -Command GetSessions

# Только список активных TCP-соединений к RDP
.\qwinsta_IP_PS7.ps1 -Command GetConnections

Внутренняя реализация диспетчера команд:

# Главный блок диспетчеризации команд (в конце файла qwinsta_IP_PS7.ps1)
param(
[string] $Command = "Analyze",
[string] $OutputFormat = "Table",
[string] $OutputFile = $null,
[int] $IntervalSeconds = 30,
[int] $RdpPort = 3389,
[switch] $IncludeSystem,
[switch] $NoColor,
[switch] $Quiet,
[int] $MaxLogEvents = 500
)

switch ($Command.ToLower()) {
"analyze" {
$Result = Invoke-RdpAnalysis -RdpPort $RdpPort -MaxLogEvents $MaxLogEvents
Format-AnalysisOutput -Result $Result -Format $OutputFormat -NoColor:$NoColor
}
"export" {
$Result = Invoke-RdpAnalysis -RdpPort $RdpPort
Export-AnalysisResult -Result $Result -Format $OutputFormat -OutputFile $OutputFile
}
"monitor" {
Start-RdpMonitor -IntervalSeconds $IntervalSeconds -RdpPort $RdpPort -NoColor:$NoColor
}
"summary" {
$Result = Invoke-RdpAnalysis -RdpPort $RdpPort
Write-Host $Result.GetSummary()
}
"getsessions" {
$Sessions = Get-RdpSessionsViaQwinsta -IncludeAll:$IncludeSystem
$Sessions | Format-Table -AutoSize
}
"getconnections" {
$Connections = Get-ActiveRdpConnectionsViaNetstat -RdpPort $RdpPort
$Connections | Format-Table RemoteIp, RemotePort, State, ProcessName -AutoSize
}
default {
Write-Error "Unknown command: $Command. Valid: Analyze, Export, Monitor, Summary, GetSessions, GetConnections"
}
}

10.10. Форматы вывода: Table, JSON, CSV, XML, HTML, Markdown, YAML

Модуль поддерживает семь форматов вывода — значительно больше, чем большинство аналогичных инструментов:

function Export-AnalysisResult {
param(
[AnalysisResult] $Result,
[string] $Format = "JSON",
[string] $OutputFile = $null
)

$OutputContent = switch ($Format.ToUpper()) {
"JSON" {
$Result | ConvertTo-Json -Depth 10
}
"CSV" {
$Result.Matches | ForEach-Object {
[PSCustomObject]@{
SessionId = $_.GetSessionId()
UserName = $_.GetUserName()
State = $_.GetState()
IpAddress = $_.IpAddress
Confidence = $_.Confidence
MatchMethod = $_.MatchMethod
}
} | ConvertTo-Csv -NoTypeInformation
}
"XML" {
$Result | ConvertTo-Xml -Depth 5 -As String
}
"HTML" {
# Генерация HTML-отчёта через встроенный шаблон
New-RdpHtmlReport -Result $Result
}
"MARKDOWN" {
# Генерация Markdown-таблицы
$Lines = @("# RDP Session Analysis — $($Result.ServerName)")
$Lines += "**Generated:** $($Result.Timestamp)"
$Lines += ""
$Lines += "| ID | User | State | IP Address | Confidence |"
$Lines += "|---|---|---|---|---|"
foreach ($M in $Result.Matches) {
$Lines += "| $($M.GetSessionId()) | $($M.GetUserName()) | $($M.GetState()) | $($M.IpAddress ?? 'N/A') | $($M.Confidence) |"
}
$Lines -join "`n"
}
"YAML" {
# Простая YAML-сериализация (без внешних зависимостей)
$Lines = @("server: $($Result.ServerName)", "timestamp: $($Result.Timestamp.ToString('o'))", "sessions:")
foreach ($M in $Result.Matches) {
$Lines += " - id: $($M.GetSessionId())"
$Lines += " user: '$($M.GetUserName())'"
$Lines += " state: $($M.GetState())"
$Lines += " ip: $(if ($M.IpAddress) {$M.IpAddress} else {'null'})"
$Lines += " confidence: $($M.Confidence)"
}
$Lines -join "`n"
}
"TABLE" {
# Консольный вывод — не сохраняется в файл
Format-AnalysisTable -Result $Result
return
}
}

if ($OutputFile) {
$OutputContent | Out-File -FilePath $OutputFile -Encoding UTF8 -Force
Write-Host "[+] Report saved to: $OutputFile" -ForegroundColor Green
} else {
Write-Output $OutputContent
}
}

10.11. Мониторинг в реальном времени (Monitor команда)

Команда Monitor реализует непрерывный цикл опроса состояния RDP-сессий с визуализацией изменений:

function Start-RdpMonitor {
param(
[int] $IntervalSeconds = 30,
[int] $RdpPort = 3389,
[switch] $NoColor,
[int] $MaxIterations = 0 # 0 = бесконечно
)

$Iteration = 0
$PreviousResult = $null

Write-Host "Starting RDP Monitor (interval: ${IntervalSeconds}s). Press Ctrl+C to stop." -ForegroundColor Cyan

try {
while ($MaxIterations -eq 0 -or $Iteration -lt $MaxIterations) {
$Iteration++
$CurrentResult = Invoke-RdpAnalysis -RdpPort $RdpPort -MaxLogEvents 100

# Очистка экрана (если поддерживается терминалом)
if ($Host.UI.RawUI.WindowSize.Width -gt 0) {
Clear-Host
}

# Заголовок мониторинга
Write-Host "═══ RDP Monitor — $($env:COMPUTERNAME) — Iteration #$Iteration ═══" -ForegroundColor Cyan
Write-Host "Last update: $(Get-Date -Format 'HH:mm:ss') | Next in: ${IntervalSeconds}s" -ForegroundColor Gray
Write-Host ""

# Вывод текущего состояния
Format-AnalysisTable -Result $CurrentResult -NoColor:$NoColor

# Вычисление и вывод изменений
if ($PreviousResult) {
$Changes = Compare-RdpResults -Previous $PreviousResult -Current $CurrentResult

if ($Changes.Count -gt 0) {
Write-Host "`n─── Changes detected ───" -ForegroundColor Yellow
foreach ($Change in $Changes) {
$Color = switch ($Change.Type) {
"NewSession" { "Green" }
"SessionClosed" { "Red" }
"StateChanged" { "Yellow" }
"IpChanged" { "Cyan" }
}
Write-Host " $($Change.Description)" -ForegroundColor $Color
}
}
}

$PreviousResult = $CurrentResult

# Ожидание следующей итерации с возможностью прерывания
$WaitEnd = (Get-Date).AddSeconds($IntervalSeconds)
while ((Get-Date) -lt $WaitEnd) {
Start-Sleep -Milliseconds 500
}
}
} catch [System.Management.Automation.PipelineStoppedException] {
# Ctrl+C — корректное завершение
Write-Host "`n[*] Monitor stopped by user." -ForegroundColor Yellow
}
}

10.12. HTML-отчёт и шаблон rdp-report-template.html

Функция New-RdpHtmlReport генерирует самодостаточный HTML-файл с встроенными стилями CSS и таблицей сессий:

function New-RdpHtmlReport {
param([AnalysisResult]$Result)

$ConfidenceColors = @{
"High" = "#28a745"
"Medium" = "#ffc107"
"Low" = "#dc3545"
"None" = "#6c757d"
}

$RowsHtml = ($Result.Matches | ForEach-Object {
$BgColor = if ($_.GetState() -eq "Active") { "#f8fff8" } else { "#fff8f8" }
$BadgeColor = $ConfidenceColors[$_.Confidence]
@"
<tr style="background: $BgColor">
<td>$($_.GetSessionId())</td>
<td>$($_.GetUserName())</td>
<td>$($_.GetState())</td>
<td>$(if ($_.IpAddress) {$_.IpAddress} else {'<em>N/A</em>'})</td>
<td><span style="background:$BadgeColor;color:white;padding:2px 8px;
border-radius:3px;font-size:0.85em">$($_.Confidence)</span></td>
<td style="font-size:0.8em;color:#666">$($_.MatchMethod)</td>
</tr>
"@
}) -join "`n"

return @"
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>RDP Session Report — $($Result.ServerName)</title>
<style>
body { font-family: 'Segoe UI', sans-serif; margin: 20px; background: #f5f5f5; }
h1 { color: #333; border-bottom: 2px solid #0078d4; padding-bottom: 10px; }
.meta { color: #666; margin-bottom: 20px; }
.stats { display: flex; gap: 15px; margin-bottom: 20px; }
.stat-card { background: white; padding: 15px 20px; border-radius: 8px;
box-shadow: 0 1px 4px rgba(0,0,0,0.1); text-align: center; }
.stat-card .num { font-size: 2em; font-weight: bold; color: #0078d4; }
table { width: 100%; border-collapse: collapse; background: white;
border-radius: 8px; overflow: hidden; box-shadow: 0 1px 4px rgba(0,0,0,0.1); }
th { background: #0078d4; color: white; padding: 12px 15px; text-align: left; }
td { padding: 10px 15px; border-bottom: 1px solid #eee; }
tr:hover { background: #f0f7ff !important; }
</style>
</head>
<body>
<h1>RDP Session Analysis Report</h1>
<div class="meta">
<strong>Server:</strong> $($Result.ServerName) &nbsp;|&nbsp;
<strong>Generated:</strong> $($Result.Timestamp.ToString("yyyy-MM-dd HH:mm:ss")) &nbsp;|&nbsp;
<strong>Duration:</strong> $($Result.AnalysisDurationMs)ms
</div>
<div class="stats">
<div class="stat-card"><div class="num">$($Result.TotalSessions)</div>Total</div>
<div class="stat-card"><div class="num">$($Result.SessionsWithIP)</div>With IP</div>
<div class="stat-card"><div class="num">$($Result.HighConfidenceCount)</div>High Conf.</div>
<div class="stat-card"><div class="num">$($Result.SessionsWithoutIP)</div>No IP</div>
</div>
<table>
<thead>
<tr><th>ID</th><th>User</th><th>State</th><th>IP Address</th><th>Confidence</th><th>Method</th></tr>
</thead>
<tbody>
$RowsHtml
</tbody>
</table>
<p style="color:#999;font-size:0.85em;margin-top:20px">
Generated by 1st Remote Session Manager Pro | deynekin.com
</p>
</body>
</html>
"@
}

Генерация HTML-отчёта и немедленное открытие в браузере:

# Генерация и открытие HTML-отчёта
$ReportPath = "C:\Reports\rdp-report-$(Get-Date -Format 'yyyyMMdd_HHmmss').html"
.\qwinsta_IP_PS7.ps1 -Command Export -OutputFormat HTML -OutputFile $ReportPath
Start-Process $ReportPath # Открыть в браузере по умолчанию

Глава 11. Механизм обнаружения и парсинга сессий

11.1. Цепочка fallback-методов обнаружения сессий

Надёжность обнаружения сессий — фундаментальное требование к комплексу, поскольку все последующие операции (теневое подключение, отключение, logoff) невозможны без корректного списка сессий. Комплекс реализует четырёхуровневую цепочку резервных методов, каждый из которых активируется только при недоступности предыдущего.

Уровень 1: WTS API (wtsapi32.dll P/Invoke)
│ Преимущества: локаленезависим, структурированные данные, максимальная полнота
│ Ограничения: требует прав администратора, недоступен в ограниченных средах

▼ (если недоступен или вернул пустой список)
Уровень 2: Get-RDSession (командлет PowerShell)
│ Преимущества: встроен в PS 3.0+, работает без P/Invoke
│ Ограничения: доступен только если установлена роль RSAT-RDS-Tools

▼ (если недоступен)
Уровень 3: qwinsta.exe с принудительной культурой en-US
│ Преимущества: доступен на всех Windows, не требует дополнительных компонентов
│ Ограничения: зависит от кодировки вывода, требует парсинга текста

▼ (если недоступен или вывод некорректен)
Уровень 4: WMI/CIM (Win32_TSSession)
Преимущества: работает через WMI Remoting, альтернативный транспорт
Ограничения: медленнее, требует запущенного WMI-сервиса, данные менее полные

Переключение между уровнями происходит автоматически — пользователь не видит и не управляет этим процессом. В режиме -DebugMode каждое переключение явно логируется с указанием причины.

11.2. Функция Get-RawTerminalSessionText — получение сырого текста

Функция Get-RawTerminalSessionText является низкоуровневым примитивом, используемым резервными методами. Она получает «сырой» текстовый вывод из различных источников и возвращает его в виде массива строк для последующего парсинга:

function Get-RawTerminalSessionText {
<#
.SYNOPSIS
Low-level function: gets raw text output from qwinsta or fallback sources
Handles encoding, culture and subprocess isolation correctly
#>

param(
[string] $ServerName = $env:COMPUTERNAME,
[switch] $ForceEnglish, # Принудительная en-US культура
[int] $TimeoutSeconds = 10
)

$RawLines = @()

# ── Метод A: qwinsta через cmd.exe с кодовой страницей 437 ──────────
try {
# chcp 437 — OEM US ASCII, гарантирует корректную ASCII-кодировку вывода qwinsta
# Временная установка OutputEncoding для корректного захвата
$OldEncoding = [Console]::OutputEncoding
[Console]::OutputEncoding = [System.Text.Encoding]::GetEncoding(437)

if ($ForceEnglish) {
# Установка культуры перед вызовом
$env:LANG = "en_US.UTF-8"
$env:LANGUAGE = "en"
}

$QwinstaCmd = if ($ServerName -ne $env:COMPUTERNAME) {
"qwinsta /server:$ServerName"
} else {
"qwinsta"
}

# Запуск через cmd.exe для корректной обработки кодовых страниц
$StartInfo = [System.Diagnostics.ProcessStartInfo]::new()
$StartInfo.FileName = "cmd.exe"
$StartInfo.Arguments = "/c chcp 437 >nul 2>&1 && $QwinstaCmd 2>&1"
$StartInfo.UseShellExecute = $false
$StartInfo.RedirectStandardOutput = $true
$StartInfo.RedirectStandardError = $true
$StartInfo.CreateNoWindow = $true
$StartInfo.StandardOutputEncoding = [System.Text.Encoding]::GetEncoding(437)

$Process = [System.Diagnostics.Process]::new()
$Process.StartInfo = $StartInfo
$Process.Start() | Out-Null

# Асинхронное чтение с таймаутом
$OutputTask = $Process.StandardOutput.ReadToEndAsync()
$Process.WaitForExit($TimeoutSeconds * 1000) | Out-Null

if (-not $Process.HasExited) {
$Process.Kill()
throw "qwinsta timed out after $TimeoutSeconds seconds"
}

$RawOutput = $OutputTask.Result
$RawLines = $RawOutput -split "`r?`n" | Where-Object { $_ -match '\S' }

[Console]::OutputEncoding = $OldEncoding

Write-DebugLog "Get-RawTerminalSessionText: got $($RawLines.Count) lines via cmd/qwinsta" "VERBOSE"
return $RawLines

} catch {
Write-DebugLog "cmd/qwinsta method failed: $_" "WARN"
[Console]::OutputEncoding = $OldEncoding
}

# ── Метод B: Прямой вызов qwinsta через PowerShell ──────────────────
try {
# Сохранение и установка культуры
$SavedCulture = [Threading.Thread]::CurrentThread.CurrentCulture
[Threading.Thread]::CurrentThread.CurrentCulture = [Globalization.CultureInfo]"en-US"

if ($ServerName -ne $env:COMPUTERNAME) {
$RawLines = (& qwinsta.exe /server:$ServerName 2>&1) -split "`r?`n"
} else {
$RawLines = (& qwinsta.exe 2>&1) -split "`r?`n"
}

[Threading.Thread]::CurrentThread.CurrentCulture = $SavedCulture

$RawLines = $RawLines | Where-Object { $_ -match '\S' }
Write-DebugLog "Get-RawTerminalSessionText: got $($RawLines.Count) lines via direct PS call" "VERBOSE"
return $RawLines

} catch {
Write-DebugLog "Direct qwinsta call failed: $_" "WARN"
}

# ── Метод C: WMI как последний резерв ──────────────────────────────
try {
$WmiSessions = Get-CimInstance -ClassName "Win32_TSSessionDirectory" `
-ComputerName $ServerName -ErrorAction Stop 2>$null

# Конвертация WMI-объектов в псевдо-qwinsta строки
# для единообразного прохождения через существующий парсер
$FakeLine = " {0,-19}{1,-20}{2,6} {3,-9}" -f "SESSIONNAME","USERNAME","ID","STATE"
$RawLines = @($FakeLine)
foreach ($S in $WmiSessions) {
$StateName = switch ($S.SessionState) {
0 { "Active" }; 4 { "Disc" }; 6 { "Listen" }; default { "Unknown" }
}
$RawLines += " {0,-19}{1,-20}{2,6} {3,-9}" -f $S.WinStationName, $S.UserName, $S.SessionId, $StateName
}
return $RawLines

} catch {
Write-DebugLog "WMI fallback also failed: $_" "ERROR"
return @()
}
}

11.3. Функция Parse-TerminalSessionLine — разбор строки вывода

Парсинг строк qwinsta является нетривиальной задачей по нескольким причинам: ширина колонок не фиксирована в спецификации и варьируется между версиями Windows; маркер текущей сессии > смещает все позиции на один символ; в имени сессии и имени пользователя могут быть пробелы; поле ID может быть пустым для системных сессий.

function Parse-TerminalSessionLine {
<#
.SYNOPSIS
Parses a single qwinsta output line into a structured object
Handles variable column widths and current-session marker
#>

param(
[string] $Line,
[int[]] $ColPositions, # [NameStart, UserStart, IdStart, StateStart, TypeStart]
[string] $HeaderLine # Для динамического определения позиций
)

if ([string]::IsNullOrWhiteSpace($Line)) { return $null }

# Определение: является ли строка текущей сессией
$IsCurrent = ($Line[0] -eq '>')

# Нормализация: убираем маркер '>' заменяем пробелом для сохранения позиций
if ($IsCurrent) {
$Line = " " + $Line.Substring(1)
}

# ── Метод 1: Динамические позиции по заголовку ──────────────────────
if ($HeaderLine -and $ColPositions.Count -eq 0) {
$ColPositions = Get-QwinstaColumnPositions -HeaderLine $HeaderLine
}

# ── Метод 2: Статические позиции (стандарт Windows 10+) ─────────────
# SessionName: 0-18 (19 chars)
# UserName: 19-38 (20 chars)
# ID: 39-44 (6 chars)
# State: 45-53 (9 chars)
# Type: 54-63 (10 chars)
# Device: 64+

$SafeGet = {
param([string]$Src, [int]$Start, [int]$Length)
if ($Src.Length -gt $Start) {
$ActualLen = [Math]::Min($Length, $Src.Length - $Start)
return $Src.Substring($Start, $ActualLen).Trim()
}
return ""
}

if ($ColPositions -and $ColPositions.Count -ge 4) {
# Динамические позиции из заголовка
$SessionName = & $SafeGet $Line $ColPositions[0] ($ColPositions[1] - $ColPositions[0])
$UserName = & $SafeGet $Line $ColPositions[1] ($ColPositions[2] - $ColPositions[1])
$IdStr = & $SafeGet $Line $ColPositions[2] ($ColPositions[3] - $ColPositions[2])
$StateStr = & $SafeGet $Line $ColPositions[3] 9
} else {
# Статические позиции
$SessionName = & $SafeGet $Line 0 19
$UserName = & $SafeGet $Line 19 20
$IdStr = & $SafeGet $Line 39 6
$StateStr = & $SafeGet $Line 45 9
}

# Парсинг Session ID
$SessionId = 0
if (-not [int]::TryParse($IdStr, [ref]$SessionId)) {
# Запасной вариант: regex для поиска числа в строке
if ($Line -match '\b(\d{1,6})\b') {
$SessionId = [int]$Matches[1]
}
}

# Нормализация состояния
$NormalizedState = ConvertTo-WtsStateEn -StateValue $StateStr

return [PSCustomObject]@{
SessionId = $SessionId
SessionName = $SessionName
UserName = $UserName
State = $NormalizedState
RawState = $StateStr
SessionType = Get-WtsSessionType -SessionName $SessionName
IsCurrent = $IsCurrent
IpAddress = $null
ParseMethod = "ColumnParser"
}
}

11.4. Нормализация имён сессий и пользователей

После парсинга сырых данных выполняется нормализация полученных значений — приведение к единому формату для корректной обработки в последующих функциях:

function Normalize-SessionData {
param([PSCustomObject]$SessionObj)

# ── Нормализация SessionName ─────────────────────────────────────────
# Убираем ведущие/замыкающие пробелы и непечатаемые символы
$SessionObj.SessionName = ($SessionObj.SessionName -replace '[\x00-\x1F\x7F]', '').Trim()

# Нормализация числового формата имён сессий (для Session 0)
# qwinsta может вывести "0" вместо "services" на некоторых конфигурациях
if ($SessionObj.SessionName -match '^\d+$' -and $SessionObj.SessionId -eq 0) {
$SessionObj.SessionName = "services"
}

# ── Нормализация UserName ────────────────────────────────────────────
$SessionObj.UserName = ($SessionObj.UserName -replace '[\x00-\x1F\x7F]', '').Trim()

# Разделение Domain\User если получен полный UPN
if ($SessionObj.UserName -match '^(.+)\\(.+)$') {
# Оставляем как есть — домен и пользователь разделены обратным слешем
# GetFullUserName() обработает корректно
} elseif ($SessionObj.UserName -match '^(.+)@(.+)$') {
# UPN формат user@domain.com → оставляем как есть для последующей обработки
}

# Замена пустого UserName для системных сессий
if ([string]::IsNullOrEmpty($SessionObj.UserName)) {
if ($SessionObj.SessionName -eq "services" -or $SessionObj.SessionId -eq 0) {
$SessionObj.UserName = "[System]"
} elseif ($SessionObj.State -eq "Listen") {
$SessionObj.UserName = "[Listener]"
}
}

return $SessionObj
}

11.5. Нормализация состояний (мультиязыковая поддержка)

Основной код пытается заставить вывод таких команд, как qwinsta выдавать результат на английском, но если вдруг что-то не сработало, то используется словарь нормализации состояний охватывает все известные локализации Windows, с которыми может столкнуться администратор в многонациональной корпоративной среде:

# Полный многоязыковый словарь нормализации состояний qwinsta
# Ключ: строка из вывода qwinsta (может быть усечённой)
# Значение: нормализованная английская строка

$Global:WtsStateNormalizationMap = @{

# ── Английский (EN) ──────────────────────────────────────────────────
"Active" = "Active"
"Activ" = "Active" # Усечённая версия
"Act" = "Active"
"Disc" = "Disc"
"Disconnected" = "Disc"
"Listen" = "Listen"
"Listening" = "Listen"
"Connected" = "Connected"
"Shadow" = "Shadow"
"Idle" = "Idle"
"Down" = "Down"
"Init" = "Init"
"Reset" = "Reset"
"ConnectQuery" = "ConnectQuery"

# ── Русский (RU) ─────────────────────────────────────────────────────
"Актив" = "Active"
"Активный" = "Active"
"Активно" = "Active"
"Откл" = "Disc"
"Отключен" = "Disc"
"Отключено" = "Disc"
"Отключён" = "Disc"
"Прос" = "Listen"
"Прослушивание" = "Listen"
"Подключен" = "Connected"
"Подключено" = "Connected"
"Тень" = "Shadow"
"Простой" = "Idle"
"Недоступ" = "Down"
"Инициализ" = "Init"
"Сброс" = "Reset"

# ── Немецкий (DE) ────────────────────────────────────────────────────
"Aktiv" = "Active"
"Getrennt" = "Disc"
"Hören" = "Listen"
"Verbunden" = "Connected"
"Überwacht" = "Shadow"
"Leerlauf" = "Idle"

# ── Французский (FR) ─────────────────────────────────────────────────
"Actif" = "Active"
"Déconnecté" = "Disc"
"Décon" = "Disc"
"Écoute" = "Listen"
"Connecté" = "Connected"
"Ombre" = "Shadow"

# ── Испанский (ES) ───────────────────────────────────────────────────
"Activo" = "Active"
"Desconectado" = "Disc"
"Descon" = "Disc"
"Escuchar" = "Listen"
"Conectado" = "Connected"

# ── Итальянский (IT) ─────────────────────────────────────────────────
"Attivo" = "Active"
"Disconnesso" = "Disc"
"In ascolto" = "Listen"
"Connesso" = "Connected"

# ── Польский (PL) ────────────────────────────────────────────────────
"Aktywny" = "Active"
"Rozłączony" = "Disc"
"Nasłuchuje" = "Listen"
"Połączony" = "Connected"

# ── Японский (JA) — транслитерация ───────────────────────────────────
"アクティブ" = "Active"
"切断" = "Disc"
"リッスン" = "Listen"

# ── Китайский упрощённый (ZH-CN) ─────────────────────────────────────
"活动" = "Active"
"已断开" = "Disc"
"侦听" = "Listen"
}

11.6. Функции Test-RdpDisplaySession и Get-SessionTypeName

function Test-RdpDisplaySession {
<#
.SYNOPSIS
Determines if a session is an actual user session (not system/listener)
Used to filter sessions eligible for shadow connection
#>

param([PSCustomObject]$Session)

# Исключаем Session 0 (Services)
if ($Session.SessionId -eq 0) { return $false }

# Исключаем слушатели
if ($Session.State -eq "Listen") { return $false }

# Исключаем сессии в процессе инициализации/сброса
if ($Session.State -in @("Init", "Reset", "Down")) { return $false }

# Исключаем системное имя сессии без пользователя
if ($Session.SessionName -eq "services" -and
[string]::IsNullOrEmpty($Session.UserName)) { return $false }

# Сессия является подходящей для теневого подключения
return $true
}

function Get-SessionTypeName {
<#
.SYNOPSIS
Returns human-readable session type name with icon
#>

param([string]$SessionType)

return switch ($SessionType) {
"Console" { "💻 Console" }
"RDP" { "🖥️ RDP" }
"System" { "⚙️ System" }
"ICA" { "🔶 Citrix" }
"HDX" { "🔷 HDX" }
"VMware" { "🟢 VMware" }
"Unknown" { "❓ Unknown" }
default { $SessionType }
}
}

11.7. Алгоритм определения «текущей» сессии (IsCurrent)

Определение текущей сессии (сессии, в контексте которой выполняется скрипт) использует несколько методов для максимальной надёжности:

function Get-CurrentSessionId {
<#
.SYNOPSIS
Returns the Session ID of the current PowerShell process
Uses multiple methods for reliability across PS versions
#>


# Метод 1: .NET Process API (наиболее надёжный, PS 3.0+)
try {
$SessionId = [System.Diagnostics.Process]::GetCurrentProcess().SessionId
if ($SessionId -ge 0) {
Write-DebugLog "Current session ID from Process.SessionId: $SessionId" "VERBOSE"
return $SessionId
}
} catch { }

# Метод 2: WTS API WTSQuerySessionInformation с WTSSessionId
try {
# Запрос информации о текущей сессии (SessionId = -1 означает текущую)
$WTS_CURRENT_SESSION = -2 # WTS_CURRENT_SESSION константа
# [WtsApi]::WTSQuerySessionInformation(...)
# Возвращает ID текущей сессии
} catch { }

# Метод 3: Переменная окружения (ненадёжно, но как fallback)
try {
$EnvSessionId = $env:SESSIONNAME
if ($EnvSessionId) {
# Ищем соответствие в списке сессий
$Sessions = Get-WtsSessionsEn
$Match = $Sessions | Where-Object { $_.SessionName -eq $EnvSessionId }
if ($Match) { return $Match.SessionId }
}
} catch { }

# Метод 4: qwinsta маркер '>' (символ текущей сессии)
try {
$QwinstaOutput = & cmd.exe /c "chcp 437 >nul && qwinsta" 2>&1
$CurrentLine = $QwinstaOutput | Where-Object { $_ -match '^\s*>' }
if ($CurrentLine -match '>\s+\S+\s+\S+\s+(\d+)') {
return [int]$Matches[1]
}
} catch { }

Write-DebugLog "Could not determine current session ID, returning 0" "WARN"
return 0
}

11.8. Обработка системных сессий (services, rdp-tcp listener)

Системные сессии требуют особой обработки при отображении и фильтрации:

# Критерии идентификации системных сессий:
$SystemSessionCriteria = @{

# Session 0 — всегда системная
SessionId0 = { $_.SessionId -eq 0 }

# Имя сессии "services"
ServicesName = { $_.SessionName -eq "services" }

# Слушатели RDP (rdp-tcp без номера)
RdpListener = { $_.SessionName -match '^rdp-tcp$' -and $_.State -eq "Listen" }

# Большой Session ID (65536 = стандартный ID слушателя rdp-tcp)
LargeId = { $_.SessionId -ge 65536 }
}

function Test-IsSystemSession {
param([PSCustomObject]$Session)

return (
$Session.SessionId -eq 0 -or
$Session.SessionName -eq "services" -or
($Session.State -eq "Listen") -or
$Session.SessionId -ge 65536
)
}

При выводе системные сессии отображаются серым цветом (если включены параметром -IncludeSystem) и явно помечаются типом System. Операции управления (Disconnect, Logoff, Shadow) для системных сессий блокируются с соответствующим сообщением об ошибке.


Глава 12. Безопасность и управление привилегиями

12.1. Принцип минимальных привилегий и JEA

Использование комплекса в производственной среде должно соответствовать принципу минимальных привилегий (Principle of Least Privilege). Несмотря на то что большинство операций требуют прав администратора, возможна настройка Just Enough Administration (JEA) — механизма PowerShell, позволяющего делегировать конкретные команды конкретным пользователям без предоставления полных прав администратора.

Настройка JEA для делегирования доступа к функциям комплекса:

# Создание конфигурации роли JEA для RDP-операторов
# Файл: C:\JEA\RdpOperator.psrc

@{
# Разрешённые команды (только чтение и безопасные операции)
VisibleCmdlets = @(
'Get-WtsSessionsEn',
'Get-RDPStatus',
'Get-ActiveSessionsPS7'
)

# Разрешённые внешние команды
VisibleExternalCommands = @(
'C:\Windows\System32\qwinsta.exe'
)

# Разрешённые функции (только информационные)
VisibleFunctions = @(
'Get-WtsSessionsEn',
'Format-WtsOutput',
'Test-IsAdministrator'
)

# Запрещены: изменение реестра, logoff, disconnect, shadow без согласия
}

# Создание конфигурации сессии JEA
New-PSSessionConfigurationFile `
-Path "C:\JEA\RdpOperator.pssc" `
-SessionType RestrictedRemoteServer `
-RoleDefinitions @{
"DOMAIN\RDP-Operators" = @{ RoleCapabilityFiles = "C:\JEA\RdpOperator.psrc" }
}

# Регистрация конечной точки JEA
Register-PSSessionConfiguration `
-Name "RdpOperator" `
-Path "C:\JEA\RdpOperator.pssc" `
-Force

12.2. Функция Test-IsAdministrator — проверка прав

Подробная реализация с обработкой нестандартных сценариев (AppLocker, WDAC, контейнеры):

function Test-IsAdministrator {
<#
.SYNOPSIS
Comprehensive administrator check with multiple fallback methods
.OUTPUTS
[bool] True if running with administrative privileges
#>


# Метод 1: WindowsPrincipal (.NET) — стандартный и надёжный
try {
$Identity = [Security.Principal.WindowsIdentity]::GetCurrent()
$Principal = [Security.Principal.WindowsPrincipal]$Identity
$IsAdmin = $Principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)

Write-DebugLog "Admin check via WindowsPrincipal: $IsAdmin (User: $($Identity.Name))" "VERBOSE"
return $IsAdmin

} catch {
Write-DebugLog "WindowsPrincipal check failed: $_" "WARN"
}

# Метод 2: Проверка через whoami /groups — ищем SID администратора
try {
$WhoamiGroups = whoami /groups /fo csv 2>$null | ConvertFrom-Csv

# S-1-5-32-544 = BUILTIN\Administrators
# S-1-16-12288 = High Mandatory Level (Elevated process)
$IsAdmin = $WhoamiGroups | Where-Object {
$_."SID" -in @("S-1-5-32-544") -and
$_."Attributes" -like "*Enabled*"
}

return ($null -ne $IsAdmin)

} catch {
Write-DebugLog "whoami /groups check failed: $_" "WARN"
}

# Метод 3: Попытка записи в защищённый раздел реестра
# (работает даже в сильно ограниченных средах)
try {
$TestKey = "HKLM:\SOFTWARE\RDPManagerAdminTest_$(Get-Random)"
New-Item -Path $TestKey -Force -ErrorAction Stop | Out-Null
Remove-Item -Path $TestKey -Force -ErrorAction SilentlyContinue
return $true # Смогли записать — значит, есть права

} catch {
return $false # Нет прав на запись в HKLM
}
}

12.3. Механизм автоподъёма UAC

Детальный разбор механизма с обработкой всех возможных исходов диалога UAC:

function Invoke-ElevationIfNeeded {
<#
.SYNOPSIS
Checks for admin rights and requests elevation if needed
Returns $true if already elevated or elevation succeeded
#>

param(
[string[]] $OriginalArgs,
[switch] $ForceElevate # Всегда поднимать, даже если уже есть права
)

$IsAdmin = Test-IsAdministrator

if ($IsAdmin -and -not $ForceElevate) {
Write-DebugLog "Already running as Administrator. No elevation needed." "INFO"
return $true
}

if (-not $IsAdmin) {
Write-Host "[!] This operation requires Administrator privileges." -ForegroundColor Yellow
Write-Host "[*] Requesting UAC elevation..." -ForegroundColor Cyan
}

# Определение исполняемого файла PowerShell
$PSExe = if ($PSVersionTable.PSVersion.Major -ge 7) {
(Get-Command pwsh -ErrorAction SilentlyContinue)?.Source ?? "pwsh.exe"
} else {
(Get-Command powershell -ErrorAction SilentlyContinue)?.Source ?? "powershell.exe"
}

# Сборка аргументов
$ScriptPath = $MyInvocation.PSCommandPath
$EscapedPath = $ScriptPath -replace '"', '\"'
$ArgsString = ($OriginalArgs | ForEach-Object {
if ($_ -match '\s') { "`"$_`"" } else { $_ }
}) -join " "

$StartArgs = "-NoProfile -ExecutionPolicy Bypass -File `"$EscapedPath`" $ArgsString"

try {
$ProcessResult = Start-Process `
-FilePath $PSExe `
-ArgumentList $StartArgs `
-Verb "RunAs" `
-Wait `
-PassThru `
-ErrorAction Stop

# Проверка кода завершения дочернего процесса
if ($ProcessResult.ExitCode -eq 0) {
Write-DebugLog "Elevated process completed successfully (ExitCode=0)" "INFO"
} else {
Write-Warning "Elevated process exited with code: $($ProcessResult.ExitCode)"
}

exit $ProcessResult.ExitCode # Завершаем текущий (неповышенный) процесс

} catch [System.ComponentModel.Win32Exception] {
switch ($_.Exception.NativeErrorCode) {
1223 {
# ERROR_CANCELLED — пользователь нажал "Нет" в UAC
Write-Host "[!] Elevation cancelled by user." -ForegroundColor Red
Write-Host " Some features may not be available without admin rights." -ForegroundColor Yellow
return $false
}
740 {
# ERROR_ELEVATION_REQUIRED — требуется повышение (нет UAC?)
Write-Error "Elevation required but UAC is not available."
return $false
}
default {
Write-Error "Elevation failed with error $($_.Exception.NativeErrorCode): $_"
return $false
}
}
}
}

12.4. Проверка политики выполнения PowerShell

function Test-ExecutionPolicy {
<#
.SYNOPSIS
Validates that script execution is permitted and
provides actionable fix instructions if not
#>


$PolicyHierarchy = @("Process", "CurrentUser", "LocalMachine", "UserPolicy", "MachinePolicy")
$EffectivePolicy = Get-ExecutionPolicy # Итоговая политика (наиболее ограничительная)

Write-DebugLog "Effective ExecutionPolicy: $EffectivePolicy" "INFO"

$BlockingPolicies = @("Restricted", "AllSigned")

if ($EffectivePolicy -in $BlockingPolicies) {
Write-Warning "ExecutionPolicy '$EffectivePolicy' may prevent script execution."

# Детальная диагностика — откуда приходит ограничение
foreach ($Scope in $PolicyHierarchy) {
$ScopePolicy = Get-ExecutionPolicy -Scope $Scope -ErrorAction SilentlyContinue
if ($ScopePolicy -ne "Undefined") {
Write-Host " [$Scope] = $ScopePolicy" -ForegroundColor $(
if ($ScopePolicy -in $BlockingPolicies) { "Red" } else { "Green" }
)
}
}

# Инструкции по исправлению
Write-Host "`nTo fix, run one of the following:" -ForegroundColor Yellow
Write-Host " Set-ExecutionPolicy RemoteSigned -Scope CurrentUser # Для текущего пользователя"
Write-Host " Set-ExecutionPolicy RemoteSigned -Scope LocalMachine # Для всей машины (требует Admin)"
Write-Host " Or run script with: powershell -ExecutionPolicy Bypass -File script.ps1"

return $false
}

# Проверка наличия GPO-блокировки
$GPOPolicy = Get-ExecutionPolicy -Scope MachinePolicy -ErrorAction SilentlyContinue
if ($GPOPolicy -in $BlockingPolicies) {
Write-Warning "ExecutionPolicy is restricted by Group Policy (MachinePolicy=$GPOPolicy)."
Write-Warning "Contact your system administrator to modify this policy."
return $false
}

return $true
}

12.5. Настройка Network Level Authentication (NLA)

NLA является важнейшим элементом безопасности RDP-инфраструктуры. Комплекс предоставляет инструменты как для проверки, так и для управления NLA:

function Set-RDPNetworkLevelAuth {
param(
[bool] $Enable = $true,
[switch] $WhatIf
)

$WinStationPath = "HKLM:\SYSTEM\CurrentControlSet\Control\Terminal Server\WinStations\RDP-Tcp"
$PolicyPath = "HKLM:\SOFTWARE\Policies\Microsoft\Windows NT\Terminal Services"

# Проверка GPO-override
$PolicyNLA = (Get-ItemProperty $PolicyPath -Name "UserAuthentication" -EA SilentlyContinue).UserAuthentication
if ($null -ne $PolicyNLA) {
Write-Warning "NLA is controlled by Group Policy (UserAuthentication=$PolicyNLA)."
Write-Warning "Direct registry change may be overridden by GPO on next gpupdate."
}

$NewValue = if ($Enable) { 1 } else { 0 }
$ActionText = if ($Enable) { "ENABLING" } else { "DISABLING" }

Write-Host "[$ActionText] Network Level Authentication (NLA)..." -ForegroundColor Cyan

if (-not $WhatIf) {
Set-ItemProperty -Path $WinStationPath -Name "UserAuthentication" -Value $NewValue -Type DWord -Force
Write-Host "[+] UserAuthentication set to $NewValue" -ForegroundColor Green
} else {
Write-Host "[WhatIf] Would set UserAuthentication = $NewValue" -ForegroundColor Yellow
}

# Рекомендации по совместной настройке SecurityLayer
if ($Enable) {
$CurrentSecLayer = (Get-ItemProperty $WinStationPath -Name "SecurityLayer" -EA SilentlyContinue).SecurityLayer
if ($CurrentSecLayer -eq 0) {
Write-Warning "SecurityLayer=0 (RDP legacy). Consider setting SecurityLayer=1 (Negotiate) for NLA to work optimally."
Write-Host " Run: Set-ItemProperty '$WinStationPath' -Name SecurityLayer -Value 1 -Type DWord"
}
}
}

Требования для работы NLA: на клиентской стороне необходим CredSSP (Credential Security Support Provider), который поддерживается Windows Vista и новее. При подключении с Windows XP к серверу с включённым NLA соединение будет отклонено.

12.6. Управление уровнями безопасности RDP (SecurityLayer)

function Set-RDPSecurityLayer {
param(
[ValidateSet(0, 1, 2)]
[int] $Level = 1,
[switch] $WhatIf
)

$WinStationPath = "HKLM:\SYSTEM\CurrentControlSet\Control\Terminal Server\WinStations\RDP-Tcp"

$LevelDescriptions = @{
0 = "RDP Security Layer (RC4 encryption, legacy compatibility, no server certificate required)"
1 = "Negotiate (TLS preferred, RDP fallback — RECOMMENDED)"
2 = "SSL/TLS only (requires valid server certificate, maximum security)"
}

Write-Host "Setting SecurityLayer = $Level ($($LevelDescriptions[$Level]))" -ForegroundColor Cyan

# Предупреждение при выборе SecurityLayer=2
if ($Level -eq 2) {
Write-Warning "SecurityLayer=2 requires a valid SSL certificate on this server."
Write-Warning "If no certificate is configured, clients may be unable to connect."

# Проверка наличия сертификата RDP
$RDPCertThumb = (Get-ItemProperty "HKLM:\SYSTEM\CurrentControlSet\Control\Terminal Server\WinStations\RDP-Tcp" `
-Name "SSLCertificateSHA1Hash" -EA SilentlyContinue).SSLCertificateSHA1Hash

if (-not $RDPCertThumb) {
Write-Warning "No RDP SSL certificate configured. Windows will use a self-signed certificate."
Write-Host " To configure certificate: wmic /namespace:\\root\cimv2\TerminalServices PATH Win32_TSGeneralSetting Set SSLCertificateSHA1Hash=<thumbprint>"
} else {
Write-Host "[+] RDP certificate thumbprint: $([BitConverter]::ToString($RDPCertThumb))" -ForegroundColor Green
}
}

if (-not $WhatIf) {
Set-ItemProperty -Path $WinStationPath -Name "SecurityLayer" -Value $Level -Type DWord -Force
Write-Host "[+] SecurityLayer updated to $Level" -ForegroundColor Green
}
}

12.7. Настройка брандмауэра Windows для RDP

function Set-RDPFirewallRules {
param(
[switch] $Enable,
[switch] $Disable,
[int] $Port = 3389,
[switch] $WhatIf
)

if (-not $Enable -and -not $Disable) {
$Enable = $true # По умолчанию — включить
}

$Action = if ($Enable) { "Enable" } else { "Disable" }

Write-Host "[$Action] RDP firewall rules (port $Port)..." -ForegroundColor Cyan

# Метод 1: PowerShell NetSecurity cmdlets (Windows 8+)
try {
$RDPRules = Get-NetFirewallRule -DisplayGroup "Remote Desktop" -ErrorAction Stop

if ($RDPRules) {
if (-not $WhatIf) {
if ($Enable) {
$RDPRules | Enable-NetFirewallRule
} else {
$RDPRules | Disable-NetFirewallRule
}
}
Write-Host "[+] Updated $($RDPRules.Count) firewall rules via NetSecurity" -ForegroundColor Green
return
}
} catch {
Write-DebugLog "NetSecurity cmdlets failed: $_. Trying netsh." "WARN"
}

# Метод 2: netsh advfirewall (Windows Vista+, совместимость)
try {
$EnableStr = if ($Enable) { "Yes" } else { "No" }

if (-not $WhatIf) {
& netsh advfirewall firewall set rule `
group="remote desktop" `
new enable=$EnableStr 2>&1 | Out-Null

# Если нестандартный порт — добавляем явное правило
if ($Port -ne 3389) {
& netsh advfirewall firewall add rule `
name="RDP Custom Port $Port" `
protocol=TCP `
dir=in `
localport=$Port `
action=allow 2>&1 | Out-Null
}
}
Write-Host "[+] Firewall rules updated via netsh" -ForegroundColor Green

} catch {
Write-Error "Failed to update firewall rules: $_"
}
}

12.8. Аудит сессий и ведение журналов

Комплекс предоставляет инструменты для анализа журналов аудита RDP-подключений:

function Get-RDPAuditLog {
<#
.SYNOPSIS
Retrieves and formats RDP audit events from Windows Event Log
Covers successful logons, failed attempts, disconnections, reconnections
#>

param(
[datetime] $StartTime = (Get-Date).AddDays(-7),
[datetime] $EndTime = (Get-Date),
[string] $UserFilter = "*",
[switch] $IncludeFailed,
[int] $MaxEvents = 1000,
[string] $OutputFormat = "Table" # Table | JSON | CSV | HTML
)

# Таблица отслеживаемых событий
$TrackedEvents = @{
4624 = "Successful Logon (RDP)"
4625 = "Failed Logon Attempt"
4634 = "Logoff"
4647 = "User Initiated Logoff"
4778 = "Session Reconnected"
4779 = "Session Disconnected"
4800 = "Workstation Locked"
4801 = "Workstation Unlocked"
}

$EventIds = @(4624, 4634, 4647, 4778, 4779)
if ($IncludeFailed) { $EventIds += 4625 }

$XPath = "*[System[(" + (($EventIds | ForEach-Object { "EventID=$_" }) -join " or ") + ")]]"

try {
$Events = Get-WinEvent -LogName "Security" `
-FilterXPath $XPath `
-MaxEvents $MaxEvents `
-ErrorAction Stop |
Where-Object { $_.TimeCreated -ge $StartTime -and $_.TimeCreated -le $EndTime }

$AuditRecords = foreach ($Event in $Events) {
$EventXml = [xml]$Event.ToXml()
$Data = @{}
foreach ($Item in $EventXml.Event.EventData.Data) {
if ($Item.Name) { $Data[$Item.Name] = $Item.'#text' }
}

$UserName = $Data["TargetUserName"] ?? $Data["SubjectUserName"]
if ($UserFilter -ne "*" -and $UserName -notlike $UserFilter) { continue }

# Фильтр: только RDP-входы (LogonType=10) для события 4624
if ($Event.Id -eq 4624 -and $Data["LogonType"] -ne "10") { continue }

[PSCustomObject]@{
Time = $Event.TimeCreated
EventId = $Event.Id
EventName = $TrackedEvents[$Event.Id]
UserName = $UserName
Domain = $Data["TargetDomainName"] ?? $Data["SubjectDomainName"]
IpAddress = ($Data["IpAddress"] -replace "^::ffff:", "") ?? "N/A"
IpPort = $Data["IpPort"] ?? "N/A"
WorkStation = $Data["WorkstationName"] ?? "N/A"
LogonId = $Data["TargetLogonId"] ?? "N/A"
}
}

# Вывод в запрошенном формате
switch ($OutputFormat.ToUpper()) {
"TABLE" { $AuditRecords | Format-Table Time, EventId, EventName, UserName, IpAddress -AutoSize }
"JSON" { $AuditRecords | ConvertTo-Json -Depth 5 }
"CSV" { $AuditRecords | ConvertTo-Csv -NoTypeInformation }
"HTML" { $AuditRecords | ConvertTo-Html -Title "RDP Audit Log" -PreContent "<h1>RDP Audit Log — $($env:COMPUTERNAME)</h1>" }
}

} catch {
Write-Error "Failed to retrieve audit log: $_"
Write-Warning "Ensure the Security event log is accessible and you have Administrator rights."
}
}

12.9. Соображения безопасности при использовании NoConsentPrompt

Использование режима -NoConsentPrompt (теневое подключение без уведомления пользователя) требует особого внимания с точки зрения законодательства и корпоративной политики. Ключевые аспекты:

Российское законодательство: Федеральный закон № 152-ФЗ «О персональных данных» требует информирования субъектов персональных данных об обработке их данных. Скрытый мониторинг рабочего стола сотрудника без его уведомления в трудовом договоре или корпоративных политиках может быть признан нарушением. Рекомендация: включить соответствующий пункт в трудовой договор и политику информационной безопасности.

Технические меры: при использовании -NoConsentPrompt в производственной среде рекомендуется:

# Рекомендуемые меры при использовании NoConsentPrompt в корпоративной среде:

# 1. Ведение журнала всех теневых подключений
function Log-ShadowConnection {
param(
[int] $TargetSessionId,
[string] $TargetUser,
[string] $AdminUser = $env:USERNAME,
[bool] $WithControl
)

$LogEntry = [PSCustomObject]@{
Timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
AdminUser = $AdminUser
TargetSession = $TargetSessionId
TargetUser = $TargetUser
Mode = if ($WithControl) { "Interactive" } else { "ViewOnly" }
ConsentPrompt = $false
ServerName = $env:COMPUTERNAME
AdminIP = (Get-NetIPAddress -AddressFamily IPv4 |
Where-Object InterfaceAlias -ne "Loopback*" |
Select-Object -First 1 -ExpandProperty IPAddress)
}

# Запись в Application Event Log (Event ID 9001)
try {
if (-not [System.Diagnostics.EventLog]::SourceExists("RDPSessionManager")) {
New-EventLog -LogName Application -Source "RDPSessionManager"
}
Write-EventLog -LogName Application `
-Source "RDPSessionManager" `
-EventId 9001 `
-EntryType Information `
-Message ($LogEntry | ConvertTo-Json)
} catch { }

# Дополнительно — запись в файл
$LogPath = "C:\Logs\RDPShadow_$(Get-Date -Format 'yyyyMM').log"
"$($LogEntry.Timestamp)|$($LogEntry.AdminUser)|$($LogEntry.TargetUser)|$($LogEntry.Mode)" |
Add-Content -Path $LogPath -Encoding UTF8
}

Глава 13. Корпоративное развёртывание

13.1. Стратегии массового развёртывания

В корпоративных средах с сотнями серверов развёртывание комплекса вручную на каждом узле нецелесообразно. Существуют три основные стратегии автоматизированного развёртывания, применимые в зависимости от инфраструктуры организации:

СтратегияИнфраструктураМасштабСложность
GPO Startup ScriptActive DirectoryСредний (до 500 серверов)Низкая
SCCM/Intune PackageConfiguration ManagerКрупный (500+)Средняя
PowerShell RemotingЛюбая с WinRMЛюбойНизкая
Ansible/DSCDevOps-инфраструктураЛюбойВысокая
Docker/ContainerКонтейнерная средаСовременнаяВысокая

13.2. Развёртывание через групповые политики (GPO)

# ═══════════════════════════════════════════════════════════════
# Скрипт создания структуры GPO для развёртывания RDPManager
# Выполняется на контроллере домена с правами Domain Admin
# ═══════════════════════════════════════════════════════════════

# Шаг 1: Создание общей папки на файловом сервере
$ShareServer = "\\FS01\IT-Tools\RDPManager"
$SharePath = "D:\IT-Tools\RDPManager"

if (-not (Test-Path $SharePath)) {
New-Item -ItemType Directory -Path $SharePath -Force
}

# Копирование файлов комплекса
$Files = @("1st-Remote-Session-Manager-Pro.ps1", "qwinsta-en.ps1", "qwinsta_IP_PS7.ps1")
foreach ($File in $Files) {
$SourceUrl = "https://raw.githubusercontent.com/paulmann/1st-Remote-Session-Manager-Pro/main/$File"
Invoke-RestMethod -Uri $SourceUrl -OutFile "$SharePath\$File"
}

# Настройка прав доступа на папку (только чтение для Domain Computers)
$Acl = Get-Acl $SharePath
$AccessRule = [System.Security.AccessControl.FileSystemAccessRule]::new(
"Domain Computers",
"ReadAndExecute",
"ContainerInherit,ObjectInherit",
"None",
"Allow"
)
$Acl.AddAccessRule($AccessRule)
Set-Acl -Path $SharePath -AclObject $Acl

# Шаг 2: Создание GPO
Import-Module GroupPolicy -ErrorAction Stop

$GPOName = "Deploy-RDPSessionManager"
$TargetOU = "OU=Servers,DC=domain,DC=local"

$GPO = New-GPO -Name $GPOName -Comment "Deploys 1st Remote Session Manager Pro to all servers"
New-GPLink -Name $GPOName -Target $TargetOU -LinkEnabled Yes

# Шаг 3: Создание скрипта развёртывания для Startup Scripts GPO
$StartupScriptContent = @'
# RDPManager Deployment Script (runs as SYSTEM at computer startup)
$Source = "\\FS01\IT-Tools\RDPManager"
$Dest = "C:\Tools\RDPManager"
$LogFile = "C:\Windows\Temp\RDPManager_Deploy.log"

function Write-DeployLog {
param([string]$Message)
"[$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')] $Message" | Add-Content $LogFile
}

Write-DeployLog "Starting RDPManager deployment check..."

# Создание целевой директории
if (-not (Test-Path $Dest)) {
New-Item -ItemType Directory -Path $Dest -Force | Out-Null
Write-DeployLog "Created directory: $Dest"
}

# Синхронизация файлов (только при изменении)
$Files = @("1st-Remote-Session-Manager-Pro.ps1", "qwinsta-en.ps1", "qwinsta_IP_PS7.ps1")
foreach ($File in $Files) {
$SrcFile = Join-Path $Source $File
$DstFile = Join-Path $Dest $File

$NeedUpdate = (-not (Test-Path $DstFile)) -or
((Get-FileHash $SrcFile -Algorithm MD5).Hash -ne
(Get-FileHash $DstFile -Algorithm MD5).Hash)

if ($NeedUpdate) {
Copy-Item $SrcFile $DstFile -Force
Write-DeployLog "Updated: $File"
}
}

# Настройка ExecutionPolicy
$CurrentPolicy = Get-ExecutionPolicy -Scope LocalMachine
if ($CurrentPolicy -notin @("RemoteSigned", "Unrestricted", "Bypass")) {
Set-ExecutionPolicy RemoteSigned -Scope LocalMachine -Force
Write-DeployLog "ExecutionPolicy set to RemoteSigned"
}

Write-DeployLog "Deployment check completed."
'@

$StartupScriptContent | Out-File "$SharePath\Deploy-RDPManager.ps1" -Encoding UTF8

Write-Host "[+] GPO created: $GPOName" -ForegroundColor Green
Write-Host "[+] Files deployed to: $SharePath" -ForegroundColor Green
Write-Host "[!] Next step: Add Deploy-RDPManager.ps1 to GPO Startup Scripts via GPMC" -ForegroundColor Yellow

13.3. Развёртывание через SCCM / Microsoft Endpoint Manager

Для развёртывания через SCCM создаётся пакет с двумя программами: Install (установка) и Verify (проверка):

# Install.ps1 — устанавливается SCCM при развёртывании
param([string]$InstallPath = "C:\Tools\RDPManager")

$ExitCode = 0
try {
# Создание целевой директории
New-Item -ItemType Directory -Path $InstallPath -Force -EA Stop | Out-Null

# Копирование файлов из источника пакета SCCM (текущая директория)
$Files = Get-ChildItem -Path $PSScriptRoot -Filter "*.ps1"
foreach ($File in $Files) {
if ($File.Name -notin @("Install.ps1", "Uninstall.ps1")) {
Copy-Item $File.FullName -Destination $InstallPath -Force
}
}

# Настройка ExecutionPolicy
Set-ExecutionPolicy RemoteSigned -Scope LocalMachine -Force

# Создание версионного файла для SCCM Detection Method
"4.2" | Out-File "$InstallPath\version.txt" -Encoding ASCII

# Запись в реестр (для обнаружения SCCM)
$RegPath = "HKLM:\SOFTWARE\IT-Tools\RDPSessionManager"
New-Item -Path $RegPath -Force | Out-Null
Set-ItemProperty $RegPath -Name "Version" -Value "4.2"
Set-ItemProperty $RegPath -Name "InstallPath" -Value $InstallPath
Set-ItemProperty $RegPath -Name "InstallDate" -Value (Get-Date -Format "yyyy-MM-dd")

} catch {
Write-Error "Installation failed: $_"
$ExitCode = 1
}

exit $ExitCode

# ─────────────────────────────────────────────────────────────────

# Detection Method для SCCM (PowerShell Script):
# Возвращает непустую строку если установлено корректно
$InstallPath = "C:\Tools\RDPManager"
$VersionFile = "$InstallPath\version.txt"
$RegPath = "HKLM:\SOFTWARE\IT-Tools\RDPSessionManager"

if ((Test-Path $VersionFile) -and (Test-Path $RegPath)) {
$Version = Get-Content $VersionFile -Raw
if ($Version.Trim() -ge "4.0") {
Write-Output "Installed: v$($Version.Trim())"
}
}

13.4. Развёртывание через PowerShell Remoting

Наиболее гибкий метод — развёртывание через PowerShell Remoting позволяет охватить произвольный список серверов за один запуск:

# Mass-Deploy-RDPManager.ps1
# Массовое развёртывание комплекса через PowerShell Remoting
# Требования: WinRM включён на целевых серверах

param(
[string[]] $ComputerNames, # Список серверов
[string] $ComputerListFile, # Или файл со списком (по одному на строку)
[string] $InstallPath = "C:\Tools\RDPManager",
[string] $SourcePath = "\\FS01\IT-Tools\RDPManager",
[PSCredential] $Credential = $null, # null = текущие учётные данные
[int] $ThrottleLimit = 10, # Параллельных подключений
[switch] $WhatIf
)

# Сборка списка серверов
if ($ComputerListFile -and (Test-Path $ComputerListFile)) {
$ComputerNames = Get-Content $ComputerListFile | Where-Object { $_ -match '\S' }
}

Write-Host "Deploying to $($ComputerNames.Count) servers..." -ForegroundColor Cyan

# Скриптблок для выполнения на каждом сервере
$DeployBlock = {
param($Source, $Dest, $WhatIf)

$Result = [PSCustomObject]@{
ComputerName = $env:COMPUTERNAME
Status = "Unknown"
Error = $null
Version = $null
}

try {
if (-not $WhatIf) {
New-Item -ItemType Directory -Path $Dest -Force -EA Stop | Out-Null

$Files = @("1st-Remote-Session-Manager-Pro.ps1", "qwinsta-en.ps1", "qwinsta_IP_PS7.ps1")
foreach ($File in $Files) {
Copy-Item (Join-Path $Source $File) -Destination $Dest -Force -EA Stop
}

Set-ExecutionPolicy RemoteSigned -Scope LocalMachine -Force -EA SilentlyContinue
}

# Проверка версии
$VersionLine = Get-Content "$Dest\1st-Remote-Session-Manager-Pro.ps1" |
Select-String '# Version:' | Select-Object -First 1
$Result.Version = $VersionLine -replace '.*# Version:\s*', ''
$Result.Status = if ($WhatIf) { "WhatIf-OK" } else { "Success" }

} catch {
$Result.Status = "Failed"
$Result.Error = $_.Exception.Message
}

return $Result
}

# Параллельное выполнение через Invoke-Command
$InvokeParams = @{
ComputerName = $ComputerNames
ScriptBlock = $DeployBlock
ArgumentList = @($SourcePath, $InstallPath, $WhatIf.IsPresent)
ThrottleLimit = $ThrottleLimit
ErrorAction = "SilentlyContinue"
}

if ($Credential) { $InvokeParams.Credential = $Credential }

$Results = Invoke-Command @InvokeParams

# Сводный отчёт
$Success = ($Results | Where-Object Status -like "*Success*").Count
$Failed = ($Results | Where-Object Status -eq "Failed").Count

Write-Host "`n═══ Deployment Summary ═══" -ForegroundColor Cyan
Write-Host " Success : $Success" -ForegroundColor Green
Write-Host " Failed : $Failed" -ForegroundColor $(if ($Failed -gt 0) {"Red"} else {"Gray"})
Write-Host " Total : $($ComputerNames.Count)"

if ($Failed -gt 0) {
Write-Host "`nFailed servers:" -ForegroundColor Red
$Results | Where-Object Status -eq "Failed" |
Format-Table ComputerName, Error -AutoSize
}

# Экспорт результатов
$Results | Export-Csv "Deploy-Results-$(Get-Date -Format 'yyyyMMdd_HHmmss').csv" -NoTypeInformation

13.5. Организация централизованного журналирования

# Централизованный коллектор событий RDPManager
# Запускается на сервере мониторинга по расписанию

function Collect-RDPManagerEvents {
param(
[string[]] $Servers,
[string] $CentralLogPath = "\\LOGSERVER\RDPLogs",
[datetime] $Since = (Get-Date).AddHours(-1)
)

foreach ($Server in $Servers) {
try {
$Events = Invoke-Command -ComputerName $Server -ScriptBlock {
param($Since)
Get-WinEvent -FilterHashtable @{
LogName = "Application"
Source = "RDPSessionManager"
StartTime = $Since
} -ErrorAction SilentlyContinue
} -ArgumentList $Since -ErrorAction SilentlyContinue

if ($Events) {
$LogFile = Join-Path $CentralLogPath "$Server-$(Get-Date -Format 'yyyyMMdd').json"
$Events | ForEach-Object {
[PSCustomObject]@{
Server = $Server
Time = $_.TimeCreated
EventId = $_.Id
Message = $_.Message
}
} | ConvertTo-Json | Add-Content $LogFile -Encoding UTF8

Write-Host "[+] Collected $($Events.Count) events from $Server" -ForegroundColor Green
}

} catch {
Write-Warning "Failed to collect from ${Server}: $_"
}
}
}

13.6. Мониторинг и аудит в корпоративной среде

Для полноценного мониторинга рекомендуется настройка подписок на события Windows (Windows Event Subscriptions) для централизованного сбора RDP-событий со всех серверов:

# Настройка Windows Event Forwarding (WEF) для RDP-событий
# Выполняется на сервере-коллекторе событий

# Создание подписки через wecutil
$SubscriptionXml = @"
<Subscription xmlns="http://schemas.microsoft.com/2006/03/windows/events/subscription">
<SubscriptionId>RDP-Security-Events</SubscriptionId>
<SubscriptionType>SourceInitiated</SubscriptionType>
<Description>Collects RDP security events from all domain servers</Description>
<Enabled>true</Enabled>
<Uri>http://schemas.microsoft.com/wbem/wsman/1/windows/EventLog</Uri>
<ConfigurationMode>MinLatency</ConfigurationMode>
<Delivery Mode="Push">
<Batching>
<MaxItems>20</MaxItems>
<MaxLatencyTime>900000</MaxLatencyTime>
</Batching>
<PushSettings>
<Heartbeat Interval="900000"/>
</PushSettings>
</Delivery>
<Query>
<![CDATA[
<QueryList>
<Query Id="0">
<Select Path="Security">
*[System[(EventID=4624 or EventID=4625 or EventID=4634 or
EventID=4778 or EventID=4779)]]
and
*[EventData[Data[@Name='LogonType']='10']]
</Select>
<Select Path="Microsoft-Windows-TerminalServices-LocalSessionManager/Operational">
*[System[(EventID=21 or EventID=22 or EventID=23 or EventID=24 or EventID=25)]]
</Select>
</Query>
</QueryList>
]]>
</Query>
<ReadExistingEvents>false</ReadExistingEvents>
<TransportName>http</TransportName>
<ContentFormat>RenderedText</ContentFormat>
<Locale Language="en-US"/>
<LogFile>ForwardedEvents</ForwardedEvents</LogFile>
<AllowedSourceDomainComputers>O:NSG:NSD:(A;;GA;;;DC)</AllowedSourceDomainComputers>
</Subscription>
"@

$SubscriptionXml | Out-File "C:\Temp\RDP-WEF.xml" -Encoding UTF8
& wecutil cs "C:\Temp\RDP-WEF.xml"
Write-Host "[+] WEF Subscription created for RDP events" -ForegroundColor Green

13.7. Интеграция с системами SIEM

Для интеграции с SIEM-системами (Splunk, Elastic SIEM, IBM QRadar, MaxPatrol SIEM) рекомендуется использовать JSON-вывод комплекса:

# Скрипт для отправки данных о сессиях в SIEM через HTTP/REST

function Send-RDPDataToSiem {
param(
[string] $SiemEndpoint = "http://siem.domain.local:8088/services/collector",
[string] $SiemToken = "YOUR_HEC_TOKEN", # Splunk HEC token
[int] $IntervalSec = 60
)

while ($true) {
try {
# Сбор данных
$Sessions = .\1st-Remote-Session-Manager-Pro.ps1 -Sessions -Json -WithIP |
ConvertFrom-Json

# Формирование payload для Splunk HEC
$SplunkEvent = @{
time = [DateTimeOffset]::UtcNow.ToUnixTimeSeconds()
host = $env:COMPUTERNAME
source = "rdp-session-manager"
sourcetype = "rdp:sessions"
event = $Sessions
} | ConvertTo-Json -Depth 10 -Compress

# Отправка через HTTPS
$Headers = @{
"Authorization" = "Splunk $SiemToken"
"Content-Type" = "application/json"
}

Invoke-RestMethod `
-Uri $SiemEndpoint `
-Method POST `
-Headers $Headers `
-Body $SplunkEvent `
-ErrorAction Stop | Out-Null

Write-DebugLog "Sent $($Sessions.TotalSessions) sessions to SIEM" "INFO"

} catch {
Write-Warning "SIEM send failed: $_"
}

Start-Sleep -Seconds $IntervalSec
}
}

13.8. Role-Based Access Control (RBAC) для RDP-администрирования

Рекомендуемая матрица RBAC для организации многоуровневого доступа к функциям комплекса:

РольПросмотр сессийIP-корреляцияShadow (View)Shadow (Control)DisconnectLogoffНастройка реестра
RDP-Viewer
RDP-HelpDesk
RDP-Operator
RDP-Admin

Реализация ролевого доступа через JEA конечные точки с отдельным .psrc-файлом для каждой роли позволяет делегировать конкретные функции комплекса без предоставления полных прав администратора, что соответствует требованиям информационной безопасности большинства организаций.


Глава 14. Практические сценарии использования

14.1. Сценарий 1: Ежедневный мониторинг сессий на терминальном сервере

Наиболее частый сценарий использования комплекса — ежедневная рутина системного администратора: быстро увидеть кто подключён, с каких IP, сколько времени, и принять решение о необходимости действий.

# ═══════════════════════════════════════════════════════════════════
# СЦЕНАРИЙ 1: Утренняя проверка терминального сервера
# Рекомендуется добавить в ярлык на рабочий стол администратора
# ═══════════════════════════════════════════════════════════════════

# Быстрый просмотр активных сессий с IP-адресами
.\1st-Remote-Session-Manager-Pro.ps1 -Sessions -WithIP

# Пример вывода:
# ╔══════════════════════════════════════════════════════════════════╗
# ║ 1st Remote Session Manager Pro v4.2 — WIN-SRV2022 ║
# ╚══════════════════════════════════════════════════════════════════╝
#
# ID SessionName UserName State IP Address Conf.
# ── ────────────────── ──────────────────── ────────── ──────────────── ──────
# 1 rdp-tcp#0 DOMAIN\ivanov Active 192.168.10.55 High
# 2 rdp-tcp#1 DOMAIN\petrov Active 192.168.10.82 High
# 3 rdp-tcp#2 DOMAIN\sidorov Disc 10.8.0.14 Medium
# 4 rdp-tcp#3 DOMAIN\kozlov Active 192.168.10.101 High
#
# Total: 4 sessions | Active: 3 | Disconnected: 1

# Если нужно узнать детали конкретной сессии
.\1st-Remote-Session-Manager-Pro.ps1 -Sessions -WithIP -SessionId 3 -Detailed

# Расширенный вывод с историей подключений из EventLog
.\1st-Remote-Session-Manager-Pro.ps1 -Sessions -WithIP -WithHistory

Для превращения ежедневной проверки в автоматический отчёт, добавляемый в Teams или отправляемый по почте:

# Автоматический ежедневный отчёт (добавить в Task Scheduler)
$ReportPath = "C:\Reports\RDP-Daily-$(Get-Date -Format 'yyyy-MM-dd').html"

.\1st-Remote-Session-Manager-Pro.ps1 -Sessions -WithIP -Export HTML -OutputFile $ReportPath

# Отправка отчёта по почте
$MailParams = @{
From = "monitoring@company.ru"
To = "admin@company.ru"
Subject = "RDP Session Report — $(Get-Date -Format 'yyyy-MM-dd') — $env:COMPUTERNAME"
Body = "See attached daily RDP session report."
Attachments = $ReportPath
SmtpServer = "mail.company.ru"
}
Send-MailMessage @MailParams -Encoding UTF8

Write-Host "[+] Daily report sent: $ReportPath" -ForegroundColor Green

14.2. Сценарий 2: Помощь пользователю через теневое подключение (HelpDesk)

Классический HelpDesk-сценарий: пользователь позвонил и сообщил о проблеме с приложением. Нужно подключиться к его сессии, увидеть происходящее и при необходимости помочь:

# ═══════════════════════════════════════════════════════════════════
# СЦЕНАРИЙ 2: HelpDesk — подключение к сессии пользователя
# ═══════════════════════════════════════════════════════════════════

# Шаг 1: Найти сессию пользователя по имени
.\1st-Remote-Session-Manager-Pro.ps1 -Sessions | Where-Object UserName -like "*ivanov*"

# Шаг 2: Подключиться в режиме просмотра (пользователь увидит уведомление)
.\1st-Remote-Session-Manager-Pro.ps1 -SessionId 1 -ViewOnly

# Шаг 3 (альтернатива): Полное управление с запросом согласия
.\1st-Remote-Session-Manager-Pro.ps1 -SessionId 1

# Диалог на экране пользователя:
# ╔════════════════════════════════════════════════════╗
# ║ DOMAIN\admin wants to view your session. ║
# ║ Do you allow? [Yes] [No] ║
# ╚════════════════════════════════════════════════════╝

# Шаг 4 (для срочных случаев): Без запроса согласия + только просмотр
# (требует настройки реестра Shadow=4 или Shadow=2)
.\1st-Remote-Session-Manager-Pro.ps1 -SessionId 1 -ViewOnly -NoConsentPrompt

# Полный HelpDesk workflow одной командой (интерактивный выбор)
.\1st-Remote-Session-Manager-Pro.ps1 -HelpDesk
# > Показывает список активных сессий
# > Предлагает выбрать сессию
# > Спрашивает режим: [1] ViewOnly [2] FullControl
# > Подключается с запросом согласия пользователя

14.3. Сценарий 3: Расследование инцидента ИБ — поиск сессии по IP

При обнаружении подозрительной активности в сети (например, IDS-системой) администратор ИБ получает IP-адрес источника и должен быстро определить, кто именно подключён с этого адреса:

# ═══════════════════════════════════════════════════════════════════
# СЦЕНАРИЙ 3: Расследование — кто подключён с подозрительного IP?
# ═══════════════════════════════════════════════════════════════════

$SuspiciousIP = "10.8.0.14"

# Метод 1: Поиск в текущих активных сессиях
$SuspectSession = .\qwinsta_IP_PS7.ps1 -Command Analyze |
ConvertFrom-Json |
Where-Object { $_.Matches.IpAddress -eq $SuspiciousIP }

if ($SuspectSession) {
Write-Host "[!] ACTIVE SESSION from suspicious IP $SuspiciousIP`:" -ForegroundColor Red
$SuspectSession.Matches | Where-Object IpAddress -eq $SuspiciousIP |
Format-Table SessionId, UserName, State, Confidence
}

# Метод 2: Поиск в историческом журнале событий за последние 48 часов
.\1st-Remote-Session-Manager-Pro.ps1 `
-AuditLog `
-Since (Get-Date).AddHours(-48) `
-FilterIP $SuspiciousIP

# Пример вывода:
# Time EventId User IP Notes
# 2026-04-25 22:14:33 4624 DOMAIN\vpnuser01 10.8.0.14 Logon (RDP)
# 2026-04-25 22:15:01 4778 DOMAIN\vpnuser01 10.8.0.14 Session Reconnect
# 2026-04-25 23:51:22 4634 DOMAIN\vpnuser01 10.8.0.14 Logoff

# Метод 3: Немедленная блокировка сессии если угроза подтверждена
$IncidentReport = @{
Timestamp = Get-Date -Format "o"
SuspiciousIP = $SuspiciousIP
ActionTaken = "Session disconnected pending investigation"
AdminUser = $env:USERNAME
}

# Отключить сессию (не logoff — для сохранения данных для криминалистики)
.\1st-Remote-Session-Manager-Pro.ps1 -SessionId $SuspectSession.SessionId -Disconnect -Force

# Сохранить отчёт об инциденте
$IncidentReport | ConvertTo-Json | Out-File "C:\Incidents\IR-$(Get-Date -Format 'yyyyMMdd_HHmmss').json"

14.4. Сценарий 4: Очистка зависших Disconnected-сессий

Накопление отключённых (Disconnected) сессий — типичная проблема терминальных серверов. Каждая такая сессия потребляет оперативную память (обычно 100–300 МБ на пользователя). Автоматическая очистка:

# ═══════════════════════════════════════════════════════════════════
# СЦЕНАРИЙ 4: Автоочистка Disconnected-сессий по расписанию
# Рекомендуется запускать через Task Scheduler каждые 2 часа
# ═══════════════════════════════════════════════════════════════════

param(
[int] $DisconnectedThresholdMinutes = 120, # Отключён более 2 часов → logoff
[switch] $WhatIf,
[string] $LogFile = "C:\Logs\SessionCleanup-$(Get-Date -Format 'yyyyMM').log"
)

function Write-CleanupLog {
param([string]$Message, [string]$Level = "INFO")
$Entry = "[$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')] [$Level] $Message"
Write-Host $Entry -ForegroundColor $(
switch ($Level) { "ERROR" { "Red" }; "WARN" { "Yellow" }; default { "Gray" } }
)
$Entry | Add-Content $LogFile -Encoding UTF8
}

Write-CleanupLog "Starting session cleanup (threshold: ${DisconnectedThresholdMinutes}min)"

# Получение всех сессий
$AllSessions = .\1st-Remote-Session-Manager-Pro.ps1 -Sessions -Json | ConvertFrom-Json

# Фильтрация Disconnected-сессий
$DiscSessions = $AllSessions | Where-Object {
$_.State -eq "Disc" -and
$_.SessionId -gt 0 -and
$_.UserName -notlike "*[System]*"
}

Write-CleanupLog "Found $($DiscSessions.Count) disconnected sessions"

$LoggedOff = 0
$Skipped = 0

foreach ($Session in $DiscSessions) {
# Вычисление времени в состоянии Disconnected
$DisconnectedSince = $null
$MinutesDisc = 999 # Default: считаем старой если время неизвестно

if ($Session.LastInputTime) {
$DisconnectedSince = [datetime]$Session.LastInputTime
$MinutesDisc = ((Get-Date) - $DisconnectedSince).TotalMinutes
}

$LogEntry = "Session ID=$($Session.SessionId) User=$($Session.UserName) DiscFor=$([int]$MinutesDisc)min"

if ($MinutesDisc -ge $DisconnectedThresholdMinutes) {
if (-not $WhatIf) {
try {
.\1st-Remote-Session-Manager-Pro.ps1 -SessionId $Session.SessionId -Logoff -Force
Write-CleanupLog "[LOGOFF] $LogEntry" "INFO"
$LoggedOff++
} catch {
Write-CleanupLog "[ERROR] Failed to logoff $($Session.SessionId): $_" "ERROR"
}
} else {
Write-CleanupLog "[WHATIF] Would logoff: $LogEntry" "WARN"
$LoggedOff++
}
} else {
Write-CleanupLog "[SKIP] $LogEntry (threshold not reached)" "INFO"
$Skipped++
}
}

Write-CleanupLog "Cleanup complete. LoggedOff=$LoggedOff Skipped=$Skipped"

# Task Scheduler команда для добавления задачи:
# schtasks /create /tn "RDP Session Cleanup" /tr "pwsh -File C:\Tools\RDPManager\cleanup.ps1" /sc hourly /mo 2 /ru SYSTEM

14.5. Сценарий 5: Скрытый аудит продуктивности (мониторинг без вмешательства)

Сценарий для службы ИБ или HR: периодические снимки состояния рабочих столов сотрудников в режиме «только просмотр» без уведомления (при наличии соответствующей корпоративной политики):

# ═══════════════════════════════════════════════════════════════════
# СЦЕНАРИЙ 5: Аудит продуктивности — снимки сессий
# ВАЖНО: Использовать только при наличии корпоративной политики!
# ═══════════════════════════════════════════════════════════════════

# Предварительное условие: Shadow=4 (ViewOnly без согласия) в реестре
# .\1st-Remote-Session-Manager-Pro.ps1 -EnableShadow -ShadowMode 4

function Invoke-SessionAuditSnapshot {
param(
[int] $DurationSeconds = 30, # Длительность наблюдения за одной сессией
[string] $ReportPath = "C:\AuditReports"
)

New-Item -ItemType Directory $ReportPath -Force | Out-Null

# Получение всех активных пользовательских сессий
$ActiveSessions = .\1st-Remote-Session-Manager-Pro.ps1 -Sessions -Json |
ConvertFrom-Json |
Where-Object { $_.State -eq "Active" -and $_.SessionId -gt 0 }

Write-Host "Starting audit of $($ActiveSessions.Count) active sessions..." -ForegroundColor Cyan

$AuditLog = foreach ($Session in $ActiveSessions) {
Write-Host " Auditing: ID=$($Session.SessionId) User=$($Session.UserName)" -ForegroundColor Gray

$AuditEntry = [PSCustomObject]@{
Timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
SessionId = $Session.SessionId
UserName = $Session.UserName
IpAddress = $Session.IpAddress
State = $Session.State
AuditedBy = $env:USERNAME
}

# ViewOnly + NoConsent: подключаемся на DurationSeconds секунд
$ShadowProcess = Start-Process mstsc.exe `
-ArgumentList "/shadow:$($Session.SessionId) /v:$env:COMPUTERNAME /noConsentPrompt" `
-PassThru

Start-Sleep -Seconds $DurationSeconds

# Закрытие теневого подключения
if (-not $ShadowProcess.HasExited) {
$ShadowProcess.CloseMainWindow() | Out-Null
Start-Sleep -Milliseconds 500
if (-not $ShadowProcess.HasExited) { $ShadowProcess.Kill() }
}

$AuditEntry
}

# Экспорт журнала аудита
$AuditLogFile = "$ReportPath\Audit-$(Get-Date -Format 'yyyyMMdd_HHmmss').csv"
$AuditLog | Export-Csv $AuditLogFile -NoTypeInformation -Encoding UTF8
Write-Host "[+] Audit log: $AuditLogFile" -ForegroundColor Green
}

14.6. Сценарий 6: Многосерверный мониторинг — агрегация с нескольких хостов

# ═══════════════════════════════════════════════════════════════════
# СЦЕНАРИЙ 6: Агрегированный мониторинг нескольких серверов
# ═══════════════════════════════════════════════════════════════════

$Servers = @(
"RDS-SRV-01",
"RDS-SRV-02",
"RDS-SRV-03",
"APP-SRV-01"
)

$AllSessionsAggregated = Invoke-Command `
-ComputerName $Servers `
-ThrottleLimit 4 `
-ScriptBlock {
# На каждом сервере запускаем модуль локально
$ScriptPath = "C:\Tools\RDPManager\1st-Remote-Session-Manager-Pro.ps1"

if (Test-Path $ScriptPath) {
& $ScriptPath -Sessions -WithIP -Json | ConvertFrom-Json
} else {
# Fallback: qwinsta если модуль не установлен
$RawSessions = qwinsta 2>&1
[PSCustomObject]@{
Server = $env:COMPUTERNAME
Error = "RDPManager not installed"
RawOutput = $RawSessions -join "`n"
}
}
} -ErrorAction SilentlyContinue

# Агрегация и сортировка
$Summary = $AllSessionsAggregated |
Where-Object { $_.State -in @("Active", "Disc") } |
Select-Object @{N="Server";E={$_.PSComputerName}},
SessionId, UserName, State, IpAddress, Confidence |
Sort-Object Server, State, UserName

# Красивый вывод таблицы
$Summary | Format-Table -GroupBy Server -AutoSize

# Сводная статистика по серверам
Write-Host "`n═══ Multi-Server Summary ═══" -ForegroundColor Cyan
$Summary | Group-Object Server | ForEach-Object {
$Active = ($_.Group | Where-Object State -eq "Active").Count
$Disc = ($_.Group | Where-Object State -eq "Disc").Count
Write-Host " $($_.Name.PadRight(15)) Active=$Active Disc=$Disc Total=$($Active+$Disc)"
}

# Экспорт агрегированного отчёта
$Summary | Export-Csv "C:\Reports\MultiServer-$(Get-Date -Format 'yyyyMMdd_HH00').csv" -NoTypeInformation

14.7. Сценарий 7: Автоматическое уведомление при обнаружении нового подключения

# ═══════════════════════════════════════════════════════════════════
# СЦЕНАРИЙ 7: Алерт в Telegram при новом RDP-подключении
# ═══════════════════════════════════════════════════════════════════

param(
[string] $TelegramBotToken = "YOUR_BOT_TOKEN",
[string] $TelegramChatId = "YOUR_CHAT_ID",
[int] $PollIntervalSec = 30
)

function Send-TelegramAlert {
param([string]$Message)
$Uri = "https://api.telegram.org/bot$TelegramBotToken/sendMessage"
$Body = @{
chat_id = $TelegramChatId
text = $Message
parse_mode = "Markdown"
}
try {
Invoke-RestMethod -Uri $Uri -Method POST -Body $Body -ErrorAction Stop | Out-Null
} catch {
Write-Warning "Telegram send failed: $_"
}
}

$PreviousSessions = @{}
Write-Host "Starting RDP connection monitor. Alerts will be sent to Telegram." -ForegroundColor Cyan

while ($true) {
$CurrentSessions = .\1st-Remote-Session-Manager-Pro.ps1 -Sessions -WithIP -Json |
ConvertFrom-Json |
Where-Object { $_.State -in @("Active", "Disc") -and $_.SessionId -gt 0 }

# Создание словаря текущих сессий
$CurrentDict = @{}
foreach ($S in $CurrentSessions) {
$CurrentDict[$S.SessionId] = $S
}

# Обнаружение новых подключений
foreach ($Id in $CurrentDict.Keys) {
if (-not $PreviousSessions.ContainsKey($Id)) {
$New = $CurrentDict[$Id]
$IpInfo = if ($New.IpAddress) { "from ``$($New.IpAddress)``" } else { "(IP unknown)" }
$Msg = "🔴 *NEW RDP CONNECTION*`n" +
"🖥️ Server: ``$env:COMPUTERNAME```n" +
"👤 User: ``$($New.UserName)```n" +
"🌐 IP: $IpInfo`n" +
"🕐 Time: $(Get-Date -Format 'HH:mm:ss')`n" +
"📋 Session ID: $Id"
Send-TelegramAlert -Message $Msg
Write-Host "[ALERT] New session: ID=$Id User=$($New.UserName)" -ForegroundColor Yellow
}
}

# Обнаружение завершённых сессий
foreach ($Id in $PreviousSessions.Keys) {
if (-not $CurrentDict.ContainsKey($Id)) {
$Old = $PreviousSessions[$Id]
$Msg = "✅ *SESSION ENDED*`n" +
"🖥️ Server: ``$env:COMPUTERNAME```n" +
"👤 User: ``$($Old.UserName)```n" +
"🕐 Time: $(Get-Date -Format 'HH:mm:ss')"
Send-TelegramAlert -Message $Msg
Write-Host "[ALERT] Session ended: ID=$Id User=$($Old.UserName)" -ForegroundColor Gray
}
}

$PreviousSessions = $CurrentDict
Start-Sleep -Seconds $PollIntervalSec
}

14.8. Сценарий 8: Экспорт данных для системы учёта рабочего времени

# ═══════════════════════════════════════════════════════════════════
# СЦЕНАРИЙ 8: Формирование отчёта о времени работы пользователей
# на терминальном сервере за период (для учёта рабочего времени)
# ═══════════════════════════════════════════════════════════════════

param(
[datetime] $PeriodStart = (Get-Date).AddDays(-30),
[datetime] $PeriodEnd = (Get-Date),
[string] $ReportPath = "C:\Reports\WorkTime-$(Get-Date -Format 'yyyyMM').csv"
)

# Извлечение пар событий Logon/Logoff из журнала Security
$LogonEvents = Get-WinEvent -FilterHashtable @{
LogName = "Security"
Id = 4624
StartTime = $PeriodStart
EndTime = $PeriodEnd
} -ErrorAction SilentlyContinue |
Where-Object { ([xml]$_.ToXml()).Event.EventData.Data | Where-Object { $_.Name -eq "LogonType" -and $_.'#text' -eq "10" } }

$LogoffEvents = Get-WinEvent -FilterHashtable @{
LogName = "Security"
Id = @(4634, 4647)
StartTime = $PeriodStart
EndTime = $PeriodEnd
} -ErrorAction SilentlyContinue

# Построение сессионного журнала через корреляцию LogonId
$WorktimeRecords = foreach ($Logon in $LogonEvents) {
$LogonXml = [xml]$Logon.ToXml()
$LogonData = @{}
foreach ($D in $LogonXml.Event.EventData.Data) { $LogonData[$D.Name] = $D.'#text' }

$LogonId = $LogonData["TargetLogonId"]
$UserName = $LogonData["TargetUserName"]
$IpAddress = $LogonData["IpAddress"] -replace "^::ffff:", ""

# Поиск соответствующего Logoff по LogonId
$MatchedLogoff = $LogoffEvents | Where-Object {
$LXml = [xml]$_.ToXml()
$LData = @{}
foreach ($D in $LXml.Event.EventData.Data) { $LData[$D.Name] = $D.'#text' }
$LData["TargetLogonId"] -eq $LogonId
} | Sort-Object TimeCreated | Select-Object -First 1

$LogoffTime = if ($MatchedLogoff) { $MatchedLogoff.TimeCreated } else { $null }
$Duration = if ($LogoffTime) {
($LogoffTime - $Logon.TimeCreated).TotalMinutes
} else {
$null # Сессия ещё активна или logoff не записан
}

[PSCustomObject]@{
Date = $Logon.TimeCreated.ToString("yyyy-MM-dd")
UserName = $UserName
LogonTime = $Logon.TimeCreated
LogoffTime = $LogoffTime
DurationMin = if ($Duration) { [int]$Duration } else { "Active/Unknown" }
ClientIP = $IpAddress
LogonId = $LogonId
}
}

# Агрегация по пользователям и дням
$DailySummary = $WorktimeRecords |
Where-Object { $_.DurationMin -ne "Active/Unknown" } |
Group-Object UserName, Date |
ForEach-Object {
[PSCustomObject]@{
UserName = ($_.Name -split ", ")[0]
Date = ($_.Name -split ", ")[1]
TotalMinutes = ($_.Group | Measure-Object DurationMin -Sum).Sum
TotalHours = [math]::Round((($_.Group | Measure-Object DurationMin -Sum).Sum / 60), 2)
Sessions = $_.Count
}
} | Sort-Object UserName, Date

$DailySummary | Export-Csv $ReportPath -NoTypeInformation -Encoding UTF8
Write-Host "[+] Work time report saved: $ReportPath" -ForegroundColor Green
$DailySummary | Format-Table -AutoSize

Глава 15. Полное руководство по устранению неисправностей

15.1. Диагностический режим -DebugMode

Все проблемы с комплексом следует начинать диагностировать с включения режима отладки. В этом режиме каждый шаг алгоритма сопровождается подробным журналом:

# Запуск в режиме полной отладки
.\1st-Remote-Session-Manager-Pro.ps1 -Sessions -DebugMode -Verbose

# Пример вывода в режиме отладки:
# [VERBOSE] PowerShell Version: 7.4.2 | OS: Windows Server 2022 (10.0.20348)
# [VERBOSE] Running as: DOMAIN\admin | Admin: True | Session: 2
# [INFO] Loading module: qwinsta-en.ps1
# [INFO] Loading module: qwinsta_IP_PS7.ps1
# [INFO] Method: WTS API (wtsapi32.dll P/Invoke)
# [VERBOSE] WTSEnumerateSessions: returned 4 sessions
# [INFO] Filtering: 4 total → 3 user sessions (1 system excluded)
# [INFO] IP correlation started: 3 sessions, 2 netstat connections
# [VERBOSE] Session 1 (ivanov): WTS API Direct → 192.168.10.55 [High]
# [VERBOSE] Session 2 (petrov): WTS API Direct → 192.168.10.82 [High]
# [VERBOSE] Session 3 (sidorov): State=Disc, trying EventLog...
# [VERBOSE] EventLog 4778 match: 10.8.0.14 [High]
# [INFO] Analysis complete: 3/3 sessions with IP, duration=127ms

# Сохранение отладочного вывода в файл
.\1st-Remote-Session-Manager-Pro.ps1 -Sessions -DebugMode *>&1 |
Tee-Object -FilePath "C:\Logs\debug-$(Get-Date -Format 'yyyyMMdd_HHmmss').txt"

15.2. Проблема: Shadow не работает — «This request is not supported»

Наиболее распространённая ошибка при попытке теневого подключения. Причины и решения:

# ДИАГНОСТИКА: Почему Shadow не работает?

function Diagnose-ShadowError {

Write-Host "═══ Shadow Connection Diagnostic ═══" -ForegroundColor Cyan

# Проверка 1: Реестровый параметр Shadow
$ShadowReg = (Get-ItemProperty "HKLM:\SYSTEM\CurrentControlSet\Control\Terminal Server" `
-Name "Shadow" -EA SilentlyContinue).Shadow

Write-Host "`n[1] Registry Shadow value: $ShadowReg" -ForegroundColor $(
if ($ShadowReg -in @(1,2,3,4)) {"Green"} else {"Red"}
)

switch ($ShadowReg) {
0 { Write-Warning "Shadow=0: Remote control is DISABLED. Run: -EnableShadow" }
1 { Write-Host " Mode: Full Control WITH consent (default)" -ForegroundColor Gray }
2 { Write-Host " Mode: Full Control WITHOUT consent" -ForegroundColor Yellow }
3 { Write-Host " Mode: View Only WITH consent" -ForegroundColor Gray }
4 { Write-Host " Mode: View Only WITHOUT consent" -ForegroundColor Yellow }
$null { Write-Warning "Shadow registry key missing! Treating as disabled." }
}

# Проверка 2: GPO-политика Remote Control
$GPOPath = "HKLM:\SOFTWARE\Policies\Microsoft\Windows NT\Terminal Services"
$GPOShadow = (Get-ItemProperty $GPOPath -Name "Shadow" -EA SilentlyContinue).Shadow

if ($null -ne $GPOShadow) {
Write-Host "`n[2] GPO Shadow override: $GPOShadow" -ForegroundColor Yellow
Write-Warning "GPO is controlling Shadow setting! Registry changes will be overridden."
Write-Host " GPO Path: Computer Config > Admin Templates > RDS > Remote Control"
Write-Host " GPO Setting: Set rules for remote control of Remote Desktop Session"
} else {
Write-Host "`n[2] No GPO override detected" -ForegroundColor Green
}

# Проверка 3: Версия Windows (XP-style vs Modern RDP Shadow)
$OSVer = [System.Environment]::OSVersion.Version
if ($OSVer.Major -lt 10) {
Write-Warning "`n[3] Windows $OSVer: Shadow via mstsc /shadow requires Windows 8.1+/Server 2012 R2+"
Write-Host " Alternative: Use tscon.exe or mstsc.exe /v /shadow (legacy syntax)"
} else {
Write-Host "`n[3] OS Version: Windows $OSVer ✓" -ForegroundColor Green
}

# Проверка 4: RDP listener status
$ListenerState = (qwinsta rdp-tcp 2>&1) -join " "
if ($ListenerState -match "Listen") {
Write-Host "`n[4] RDP Listener (rdp-tcp): Active ✓" -ForegroundColor Green
} else {
Write-Warning "`n[4] RDP Listener not found or not in Listen state!"
}

# Проверка 5: Наличие целевой сессии
Write-Host "`n[5] Current sessions:" -ForegroundColor Cyan
qwinsta 2>&1 | ForEach-Object { Write-Host " $_" -ForegroundColor Gray }

# Автоисправление
Write-Host "`n─── Recommended Fix ───" -ForegroundColor Yellow
if ($ShadowReg -eq 0 -or $null -eq $ShadowReg) {
Write-Host "Run: .\1st-Remote-Session-Manager-Pro.ps1 -EnableShadow"
}
if ($null -ne $GPOShadow -and $GPOShadow -eq 0) {
Write-Host "Fix GPO: gpedit.msc → [path above] → Set to 'Full Control without user consent'"
}
}

Diagnose-ShadowError

15.3. Проблема: qwinsta возвращает некорректный вывод (кириллица вместо данных)

# ДИАГНОСТИКА И ИСПРАВЛЕНИЕ: Проблема с кодировкой qwinsta

# Симптом: вместо состояний сессий отображаются кракозябры
# Причина: qwinsta выводит OEM-кодировку (CP866 для RU Windows)
# а PowerShell ожидает UTF-8 или CP1251

# Диагностика текущей кодовой страницы
Write-Host "Current OEM CodePage : $([System.Text.Encoding]::Default.CodePage)"
Write-Host "Current Console CP : $([Console]::OutputEncoding.CodePage)"

# Быстрое исправление для текущей сессии
[Console]::OutputEncoding = [System.Text.Encoding]::GetEncoding(866) # OEM Russian
$Output = qwinsta 2>&1
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8

# Альтернатива: использовать cmd.exe с явной установкой кодовой страницы
$RawOutput = cmd /c "chcp 437 >nul 2>&1 && qwinsta" 2>&1

# Проверка успешности
Write-Host "Raw output:"
$RawOutput | Select-Object -Skip 1 | ForEach-Object { Write-Host " $_" }

# Если проблема сохраняется — использовать WTS API вместо qwinsta
# WTS API не зависит от кодировки терминала
.\qwinsta-en.ps1 -Method WtsApi # Принудительное использование WTS API

15.4. Проблема: WTS API возвращает «Access Denied»

# ДИАГНОСТИКА: WTS API Access Denied

# Причина 1: Недостаточно прав (не администратор)
$IsAdmin = ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole(
[Security.Principal.WindowsBuiltInRole]::Administrator
)
Write-Host "Running as Administrator: $IsAdmin"

if (-not $IsAdmin) {
Write-Warning "Most WTS API operations require Administrator rights."
Write-Warning "Run PowerShell as Administrator or use -Elevate parameter."
}

# Причина 2: Служба Terminal Services не запущена
$TSService = Get-Service -Name TermService -ErrorAction SilentlyContinue
Write-Host "Terminal Services status: $($TSService.Status)"

if ($TSService.Status -ne "Running") {
Write-Warning "Terminal Services is NOT running! Starting..."
Start-Service TermService
Set-Service TermService -StartupType Automatic
}

# Причина 3: Политика ограниченного удалённого управления
$AllowGetInfo = (Get-ItemProperty "HKLM:\SYSTEM\CurrentControlSet\Control\Terminal Server" `
-Name "AllowGetInfo" -EA SilentlyContinue).AllowGetInfo
if ($AllowGetInfo -eq 0) {
Write-Warning "AllowGetInfo=0: WTS session query is restricted by registry."
Set-ItemProperty "HKLM:\SYSTEM\CurrentControlSet\Control\Terminal Server" `
-Name "AllowGetInfo" -Value 1 -Type DWord
Write-Host "[+] AllowGetInfo set to 1" -ForegroundColor Green
}

# Причина 4: Проверка членства в группе Remote Desktop Users
$RDPGroup = [ADSI]"WinNT://./Remote Desktop Users,group"
$Members = @($RDPGroup.Invoke("Members")) | ForEach-Object { $_.GetType().InvokeMember("Name", "GetProperty", $null, $_, $null) }
Write-Host "Remote Desktop Users group members:"
$Members | ForEach-Object { Write-Host " - $_" }

15.5. Проблема: IP-адрес не определяется (Confidence=None)

Пошаговая диагностика почему корреляция IP не работает:

# ДИАГНОСТИКА: Почему IP не определяется?

function Diagnose-IPCorrelation {
param([int]$SessionId)

Write-Host "═══ IP Correlation Diagnostic for Session $SessionId ═══" -ForegroundColor Cyan

# Шаг 1: WTS API прямой запрос
Write-Host "`n[Step 1] WTS API direct query..." -ForegroundColor Yellow
try {
$WtsIp = Get-WtsClientIpForSession -SessionId $SessionId
Write-Host " Result: $WtsIp" -ForegroundColor $(if ($WtsIp) {"Green"} else {"Red"})
if (-not $WtsIp -or $WtsIp -eq "0.0.0.0") {
Write-Warning " WTS API returned empty/zero IP."
Write-Warning " Possible causes: session is Disconnected, client behind NAT, RDP over RDP"
}
} catch {
Write-Host " WTS API failed: $_" -ForegroundColor Red
}

# Шаг 2: netstat активные соединения
Write-Host "`n[Step 2] Active TCP connections to RDP port..." -ForegroundColor Yellow
$RDPConnections = Get-NetTCPConnection -LocalPort 3389 -State Established -EA SilentlyContinue
if ($RDPConnections) {
$RDPConnections | Format-Table LocalAddress, LocalPort, RemoteAddress, RemotePort, State
} else {
Write-Warning " No active TCP connections to port 3389 found!"
Write-Warning " Check if RDP uses a non-standard port:"
$RDPPort = (Get-ItemProperty "HKLM:\SYSTEM\CurrentControlSet\Control\Terminal Server\WinStations\RDP-Tcp" `
-Name "PortNumber").PortNumber
Write-Host " Registry RDP Port: $RDPPort"
if ($RDPPort -ne 3389) {
Write-Warning " Non-standard port! Use -RdpPort $RDPPort parameter."
}
}

# Шаг 3: Журнал событий
Write-Host "`n[Step 3] Event Log records for session $SessionId..." -ForegroundColor Yellow
$RecentEvents = Get-WinEvent -LogName Security `
-FilterXPath "*[System[(EventID=4624 or EventID=4778)]]" `
-MaxEvents 50 -EA SilentlyContinue |
Where-Object {
$D = @{}
foreach ($Item in ([xml]$_.ToXml()).Event.EventData.Data) { $D[$Item.Name] = $Item.'#text' }
$D["LogonType"] -eq "10"
}

if ($RecentEvents) {
Write-Host " Found $($RecentEvents.Count) recent RDP logon events" -ForegroundColor Green
$RecentEvents | Select-Object -First 5 |
ForEach-Object {
$D = @{}
foreach ($Item in ([xml]$_.ToXml()).Event.EventData.Data) { $D[$Item.Name] = $Item.'#text' }
Write-Host " $($_.TimeCreated) User=$($D['TargetUserName']) IP=$($D['IpAddress'])"
}
} else {
Write-Warning " No recent RDP logon events found in Security log."
Write-Warning " Check: Is Security Auditing enabled? (auditpol /get /category:*)"
& auditpol /get /subcategory:"Logon" 2>&1 | Write-Host
}

# Шаг 4: TermServices LocalSessionManager log
Write-Host "`n[Step 4] TermServices LocalSessionManager log..." -ForegroundColor Yellow
$TSLog = Get-WinEvent `
-LogName "Microsoft-Windows-TerminalServices-LocalSessionManager/Operational" `
-MaxEvents 20 -EA SilentlyContinue |
Where-Object { $_.Id -in @(21, 25) }

if ($TSLog) {
Write-Host " Found $($TSLog.Count) TS session events" -ForegroundColor Green
} else {
Write-Warning " TermServices LocalSessionManager log is empty or inaccessible."
Write-Warning " Enable with: wevtutil sl Microsoft-Windows-TerminalServices-LocalSessionManager/Operational /e:true"
}

Write-Host "`n─── Summary ───" -ForegroundColor Yellow
Write-Host "If all steps above failed:"
Write-Host " 1. Ensure you run as Administrator"
Write-Host " 2. Enable Security Auditing: auditpol /set /subcategory:'Logon' /success:enable"
Write-Host " 3. Enable TermServices log: wevtutil sl Microsoft-Windows-TerminalServices-LocalSessionManager/Operational /e:true"
Write-Host " 4. Check non-standard RDP port with -RdpPort parameter"
Write-Host " 5. For sessions behind VPN/RDP-over-RDP: IP correlation may be fundamentally impossible"
}

15.6. Проблема: mstsc /shadow открывается и сразу закрывается

# ДИАГНОСТИКА: mstsc /shadow немедленно закрывается

# Причина 1: Целевая сессия уже завершилась
$Session = Get-WtsSessionsEn | Where-Object { $_.SessionId -eq $TargetId }
if (-not $Session) {
Write-Error "Session $TargetId no longer exists. Refresh session list."
}

# Причина 2: Попытка подключиться к собственной сессии
$CurrentSessId = [System.Diagnostics.Process]::GetCurrentProcess().SessionId
if ($TargetId -eq $CurrentSessId) {
Write-Error "Cannot shadow your own session (SessionId=$CurrentSessId)."
}

# Причина 3: Windows 10 v1803+ баг с /noConsentPrompt
# Решение: использовать tscon.exe для принудительного Shadow
$OSBuild = [System.Environment]::OSVersion.Version.Build
if ($OSBuild -ge 17134) { # Windows 10 1803+
Write-Warning "OS Build $OSBuild: mstsc /shadow may have issues."
Write-Host "Alternative method via Group Policy:"
Write-Host " gpedit.msc → Computer Configuration → Admin Templates"
Write-Host " → Windows Components → Remote Desktop Services"
Write-Host " → Remote Desktop Session Host → Connections"
Write-Host " → 'Set rules for remote control of RDS user sessions'"
}

# Причина 4: Сессия в состоянии Disconnected (нельзя shadow)
if ($Session -and $Session.State -eq "Disc") {
Write-Warning "Session $TargetId is Disconnected. Shadow requires Active session."
Write-Host "Ask user to reconnect, or use logoff/disconnect for session management."
}

# Причина 5: Отсутствие прав в группе Remote Desktop Users
# Или ограничение GPO 'Deny log on through Remote Desktop Services'
Write-Host "`nChecking relevant policies..."
$DenyRDP = (Get-LocalGroupMember "Remote Desktop Users" -ErrorAction SilentlyContinue)
Write-Host "Remote Desktop Users: $($DenyRDP.Name -join ', ')"

15.7. Таблица кодов ошибок и их решений

Код ошибкиИсточникОписаниеРешение
1722WinRMRPC сервер недоступенЗапустить: sc start RpcSs
1223UACПользователь отменил UACПовторить запуск и принять UAC
5WTS APIОтказ в доступеЗапустить от имени Администратора
7mstscНеверное имя сервераПроверить -ServerName
1314ShadowНедостаточно правНастроить Shadow в реестре
7022TermServiceСлужба не запустиласьStart-Service TermService
0x80070005PowerShell RemotingОтказ в доступе WS-ManНастроить WinRM: winrm quickconfig
0x80090304CredSSPОшибка CredSSPОбновить CredSSP: Enable-WSManCredSSP

15.8. Полный диагностический скрипт Test-RDPManagerHealth

function Test-RDPManagerHealth {
<#
.SYNOPSIS
Complete health check for RDP Session Manager environment
Run this when experiencing any issues with the toolkit
#>


$Checks = [System.Collections.Generic.List[PSCustomObject]]::new()
$Passed = 0
$Failed = 0
$Warning = 0

function Add-Check {
param([string]$Name, [string]$Status, [string]$Details, [string]$Fix = "")
$Checks.Add([PSCustomObject]@{
Check = $Name
Status = $Status
Details = $Details
Fix = $Fix
})
switch ($Status) {
"PASS" { $script:Passed++ }
"FAIL" { $script:Failed++ }
"WARN" { $script:Warning++ }
}
}

Write-Host "Running RDP Manager Health Check..." -ForegroundColor Cyan

# ── Check 1: PowerShell Version ─────────────────────────────────────
$PSVer = $PSVersionTable.PSVersion
if ($PSVer.Major -ge 7) {
Add-Check "PowerShell Version" "PASS" "PS $PSVer (PS7 — full feature set)"
} elseif ($PSVer.Major -ge 5) {
Add-Check "PowerShell Version" "WARN" "PS $PSVer (PS5.1 — classes may have limitations)" `
"Install PowerShell 7: winget install Microsoft.PowerShell"
} else {
Add-Check "PowerShell Version" "FAIL" "PS $PSVer (minimum: PS5.0)" `
"Upgrade PowerShell from microsoft.com/powershell"
}

# ── Check 2: Administrator Rights ───────────────────────────────────
$IsAdmin = ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole(
[Security.Principal.WindowsBuiltInRole]::Administrator
)
if ($IsAdmin) {
Add-Check "Administrator Rights" "PASS" "Running as Administrator"
} else {
Add-Check "Administrator Rights" "FAIL" "Not running as Administrator" `
"Right-click PowerShell and select 'Run as Administrator'"
}

# ── Check 3: Terminal Services ───────────────────────────────────────
$TSService = Get-Service TermService -EA SilentlyContinue
if ($TSService.Status -eq "Running") {
Add-Check "Terminal Services" "PASS" "TermService is Running"
} else {
Add-Check "Terminal Services" "FAIL" "TermService status: $($TSService.Status)" `
"Start-Service TermService; Set-Service TermService -StartupType Automatic"
}

# ── Check 4: RDP Enabled ────────────────────────────────────────────
$RDPEnabled = (Get-ItemProperty "HKLM:\SYSTEM\CurrentControlSet\Control\Terminal Server" `
-Name "fDenyTSConnections" -EA SilentlyContinue).fDenyTSConnections
if ($RDPEnabled -eq 0) {
Add-Check "RDP Enabled" "PASS" "RDP is enabled (fDenyTSConnections=0)"
} else {
Add-Check "RDP Enabled" "FAIL" "RDP is disabled (fDenyTSConnections=$RDPEnabled)" `
".\1st-Remote-Session-Manager-Pro.ps1 -EnableRDP"
}

# ── Check 5: Shadow Configuration ───────────────────────────────────
$ShadowVal = (Get-ItemProperty "HKLM:\SYSTEM\CurrentControlSet\Control\Terminal Server" `
-Name "Shadow" -EA SilentlyContinue).Shadow
if ($ShadowVal -in @(1, 2, 3, 4)) {
Add-Check "Shadow Config" "PASS" "Shadow mode: $ShadowVal (enabled)"
} elseif ($ShadowVal -eq 0) {
Add-Check "Shadow Config" "FAIL" "Shadow is disabled (Shadow=0)" `
".\1st-Remote-Session-Manager-Pro.ps1 -EnableShadow"
} else {
Add-Check "Shadow Config" "WARN" "Shadow registry key missing (treating as disabled)" `
".\1st-Remote-Session-Manager-Pro.ps1 -EnableShadow"
}

# ── Check 6: Firewall RDP Rules ──────────────────────────────────────
$FWRules = Get-NetFirewallRule -DisplayGroup "Remote Desktop" -EA SilentlyContinue |
Where-Object { $_.Enabled -eq "True" -and $_.Direction -eq "Inbound" }
if ($FWRules) {
Add-Check "Firewall Rules" "PASS" "$($FWRules.Count) active RDP firewall rules"
} else {
Add-Check "Firewall Rules" "WARN" "No active RDP firewall rules found" `
".\1st-Remote-Session-Manager-Pro.ps1 -EnableFirewall"
}

# ── Check 7: WTS API Accessibility ──────────────────────────────────
try {
$TestSessions = Get-WtsSessionsEn -ErrorAction Stop
Add-Check "WTS API" "PASS" "WTS API accessible, returned $($TestSessions.Count) sessions"
} catch {
Add-Check "WTS API" "FAIL" "WTS API failed: $_" `
"Ensure running as Administrator and TermService is running"
}

# ── Check 8: Event Log Access ─────────────────────────────────────────
try {
$TestEvent = Get-WinEvent -LogName Security -MaxEvents 1 -EA Stop | Out-Null
Add-Check "Security Event Log" "PASS" "Security log accessible"
} catch {
Add-Check "Security Event Log" "WARN" "Security log inaccessible: $_" `
"Requires Local Security Policy: Audit Logon Events = Success,Failure"
}

# ── Check 9: PowerShell Execution Policy ────────────────────────────
$Policy = Get-ExecutionPolicy
if ($Policy -in @("RemoteSigned", "Unrestricted", "Bypass")) {
Add-Check "Execution Policy" "PASS" "ExecutionPolicy: $Policy"
} else {
Add-Check "Execution Policy" "FAIL" "ExecutionPolicy: $Policy (too restrictive)" `
"Set-ExecutionPolicy RemoteSigned -Scope LocalMachine"
}

# ── Check 10: Module Files ────────────────────────────────────────────
$RequiredFiles = @(
"1st-Remote-Session-Manager-Pro.ps1",
"qwinsta-en.ps1",
"qwinsta_IP_PS7.ps1"
)
foreach ($File in $RequiredFiles) {
$FilePath = Join-Path $PSScriptRoot $File
if (Test-Path $FilePath) {
$Size = (Get-Item $FilePath).Length
Add-Check "File: $File" "PASS" "Present ($([int]($Size/1KB)) KB)"
} else {
Add-Check "File: $File" "FAIL" "File not found: $FilePath" `
"Download from github.com/paulmann/1st-Remote-Session-Manager-Pro"
}
}

# ── Итоговый отчёт ────────────────────────────────────────────────────
Write-Host "`n═══ Health Check Results ═══" -ForegroundColor Cyan
$Checks | ForEach-Object {
$Color = switch ($_.Status) {
"PASS" { "Green" }; "FAIL" { "Red" }; "WARN" { "Yellow" }
}
$Icon = switch ($_.Status) { "PASS" { "✓" }; "FAIL" { "✗" }; "WARN" { "!" } }
Write-Host " [$Icon] $($_.Check.PadRight(30)) $($_.Details)" -ForegroundColor $Color
if ($_.Fix -and $_.Status -ne "PASS") {
Write-Host " Fix: $($_.Fix)" -ForegroundColor DarkGray
}
}

Write-Host "`n Passed : $Passed / $(Passed+Failed+Warning)" -ForegroundColor Green
Write-Host " Failed : $Failed" -ForegroundColor $(if ($Failed -gt 0) { "Red" } else { "Gray" })
Write-Host " Warnings: $Warning" -ForegroundColor $(if ($Warning -gt 0) { "Yellow" } else { "Gray" })

if ($Failed -eq 0 -and $Warning -eq 0) {
Write-Host "`n[✓] All checks passed. RDP Manager is fully operational." -ForegroundColor Green
} elseif ($Failed -eq 0) {
Write-Host "`n[!] All critical checks passed. Review warnings for optimal operation." -ForegroundColor Yellow
} else {
Write-Host "`n[✗] $Failed critical issues found. Address FAIL items before use." -ForegroundColor Red
}

return $Checks
}

# Запуск диагностики
Test-RDPManagerHealth

15.9. FAQ — Часто задаваемые вопросы

Q: Работает ли комплекс на Windows Server Core (без GUI)?
A: Частично. Модули qwinsta-en.ps1 и qwinsta_IP_PS7.ps1 работают полностью — вывод данных в JSON/CSV/текст. Теневое подключение (mstsc /shadow) требует GUI-сессии и на Server Core недоступно. Для управления сессиями на Core-сервере используйте -Disconnect и -Logoff — они работают через WTS API без GUI.

Q: Можно ли подключиться к сессии на удалённом сервере?
A: Теневое подключение (Shadow) работает только для локальных сессий текущего сервера — это ограничение протокола mstsc /shadow. Для теневого подключения к удалённому серверу: сначала подключитесь RDP к целевому серверу, затем запустите комплекс уже там.

Q: Почему у некоторых сессий IP = N/A даже при Confidence=None?
A: Несколько сценариев: (1) сессия работает через RDP-over-RDP (каскадное подключение) — WTS API видит IP промежуточного сервера; (2) пользователь подключился с устройства за строгим NAT без сохранения записей в EventLog; (3) журнал аудита безопасности отключён или очищен. Включите аудит: auditpol /set /subcategory:"Logon" /success:enable.

Q: Как изменить порт RDP с 3389 на нестандартный?

# Изменение порта RDP (для всех модулей комплекса)
$NewPort = 55389

# Изменение в реестре
Set-ItemProperty "HKLM:\SYSTEM\CurrentControlSet\Control\Terminal Server\WinStations\RDP-Tcp" `
-Name "PortNumber" -Value $NewPort -Type DWord

# Обновление правила брандмауэра
Remove-NetFirewallRule -DisplayName "RDP Custom*" -ErrorAction SilentlyContinue
New-NetFirewallRule -DisplayName "RDP Custom Port $NewPort" `
-Protocol TCP -LocalPort $NewPort -Direction Inbound -Action Allow

# Использование комплекса с нестандартным портом
.\qwinsta_IP_PS7.ps1 -Command Analyze -RdpPort $NewPort

Q: Как запустить комплекс без прав администратора (только чтение)?

# Режим ограниченного чтения (без Admin):
# Доступны: список сессий, базовая IP-корреляция через netstat
# Недоступны: журнал событий Security, WTS API, изменения реестра

.\1st-Remote-Session-Manager-Pro.ps1 -Sessions -ReadOnly
# или
.\qwinsta-en.ps1 # Только qwinsta — работает без Admin

15.10. Журнал изменений и версии

Версия  Дата        Изменения
────── ────────── ────────────────────────────────────────────────────────
4.2 2026-04-25 Добавлена поддержка Windows Server 2025
Улучшена IP-корреляция для VPN-сессий (WireGuard/Amnezia)
Новый формат вывода YAML
Исправлена ошибка парсинга qwinsta на японской Windows 11
Добавлен параметр -HelpDesk (интерактивный workflow)

4.1 2025-11-10 PowerShell 7.4 совместимость
HTML-отчёт с адаптивным дизайном (mobile-friendly)
Мониторинг: обнаружение изменений между итерациями
Исправлена проблема с кодировкой на Windows Server Core

4.0 2025-06-15 Полное переписание архитектуры
Введены классы PS: RdpSession, RdpConnection, RdpMatch
Многоуровневая IP-корреляция с Confidence Level
Модульная структура (3 файла вместо 1)
Поддержка 7 форматов экспорта
JEA-совместимость

3.x 2024-xx-xx Legacy версия (однофайловый скрипт)
Базовая поддержка Shadow и Disconnect/Logoff
Только qwinsta-парсинг без WTS API


Глава 16. Полный справочник параметров командной строки (CLI Reference)

16.1. Обзор синтаксиса вызова

Комплекс состоит из трёх исполняемых модулей, каждый из которых имеет собственный CLI-интерфейс. Все три модуля доступны через главный скрипт или напрямую.

Синтаксис главного модуля:
.\1st-Remote-Session-Manager-Pro.ps1 [<ParameterSet>] [<Options>]

Синтаксис модуля сессий:
.\qwinsta-en.ps1 [<Method>] [<Options>]

Синтаксис модуля IP-корреляции:
.\qwinsta_IP_PS7.ps1 -Command <Command> [<Options>]

Все параметры поддерживают короткие псевдонимы (aliases) для быстрого интерактивного использования. Каждый параметр можно сокращать до минимально различимого префикса — стандартное поведение PowerShell.

16.2. Основные параметры главного модуля

Группа: Просмотр сессий

ПараметрПсевдонимТипПо умолчаниюОписание
-Sessions-eSwitch$falseВывести список всех сессий с деталями
-WithIPSwitch$falseДобавить IP-адреса клиентов (запускает qwinsta_IP_PS7)
-WithHistorySwitch$falseДобавить историю подключений из EventLog
-IncludeSystemSwitch$falseВключить системные сессии (Services, Listen)
-SessionId-iInt32-1ID конкретной сессии для операции
-ComputerName-cString$env:COMPUTERNAMEИмя целевого компьютера
-DetailedSwitch$falseРасширенный вывод с дополнительными полями
# Примеры группы Sessions:
.\1st-Remote-Session-Manager-Pro.ps1 -Sessions
.\1st-Remote-Session-Manager-Pro.ps1 -e # короткий псевдоним
.\1st-Remote-Session-Manager-Pro.ps1 -Sessions -WithIP -IncludeSystem
.\1st-Remote-Session-Manager-Pro.ps1 -Sessions -ComputerName "RDS-SRV-01"
.\1st-Remote-Session-Manager-Pro.ps1 -Sessions -SessionId 3 -Detailed

Группа: Теневое подключение

ПараметрПсевдонимТипПо умолчаниюОписание
-SessionId-iInt32обязателенID целевой сессии для Shadow
-ViewOnly-oSwitch$falseРежим только просмотра (без /control)
-NoConsentPromptSwitch$falseБез запроса согласия пользователя
-Force-fSwitch$falseПропустить проверки и предупреждения
-ServerNameString$env:COMPUTERNAMEИмя сервера для mstsc /v:
# Примеры группы Shadow:
.\1st-Remote-Session-Manager-Pro.ps1 -SessionId 2
.\1st-Remote-Session-Manager-Pro.ps1 -i 2 -o # Короткие псевдонимы
.\1st-Remote-Session-Manager-Pro.ps1 -SessionId 2 -ViewOnly
.\1st-Remote-Session-Manager-Pro.ps1 -SessionId 2 -ViewOnly -NoConsentPrompt
.\1st-Remote-Session-Manager-Pro.ps1 -SessionId 2 -Force # Без подтверждений

Группа: Управление сессиями

ПараметрПсевдонимТипПо умолчаниюОписание
-Disconnect-xSwitch$falseОтключить сессию (мягкое завершение)
-Logoff-lSwitch$falseЗавершить сессию (жёсткое завершение)
-Message-mStringОтправить сообщение в сессию
-Force-fSwitch$falseБез запроса подтверждения
-GraceSecondsInt320Секунд предупреждения перед Logoff
# Примеры группы Management:
.\1st-Remote-Session-Manager-Pro.ps1 -SessionId 3 -Disconnect
.\1st-Remote-Session-Manager-Pro.ps1 -SessionId 3 -x # Псевдоним
.\1st-Remote-Session-Manager-Pro.ps1 -SessionId 4 -Logoff
.\1st-Remote-Session-Manager-Pro.ps1 -SessionId 4 -Logoff -Force
.\1st-Remote-Session-Manager-Pro.ps1 -SessionId 4 -Logoff -GraceSeconds 60
.\1st-Remote-Session-Manager-Pro.ps1 -SessionId 5 -Message "Reboot in 10 minutes"

Группа: Конфигурация RDP и Shadow

ПараметрПсевдонимТипПо умолчаниюОписание
-EnableRDPSwitch$falseВключить RDP (fDenyTSConnections=0)
-DisableRDPSwitch$falseОтключить RDP (fDenyTSConnections=1)
-EnableShadowSwitch$falseНастроить Shadow в реестре
-ShadowModeInt322Режим Shadow: 1-4
-EnableNLASwitch$falseВключить Network Level Authentication
-DisableNLASwitch$falseОтключить NLA
-EnableFirewallSwitch$falseВключить правила брандмауэра RDP
-RdpPortInt323389Нестандартный порт RDP
# Примеры группы Configuration:
.\1st-Remote-Session-Manager-Pro.ps1 -EnableRDP
.\1st-Remote-Session-Manager-Pro.ps1 -EnableShadow
.\1st-Remote-Session-Manager-Pro.ps1 -EnableShadow -ShadowMode 4 # ViewOnly без согласия
.\1st-Remote-Session-Manager-Pro.ps1 -EnableNLA
.\1st-Remote-Session-Manager-Pro.ps1 -EnableFirewall
.\1st-Remote-Session-Manager-Pro.ps1 -RdpPort 55389 -EnableFirewall

Группа: Статус, вывод и диагностика

ПараметрПсевдонимТипПо умолчаниюОписание
-Status-sSwitch$falseОтчёт о конфигурации системы
-Help-h, -?Switch$falseСправка с примерами
-Version-vSwitch$falseВерсия скрипта
-Update-uSwitch$falseОбновить с GitHub
-DebugMode-dSwitch$falseПодробный отладочный вывод
-Quiet-qSwitch$falseМинимальный вывод
-NoColorSwitch$falseВывод без цветового выделения
-JsonSwitch$falseЭкспорт результата в JSON
-CsvSwitch$falseЭкспорт результата в CSV
-ExportStringФормат: JSON|CSV|XML|HTML|MD|YAML
-OutputFileStringПуть к файлу для сохранения вывода
# Примеры группы Output:
.\1st-Remote-Session-Manager-Pro.ps1 -Status
.\1st-Remote-Session-Manager-Pro.ps1 -Help
.\1st-Remote-Session-Manager-Pro.ps1 -Version
.\1st-Remote-Session-Manager-Pro.ps1 -Update
.\1st-Remote-Session-Manager-Pro.ps1 -Sessions -Json
.\1st-Remote-Session-Manager-Pro.ps1 -Sessions -Export HTML -OutputFile "C:\report.html"
.\1st-Remote-Session-Manager-Pro.ps1 -Sessions -DebugMode -Verbose *>&1 | Tee-Object debug.log

16.3. Значения параметра -ShadowMode

ЗначениеНазваниеУправлениеСогласие пользователяТипичное применение
0DisabledShadow полностью отключён
1Full Control + Consent✅ Полное✅ ТребуетсяПо умолчанию Windows, HelpDesk
2Full Control, No Consent✅ Полное❌ Не требуетсяАдминистрирование, срочная помощь
3View Only + Consent❌ Только просмотр✅ ТребуетсяАудит с уведомлением
4View Only, No Consent❌ Только просмотр❌ Не требуетсяСкрытый мониторинг
# Быстрая настройка нужного режима:
.\1st-Remote-Session-Manager-Pro.ps1 -EnableShadow -ShadowMode 1 # HelpDesk default
.\1st-Remote-Session-Manager-Pro.ps1 -EnableShadow -ShadowMode 2 # Full admin control
.\1st-Remote-Session-Manager-Pro.ps1 -EnableShadow -ShadowMode 4 # Silent monitoring

16.4. Параметры модуля qwinsta-en.ps1

# Синтаксис:
.\qwinsta-en.ps1 [[-Method] <String>] [[-ServerName] <String>]
[-IncludeAll] [-Raw] [-Json] [-NoColor] [-Debug]

# Параметры:
# -Method : WtsApi | Qwinsta | Auto (default: Auto)
# -ServerName : Имя сервера (default: localhost)
# -IncludeAll : Включить системные сессии
# -Raw : Вывод без форматирования (массив объектов)
# -Json : JSON-вывод
# -NoColor : Монохромный вывод
# -Debug : Отладочный вывод

# Примеры:
.\qwinsta-en.ps1 # Автоматический выбор метода
.\qwinsta-en.ps1 -Method WtsApi # Принудительно WTS API
.\qwinsta-en.ps1 -Method Qwinsta # Принудительно qwinsta.exe
.\qwinsta-en.ps1 -Json | ConvertFrom-Json # Использование в скриптах
.\qwinsta-en.ps1 -ServerName "RDS-SRV-01" # Удалённый сервер через qwinsta /server
.\qwinsta-en.ps1 -IncludeAll # Включая Session 0 и Listen

16.5. Параметры модуля qwinsta_IP_PS7.ps1

# Синтаксис:
.\qwinsta_IP_PS7.ps1 [-Command <String>]
[-OutputFormat <String>]
[-OutputFile <String>]
[-RdpPort <Int32>]
[-IntervalSeconds <Int32>]
[-MaxLogEvents <Int32>]
[-IncludeSystem]
[-NoColor]
[-Quiet]

# Параметры:
# -Command : Analyze | Export | Monitor | Summary |
# GetSessions | GetConnections (default: Analyze)
# -OutputFormat : Table | JSON | CSV | XML | HTML | MARKDOWN | YAML
# -OutputFile : Путь к файлу вывода
# -RdpPort : Порт RDP (default: 3389)
# -IntervalSeconds : Интервал мониторинга в секундах (default: 30)
# -MaxLogEvents : Лимит событий из EventLog (default: 500)
# -IncludeSystem : Включить системные сессии
# -NoColor : Монохромный вывод
# -Quiet : Тихий режим

# Примеры:
.\qwinsta_IP_PS7.ps1 -Command Analyze
.\qwinsta_IP_PS7.ps1 -Command Export -OutputFormat JSON -OutputFile "C:\rdp.json"
.\qwinsta_IP_PS7.ps1 -Command Export -OutputFormat HTML -OutputFile "C:\rdp.html"
.\qwinsta_IP_PS7.ps1 -Command Monitor -IntervalSeconds 15
.\qwinsta_IP_PS7.ps1 -Command Summary -Quiet
.\qwinsta_IP_PS7.ps1 -Command Analyze -RdpPort 55389 -MaxLogEvents 1000
.\qwinsta_IP_PS7.ps1 -Command GetConnections | ConvertFrom-Json

16.6. Полная таблица совместимости параметров

Некоторые параметры несовместимы или требуют совместного использования. Матрица совместимости:

ПараметрТребуетНесовместим сПримечание
-SessionIdОбязателен для Shadow/Disconnect/Logoff
-ViewOnly-SessionIdТолько для Shadow
-NoConsentPrompt-SessionIdТолько для Shadow
-Disconnect-SessionId-Logoff, -ViewOnlyВзаимоисключающие операции
-Logoff-SessionId-Disconnect, -ViewOnlyВзаимоисключающие операции
-Message-SessionId-Disconnect, -LogoffТолько для активных сессий
-GraceSeconds-LogoffБез -Logoff игнорируется
-WithIP-SessionsТолько с -Sessions
-Export-Json, -CsvПолный формат экспорта
-OutputFile-ExportБез -Export игнорируется
-ShadowMode-EnableShadowТолько с -EnableShadow
-NoConsentPromptShadow≠1,3 в реестреИначе выдаёт предупреждение

16.7. Возвращаемые коды завершения (Exit Codes)

КодЗначениеТипичная причина
0УспехОперация выполнена без ошибок
1Общая ошибкаНеверные параметры, неизвестная ошибка
2Недостаточно правНе запущен от администратора, UAC отменён
3Сессия не найденаНеверный SessionId
4Shadow не настроенShadow=0 в реестре и -Force не указан
5Отменено пользователемПользователь выбрал N на подтверждении
10Ошибка конфигурацииНе удалось записать в реестр
11Ошибка брандмауэраНе удалось настроить firewall
20Ошибка WTS APIWTS API недоступен
21Ошибка qwinstaqwinsta.exe вернул ненулевой код
127Файл не найденМодульный файл .ps1 не найден
# Использование Exit Code в автоматизации:
.\1st-Remote-Session-Manager-Pro.ps1 -SessionId 2 -Disconnect -Force
if ($LASTEXITCODE -eq 0) {
Write-Host "Session disconnected successfully"
} elseif ($LASTEXITCODE -eq 3) {
Write-Host "Session not found — may have already ended"
} else {
Write-Error "Operation failed with code $LASTEXITCODE"
}

16.8. Использование в скриптах и Pipeline

Модули возвращают объекты PowerShell, совместимые с pipeline:

# ── Использование в конвейере (pipeline) ────────────────────────────

# Получить все Disconnected-сессии и завершить их одной командой
.\1st-Remote-Session-Manager-Pro.ps1 -Sessions -Json |
ConvertFrom-Json |
Where-Object { $_.State -eq "Disc" -and $_.SessionId -gt 0 } |
ForEach-Object {
.\1st-Remote-Session-Manager-Pro.ps1 -SessionId $_.SessionId -Logoff -Force
}

# Найти сессию конкретного пользователя
$UserSession = .\1st-Remote-Session-Manager-Pro.ps1 -Sessions -Json |
ConvertFrom-Json |
Where-Object { $_.UserName -like "*ivanov*" } |
Select-Object -First 1

if ($UserSession) {
Write-Host "Found: ID=$($UserSession.SessionId)"
}

# Экспорт в hashtable для дальнейшей обработки
$SessionsHash = .\1st-Remote-Session-Manager-Pro.ps1 -Sessions -Json |
ConvertFrom-Json |
Group-Object State -AsHashTable -AsString

Write-Host "Active: $($SessionsHash.Active.Count)"
Write-Host "Disconnected: $($SessionsHash.Disc.Count)"

# ── Использование qwinsta-en.ps1 как библиотеки ─────────────────────
# Импорт функций в текущую сессию (dot-sourcing)
. .\qwinsta-en.ps1

# После этого функции доступны напрямую:
$Sessions = Get-WtsSessionsEn
$Sessions | Where-Object State -eq "Active" | ForEach-Object {
Write-Host "Active: $($_.UserName) (ID $($_.SessionId))"
}

# ── qwinsta_IP_PS7.ps1 как библиотека ──────────────────────────────
. .\qwinsta_IP_PS7.ps1

# Использование классов и функций напрямую:
$Result = Invoke-RdpAnalysis -RdpPort 3389
$Result.Matches | Where-Object Confidence -eq "High" |
Format-Table SessionId, UserName, IpAddress

Глава 17. Глоссарий терминов и концепций

17.1. Терминология Windows Session Management

Active Directory (AD) — служба каталогов Microsoft, обеспечивающая централизованную аутентификацию и авторизацию в корпоративных сетях. RDP используется совместно с AD для Single Sign-On (SSO) через Kerberos.

AppLocker — механизм Windows, позволяющий контролировать запуск приложений и скриптов по правилам (издатель, путь, хэш файла). Может блокировать выполнение PowerShell-скриптов комплекса, требуя добавления исключений в политику.

Console Session (Session 0) — специальная изолированная сессия в Windows Vista и новее, в которой работают службы Windows. Сессия 0 не имеет интерактивного рабочего стола и не доступна для обычного входа или теневого подключения.

CredSSP (Credential Security Support Provider) — протокол аутентификации, передающий учётные данные с клиента на сервер для использования в remote-операциях. Необходим для Network Level Authentication (NLA) в RDP.

FQDN (Fully Qualified Domain Name) — полное доменное имя хоста, однозначно идентифицирующее его в DNS-иерархии, например: rds-srv-01.domain.local.

GPO (Group Policy Object) — объект групповой политики Active Directory, определяющий настройки для компьютеров и пользователей в домене. GPO может переопределять локальные реестровые параметры, в том числе настройки Shadow.

High Mandatory Level (SID: S-1-16-12288) — уровень обязательного контроля целостности (Mandatory Integrity Control), соответствующий повышенным правам в Windows Vista+. Процессы с этим уровнем имеют административные привилегии.

Integrity Level — уровень доверия процесса в Windows, определяющий доступ к ресурсам. Уровни: Low (4096), Medium (8192), High (12288 — Admin), System (16384).

JEA (Just Enough Administration) — технология PowerShell, позволяющая делегировать строго определённые команды конкретным пользователям через специальные endpoint-конфигурации, без предоставления полных прав администратора.

Kerberos — протокол сетевой аутентификации, используемый в Active Directory. При RDP-подключении внутри домена используется для Single Sign-On.

17.2. Терминология RDP-сессий

Active Session — сессия, в которой пользователь активно работает: имеется установленное RDP-соединение, пользователь видит рабочий стол и взаимодействует с ним.

Disconnected Session (Disc) — сессия, в которой пользователь отключился (нажал крестик или соединение прервалось), но все запущенные процессы продолжают работу. Сессия остаётся в памяти сервера и готова к переподключению.

Idle Session — сессия без пользовательской активности в течение определённого времени. Windows может автоматически отключать или завершать idle-сессии согласно групповым политикам.

Listen State — состояние системного listener-объекта rdp-tcp, ожидающего входящих RDP-подключений. Не является пользовательской сессией.

Logon ID (LUID) — локально уникальный идентификатор сессии входа (Locally Unique Identifier). Используется в журнале событий Security для корреляции событий одной сессии: создание (4624), переподключение (4778), завершение (4634).

mstsc.exe — Microsoft Terminal Services Client, исполняемый файл клиента RDP. При вызове с параметром /shadow:ID используется для теневых подключений к локальным сессиям.

NLA (Network Level Authentication) — режим аутентификации RDP, при котором учётные данные запрашиваются до создания сессии на сервере. Защищает от атак типа denial-of-service на RDP-сервер.

RDP (Remote Desktop Protocol) — проприетарный протокол Microsoft для удалённого доступа к рабочему столу. Работает поверх TCP (стандартный порт 3389). Версия 10.x поддерживает GPU-ускорение, адаптивный битрейт и аппаратное кодирование H.264/AVC.

RDP-Tcp — имя стандартного слушателя (listener) протокола RDP в Windows Terminal Services. В выводе qwinsta отображается как строка с состоянием Listen.

rwinsta.exe — утилита командной строки Windows для сброса (disconnect) пользовательских сессий. Функционально аналогична tscon с параметром /dest:console, используется как fallback при недоступности WTS API.

Session 0 Isolation — механизм безопасности, введённый в Windows Vista, изолирующий сервисную сессию (Session 0) от интерактивных пользовательских сессий. Предотвращает атаки типа «shatter attack».

Session ID — целочисленный идентификатор, однозначно идентифицирующий сессию на сервере. ID присваивается при создании сессии и освобождается при её завершении. Session 0 — всегда системная.

Shadow (Remote Control) — механизм Windows Terminal Services для просмотра и управления активной сессией другого пользователя. Реализуется через mstsc.exe /shadow:ID.

tscon.exe — утилита командной строки для переключения между сессиями Terminal Services. Исторически использовалась для shadow-подключений на Windows XP/2003.

17.3. Терминология Windows API

CIM (Common Information Model) — стандарт представления информации об управляемых элементах IT-инфраструктуры. В PowerShell доступен через Get-CimInstance. Является современным преемником WMI.

Get-NetTCPConnection — PowerShell командлет (PowerShell 4.0+), возвращающий список активных TCP-соединений. Более производительная альтернатива netstat.exe с поддержкой фильтрации.

P/Invoke (Platform Invocation Services) — механизм .NET для вызова неуправляемых функций из системных DLL (например, wtsapi32.dll, netapi32.dll) из управляемого кода PowerShell через Add-Type.

WMI (Windows Management Instrumentation) — инфраструктура управления компонентами Windows, предоставляющая единый API для получения информации о системе и управления ею через классы Win32_*.

WTS API (Windows Terminal Services API) — набор функций в wtsapi32.dll для работы с сессиями Terminal Services. Ключевые функции: WTSEnumerateSessions, WTSQuerySessionInformation, WTSDisconnectSession, WTSLogoffSession.

WTSClientAddress — константа WTS API (14), используемая с WTSQuerySessionInformation для получения IP-адреса клиента. Возвращает структуру WTS_CLIENT_ADDRESS с полями AddressFamily и Address.

wtsapi32.dll — системная DLL Windows, содержащая Windows Terminal Services API. Загружается через P/Invoke для прямого взаимодействия с подсистемой RDS.

17.4. Терминология сетевых протоколов и безопасности

ARP (Address Resolution Protocol) — протокол преобразования IP-адресов в MAC-адреса в пределах локальной сети. Косвенно используется при определении является ли IP-адрес клиента локальным или удалённым.

CredSSP (Credential Security Support Provider) — протокол делегирования учётных данных, используемый NLA в RDP. Передаёт зашифрованные учётные данные с клиента на сервер для использования в remote-операциях.

CVE (Common Vulnerabilities and Exposures) — система идентификации публично известных уязвимостей. Для RDP наиболее известны CVE-2019-0708 (BlueKeep) и CVE-2019-1182/1222 (DejaBlue).

DKIM, DMARC, SPF — протоколы аутентификации электронной почты. Не относятся напрямую к RDP, но упоминаются в контексте уведомлений о сессиях.

IPv6-mapped IPv4 — представление IPv4-адреса в формате IPv6: ::ffff:192.168.1.1. В журналах событий Windows адреса часто записываются в этом формате. Комплекс нормализует их, удаляя префикс ::ffff:.

Kerberos Ticket — зашифрованный токен, выдаваемый Kerberos Key Distribution Center (KDC) для аутентификации. При RDP-подключении внутри домена используется вместо передачи пароля.

loopback (127.0.0.1, ::1) — специальный IP-адрес, ссылающийся на текущий хост. RDP-соединения с loopback-адреса — не реальные клиентские подключения (обычно системные), и фильтруются комплексом при IP-корреляции.

NAT (Network Address Translation) — технология трансляции IP-адресов, при которой несколько устройств используют один публичный IP. Усложняет IP-корреляцию: несколько пользователей могут иметь одинаковый внешний IP.

NLA (Network Level Authentication) — см. раздел 17.2.

RC4 — потоковый шифр, используемый в устаревшем RDP Security Layer. Считается небезопасным с 2013 года. Современный RDP использует TLS 1.2+.

SecurityLayer — реестровый параметр RDP, определяющий протокол безопасности: 0 = RDP (RC4), 1 = Negotiate (TLS preferred), 2 = SSL/TLS only.

SID (Security Identifier) — уникальный идентификатор субъекта безопасности (пользователя, группы, компьютера) в Windows. Пример: S-1-5-32-544 = BUILTIN\Administrators.

SSL/TLS — протоколы шифрования транспортного уровня. RDP SecurityLayer=2 требует наличия действительного SSL-сертификата на сервере.

VPN (Virtual Private Network) — технология создания зашифрованного туннеля поверх публичной сети. Пользователи через VPN могут иметь внутренние IP-адреса (например, 10.8.0.x для WireGuard), которые отображаются в RDP вместо реальных внешних адресов.

17.5. Терминология журнала событий Windows

Event 4624 — «An account was successfully logged on». LogonType=10 означает RemoteInteractive (RDP). Содержит IP-адрес клиента в поле IpAddress.

Event 4625 — «An account failed to log on». Используется для обнаружения брутфорс-атак на RDP. Содержит IP-адрес источника попытки.

Event 4634 — «An account was logged off». Завершение сессии. Содержит LogonId для корреляции с 4624.

Event 4647 — «User initiated logoff». Пользователь сам вышел из системы. Отличается от 4634 — системного события завершения.

Event 4778 — «A session was reconnected to a Window Station». Переподключение к существующей Disconnected-сессии. Содержит SessionId напрямую.

Event 4779 — «A session was disconnected from a Window Station». Отключение от сессии (Disconnect). Содержит SessionId.

Event 21 (TermServices LSM) — «Remote Desktop Services: Session logon succeeded». Содержит SessionID и Address (IP клиента) в UserData XML.

Event 25 (TermServices LSM) — «Remote Desktop Services: Session reconnection succeeded». Аналогичен 21, но для переподключения.

Event 131 (RdpCoreTS) — «The server accepted a new TCP connection from client IP:Port». Первичное TCP-соединение до аутентификации. Не содержит имени пользователя.

XPath Query — язык запросов XML, используемый в Get-WinEvent -FilterXPath для эффективной фильтрации событий. Значительно быстрее FilterHashtable для больших журналов.

17.6. Терминология PowerShell

Add-Type — командлет PowerShell, компилирующий C#-код или загружающий .NET-сборки в текущую сессию. Используется для P/Invoke (доступ к wtsapi32.dll).

Classes (PS5.0+) — объектно-ориентированные классы PowerShell, доступные с версии 5.0. Используются в qwinsta_IP_PS7.ps1 для типизации данных (RdpSession, RdpConnection, и др.).

CultureInfo — класс .NET, определяющий культурно-зависимое поведение: форматирование чисел, дат, строк. Используется для принудительной установки en-US при вызове qwinsta.

Dot-sourcing (. .\script.ps1) — загрузка скрипта в текущее пространство имён, делая доступными все его функции и переменные. Используется для подключения модулей комплекса как библиотек.

ForEach-Object -Parallel — параллельное выполнение блоков скрипта (PowerShell 7.0+). Используется для одновременного опроса нескольких серверов.

PSCustomObject — легковесный объект PowerShell для хранения произвольных свойств. Основной тип данных для хранения информации о сессиях в совместимом с PS5.1 режиме.

PSTypeName — псевдоним типа для PSCustomObject, обеспечивающий форматирование через Format.ps1xml файлы. Используется для кастомного отображения объектов сессий.

Splatting — техника передачи хэштаблицы как набора именованных параметров командлету: $params = @{...}; Cmdlet @params. Используется в комплексе для чистоты кода.

WhatIf — общий PowerShell-параметр, включающий режим симуляции без реального выполнения изменений. Реализован в операциях Logoff, Disconnect и конфигурации реестра.

17.7. Быстрый справочник команд для ежедневного использования

# ══════════════════════════════════════════════════════════════
# ШПАРГАЛКА: 1st Remote Session Manager Pro
# Репозиторий: github.com/paulmann/1st-Remote-Session-Manager-Pro
# ══════════════════════════════════════════════════════════════

# ── ПРОСМОТР ─────────────────────────────────────────────────
.\1st-Remote-Session-Manager-Pro.ps1 -Sessions # Список сессий
.\1st-Remote-Session-Manager-Pro.ps1 -Sessions -WithIP # + IP-адреса
.\1st-Remote-Session-Manager-Pro.ps1 -Status # Конфигурация системы
.\1st-Remote-Session-Manager-Pro.ps1 -Help # Справка

# ── ТЕНЕВОЕ ПОДКЛЮЧЕНИЕ ───────────────────────────────────────
.\1st-Remote-Session-Manager-Pro.ps1 -SessionId 2 # Full Control + согласие
.\1st-Remote-Session-Manager-Pro.ps1 -SessionId 2 -ViewOnly # View Only + согласие
.\1st-Remote-Session-Manager-Pro.ps1 -SessionId 2 -NoConsentPrompt # Full без согласия
.\1st-Remote-Session-Manager-Pro.ps1 -SessionId 2 -ViewOnly -NoConsentPrompt # View без согласия

# ── УПРАВЛЕНИЕ СЕССИЯМИ ───────────────────────────────────────
.\1st-Remote-Session-Manager-Pro.ps1 -SessionId 3 -Disconnect # Отключить
.\1st-Remote-Session-Manager-Pro.ps1 -SessionId 3 -Disconnect -Force
.\1st-Remote-Session-Manager-Pro.ps1 -SessionId 4 -Logoff # Завершить
.\1st-Remote-Session-Manager-Pro.ps1 -SessionId 4 -Logoff -Force
.\1st-Remote-Session-Manager-Pro.ps1 -SessionId 5 -Message "Reboot in 10 min"

# ── КОНФИГУРАЦИЯ ──────────────────────────────────────────────
.\1st-Remote-Session-Manager-Pro.ps1 -EnableRDP # Включить RDP
.\1st-Remote-Session-Manager-Pro.ps1 -EnableShadow # Shadow Mode 2 (Full, no consent)
.\1st-Remote-Session-Manager-Pro.ps1 -EnableShadow -ShadowMode 4 # View Only, no consent
.\1st-Remote-Session-Manager-Pro.ps1 -EnableNLA # Включить NLA
.\1st-Remote-Session-Manager-Pro.ps1 -EnableFirewall # Правила брандмауэра

# ── ЭКСПОРТ ───────────────────────────────────────────────────
.\1st-Remote-Session-Manager-Pro.ps1 -Sessions -Json # JSON в stdout
.\1st-Remote-Session-Manager-Pro.ps1 -Sessions -Export HTML -OutputFile "rdp.html"
.\qwinsta_IP_PS7.ps1 -Command Export -OutputFormat JSON -OutputFile "rdp.json"

# ── МОНИТОРИНГ ────────────────────────────────────────────────
.\qwinsta_IP_PS7.ps1 -Command Monitor -IntervalSeconds 30 # Авто-обновление
.\qwinsta_IP_PS7.ps1 -Command Summary # Краткая сводка

# ── ДИАГНОСТИКА ───────────────────────────────────────────────
.\1st-Remote-Session-Manager-Pro.ps1 -Sessions -DebugMode # Отладочный вывод
.\1st-Remote-Session-Manager-Pro.ps1 -Update # Обновить с GitHub
Test-RDPManagerHealth # Полная диагностика

Послесловие: Полная структура документации

Документация «1st Remote Session Manager Pro» состоит из 9 частей, охватывающих все аспекты комплекса:

ЧастьГлавыСодержание
11–2Введение, архитектура, системные требования
23Модуль qwinsta-en.ps1, WTS API, P/Invoke
34–5Парсинг сессий, нормализация, форматирование вывода
46–7Конфигурация RDP, реестр, самообновление
58–9Функциональность главного модуля
610Модуль qwinsta_IP_PS7.ps1, IP-корреляция
711–13Обнаружение сессий, безопасность, корпоративное развёртывание
814–15Практические сценарии, устранение неисправностей
916–17CLI Reference, Глоссарий

Репозиторий проекта: github.com/paulmann/1st-Remote-Session-Manager-Pro

Добавить комментарий

Разработка и продвижение сайтов webseed.ru
Прокрутить вверх