From 80f57c53517b9198fb978c4a7f949280e58bfb49 Mon Sep 17 00:00:00 2001 From: PTah Date: Wed, 29 Apr 2026 15:14:41 +1000 Subject: [PATCH] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=20=D0=B4=D0=BE=D0=BC=D0=B5=D0=BD=D0=BD=D1=8B=D0=B9=20?= =?UTF-8?q?=D0=B4=D0=B5=D0=BF=D0=BB=D0=BE=D0=B9=20(Deploy-LoginMonitor.ps1?= =?UTF-8?q?,=20version.txt),=20DEPLOY.md,=20=D0=BF=D1=80=D0=B0=D0=B2=D0=BA?= =?UTF-8?q?=D0=B8=20README=20=D0=B8=20=D0=BA=D0=BE=D0=BC=D0=BC=D0=B5=D0=BD?= =?UTF-8?q?=D1=82=D0=B0=D1=80=D0=B8=D0=B9=20=D0=BA=20=D0=B2=D0=B5=D1=80?= =?UTF-8?q?=D1=81=D0=B8=D0=B8=20=D0=B2=20Login=5FMonitor.ps1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- DEPLOY.md | 85 ++++++++ Deploy-LoginMonitor.ps1 | 239 +++++++++++++++++++++++ Encrypt-DpapiForRdpMonitor.ps1 | 11 ++ Login_Monitor.ps1 | 341 ++++++++++++++++++++++++++++++--- README.md | 12 +- version.txt | 1 + 6 files changed, 660 insertions(+), 29 deletions(-) create mode 100644 DEPLOY.md create mode 100644 Deploy-LoginMonitor.ps1 create mode 100644 Encrypt-DpapiForRdpMonitor.ps1 create mode 100644 version.txt diff --git a/DEPLOY.md b/DEPLOY.md new file mode 100644 index 0000000..6dfb711 --- /dev/null +++ b/DEPLOY.md @@ -0,0 +1,85 @@ +# Развёртывание RDP Login Monitor в домене + +Монитор ставится в **`C:\ProgramData\RDP-login-monitor\`**, задачи планировщика создаёт сам **`Login_Monitor.ps1`** (параметр `-InstallTasks`). Доставку по сети выполняет **`Deploy-LoginMonitor.ps1`**. + +## Файлы на файловой шаре + +Создайте каталог, доступный **конечным компьютерам** на чтение (часто учётная запись компьютера домена), например: + +`\\dc.contoso.local\NETLOGON\RDP-login-monitor\` + +Внутри должны лежать **три файла** (имена фиксированы): + +| Файл | Назначение | +|------|------------| +| `Login_Monitor.ps1` | Основной скрипт (токен/chat id plain или DPAPI в параметрах). | +| `version.txt` | **Одна строка** — номер версии пакета на шаре (см. раздел «Версии» ниже). | +| `Deploy-LoginMonitor.ps1` | Установщик: сравнивает версию, копирует монитор, вызывает `-InstallTasks`, при необходимости запускает процесс монитора. | + +При выпуске новой сборки обновляйте на шаре **`Login_Monitor.ps1`** и при необходимости **`version.txt`** (логика описана в разделе «Версии»). + +## Как это работает + +1. **`Deploy-LoginMonitor.ps1`** определяет корень дистрибутива: + - параметр **`-SourceShareRoot`** `\\server\share\RDP-login-monitor`, **или** + - если скрипт запущен по UNC, берётся **родительская папка** этого файла (удобно вызывать шару без параметров). + +2. Читается **`version.txt`** на шаре и сравнивается с локальной меткой **`C:\ProgramData\RDP-login-monitor\deployed_version.txt`**. Если метки ещё нет — для сравнения подтягивается **`$ScriptVersion`** из уже установленного **`Login_Monitor.ps1`**. + +3. Если версия на шаре **совпадает** с зафиксированной локально — выход без копирования (быстро, можно при каждой загрузке). + +4. Если версия на шаре **новее** — останавливаются процессы монитора с каноническим путём → копируется **`Login_Monitor.ps1`** → выполняется **`Login_Monitor.ps1 -InstallTasks`** → записывается **`deployed_version.txt`** → запускается монитор (если не указан **`-SkipStartMonitorAfterUpdate`**). + +5. Если версия на шаре **старее** локальной — обновление **не выполняется** (защита от отката), пока не указан **`-AllowDowngrade`**. + +Лог установки: **`C:\ProgramData\RDP-login-monitor\Logs\deploy.log`**. + +## Версии: `version.txt` и `$ScriptVersion` + +| Что | Роль | +|-----|------| +| **`version.txt` на шаре** | Единственный источник для **`Deploy-LoginMonitor.ps1`**: решение «класть ли новый файл на компьютер». Поднимайте номер **всякий раз**, когда нужно, чтобы доменные машины забрали **новую копию** скрипта с шары — в том числе при мелких правках **без** изменения «видимой» версии в логах. | +| **`$ScriptVersion` в `Login_Monitor.ps1`** | Версия для **логов и Telegram** (что видит администратор). Меняйте при **значимых** релизах; для полной ясности можно держать ту же строку, что и в **`version.txt`**. | + +**Типичные сценарии:** + +- Крупный релиз: обновили **`Login_Monitor.ps1`**, подняли **`$ScriptVersion`** (например `1.4.0`) и записали то же в **`version.txt`** на шаре. +- Мелкая правка на шаре (опечатка, узкий фикс), не хотите путать отчёты по версии в логах: поднимите только **`version.txt`** (например с `1.3.0` на **`1.3.0.1`** — поддерживаются четырёхкомпонентные номера .NET **Version**). **`$ScriptVersion`** можно не трогать; на клиентах после деплоя в логах по-прежнему будет старая «человеческая» версия, но файл будет актуальным. +- Идеально поддерживать синхронность **`version.txt`** и **`$ScriptVersion`**, когда правки крупные и версия в логах должна совпадать с дистрибутивом. + +Итого: **обновляться «по сети» обязан именно `version.txt`**; строка **`$ScriptVersion`** нужна для прозрачности в мониторинге и может совпадать с шарой или отставать по «маркетингу», если вы сознательно крутите только patch в **`version.txt`**. + +## Групповая политика (GPO) + +1. Положите три файла на шару (см. выше). +2. **Конфигурация компьютера** → **Политики** → **Windows** → **Сценарии (запуск/завершение)** → **Автозагрузка**. +3. Добавьте сценарий PowerShell с **путём к установщику на шаре**, например: + + `\\contoso.local\NETLOGON\RDP-login-monitor\Deploy-LoginMonitor.ps1` + + Параметры можно оставить пустыми, если рядом на шаре лежат **`Login_Monitor.ps1`** и **`version.txt`**. + +Скрипт выполняется от **SYSTEM** при старте ОС — этого достаточно для записи в **`ProgramData`** и регистрации задач. + +Альтернатива: команда + +```text +powershell.exe -NoProfile -ExecutionPolicy Bypass -File "\\...\Deploy-LoginMonitor.ps1" +``` + +в сценарии **cmd** / отдельном **bat**. + +## Ручная проверка + +```powershell +powershell.exe -NoProfile -ExecutionPolicy Bypass -File "\\...\Deploy-LoginMonitor.ps1" +``` + +- **`-WhatIf`** — только сообщение в лог, без копирования. +- **`-SkipStartMonitorAfterUpdate`** — после обновления не запускать процесс монитора (остаются задачи планировщика и следующая загрузка / watchdog). + +## Безопасность и замечания + +- ACL на шару: чтение только нужным **компьютерам** / группам; файл **`Login_Monitor.ps1`** может содержать токен — ограничивайте доступ. +- DPAPI-секреты привязаны к машине: шифровать на каждой цели или использовать plain на закрытой шаре (см. комментарии в **`Login_Monitor.ps1`**). +- Deploy при ошибках пишет в **`deploy.log`** и завершается с кодом **0**, чтобы не блокировать загрузку ОС; проблемы смотрите по логу на ПК. diff --git a/Deploy-LoginMonitor.ps1 b/Deploy-LoginMonitor.ps1 new file mode 100644 index 0000000..63c0718 --- /dev/null +++ b/Deploy-LoginMonitor.ps1 @@ -0,0 +1,239 @@ +<# +.SYNOPSIS + Доставка Login_Monitor.ps1 с файловой шары по версии (домен: ПК и серверы). +.DESCRIPTION + Читает version.txt на шаре, сравнивает с локальной меткой (или с версией в установленном скрипте). + При необходимости копирует Login_Monitor.ps1 в C:\ProgramData\RDP-login-monitor\, регистрирует задачи (-InstallTasks), + перезапускает процесс монитора. + Предназначен для GPO «Сценарий запуска компьютера» (SYSTEM); можно запускать вручную от администратора. + + СТРУКТУРА НА ШАРЕ (пример): + \\dc\share\RDP-login-monitor\Login_Monitor.ps1 + \\dc\share\RDP-login-monitor\version.txt — одна строка, например: 1.3.0 + \\dc\share\RDP-login-monitor\Deploy-LoginMonitor.ps1 + + Если Deploy-LoginMonitor.ps1 запускают с этой шары, параметр -SourceShareRoot можно не указывать — + корень шары берётся из расположения этого файла. + +.NOTES + Лог: C:\ProgramData\RDP-login-monitor\Logs\deploy.log +#> + +[CmdletBinding()] +param( + # UNC-каталог, где лежат Login_Monitor.ps1 и version.txt. Пусто = родительский каталог этого скрипта. + [string]$SourceShareRoot = "", + [switch]$WhatIf, + # После обновления не стартовать монитор (только файлы и задачи). + [switch]$SkipStartMonitorAfterUpdate, + # Разрешить установку более старой версии с шары (по умолчанию откаты блокируются). + [switch]$AllowDowngrade +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +$InstallRoot = [System.IO.Path]::GetFullPath("$env:ProgramData\RDP-login-monitor") +$LocalScript = Join-Path $InstallRoot "Login_Monitor.ps1" +$VersionStampPath = Join-Path $InstallRoot "deployed_version.txt" +$DeployLogPath = Join-Path $InstallRoot "Logs\deploy.log" +$ScriptName = "Login_Monitor.ps1" +$VersionFileName = "version.txt" +$PsExe = "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe" + +$Utf8Bom = New-Object System.Text.UTF8Encoding $true + +function Write-DeployLog { + param([string]$Message) + $line = "$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') - $Message" + [Environment]::NewLine + try { + $dir = Split-Path $DeployLogPath -Parent + if (-not (Test-Path -LiteralPath $dir)) { + New-Item -ItemType Directory -Path $dir -Force | Out-Null + } + [System.IO.File]::AppendAllText($DeployLogPath, $line, $Utf8Bom) + } catch { } + if ($Host.Name -eq 'ConsoleHost' -or $Host.Name -eq 'Windows PowerShell ISE Host') { + Write-Host $line.TrimEnd("`r`n") + } +} + +function Resolve-SourceShareRoot { + if (-not [string]::IsNullOrWhiteSpace($SourceShareRoot)) { + return [System.IO.Path]::GetFullPath($SourceShareRoot.TrimEnd('\')) + } + $here = $PSCommandPath + if ([string]::IsNullOrWhiteSpace($here)) { $here = $MyInvocation.MyCommand.Path } + if ([string]::IsNullOrWhiteSpace($here)) { + throw "Укажите -SourceShareRoot или запускайте Deploy-LoginMonitor.ps1 с путём к файлу (например с UNC-шары)." + } + return [System.IO.Path]::GetFullPath((Split-Path -Parent $here)) +} + +function Read-VersionLineFromFile { + param([string]$Path) + if (-not (Test-Path -LiteralPath $Path)) { return $null } + $raw = (Get-Content -LiteralPath $Path -TotalCount 3 -ErrorAction Stop | Where-Object { $_ -match '\S' }) | Select-Object -First 1 + if ($null -eq $raw) { return $null } + return ([string]$raw).Trim() -replace '^v', '' +} + +function Get-LocalDeployedVersion { + $fromStamp = Read-VersionLineFromFile -Path $VersionStampPath + if (-not [string]::IsNullOrWhiteSpace($fromStamp)) { return $fromStamp } + + if (-not (Test-Path -LiteralPath $LocalScript)) { return $null } + + try { + $head = Get-Content -LiteralPath $LocalScript -TotalCount 120 -ErrorAction Stop + foreach ($ln in $head) { + if ($ln -match '^\s*\$ScriptVersion\s*=\s*["'']([0-9]+(?:\.[0-9]+){0,3})["'']') { + return $Matches[1] + } + } + } catch { } + + return $null +} + +function Normalize-VersionOrNull { + param([string]$Text) + if ([string]::IsNullOrWhiteSpace($Text)) { return $null } + $t = $Text.Trim() -replace '^v', '' + try { + return [version]$t + } catch { + return $null + } +} + +function Compare-VersionStrings { + param([string]$Left, [string]$Right) + $a = Normalize-VersionOrNull -Text $Left + $b = Normalize-VersionOrNull -Text $Right + if ($null -eq $a -or $null -eq $b) { return $null } + return $a.CompareTo($b) +} + +function Get-ScriptPathFromCommandLine { + param([string]$CommandLine) + if ([string]::IsNullOrWhiteSpace($CommandLine)) { return $null } + $m = [regex]::Match($CommandLine, '(?i)-File\s+"([^"]+)"') + if ($m.Success) { + try { return [System.IO.Path]::GetFullPath($m.Groups[1].Value) } catch { return $null } + } + $m2 = [regex]::Match($CommandLine, '(?i)-File\s+(\S+)') + if ($m2.Success) { + try { return [System.IO.Path]::GetFullPath($m2.Groups[1].Value) } catch { return $null } + } + return $null +} + +function Test-CommandLineIsWatchdog { + param([string]$CommandLine) + return ($CommandLine -match '(?i)(^|\s)-Watchdog(\s|$)') +} + +function Stop-RdpLoginMonitorMainProcesses { + $canonical = [System.IO.Path]::GetFullPath($LocalScript) + try { + $procs = Get-CimInstance Win32_Process -Filter "Name = 'powershell.exe' OR Name = 'pwsh.exe'" -ErrorAction Stop + foreach ($proc in $procs) { + $cl = [string]$proc.CommandLine + if ($cl -notmatch 'Login_Monitor\.ps1') { continue } + if (Test-CommandLineIsWatchdog -CommandLine $cl) { continue } + $sp = Get-ScriptPathFromCommandLine -CommandLine $cl + if ($null -eq $sp) { continue } + if ([System.IO.Path]::GetFullPath($sp) -ne $canonical) { continue } + if ([int]$proc.ProcessId -eq $PID) { continue } + Write-DeployLog "Останавливаю процесс монитора PID $($proc.ProcessId) перед обновлением файла." + Stop-Process -Id $proc.ProcessId -Force -ErrorAction SilentlyContinue + } + } catch { + Write-DeployLog "Предупреждение при остановке монитора: $($_.Exception.Message)" + } +} + +# --- main --- +try { + $shareRoot = Resolve-SourceShareRoot + $sourceScript = Join-Path $shareRoot $ScriptName + $sourceVersionFile = Join-Path $shareRoot $VersionFileName + + Write-DeployLog "Deploy: корень дистрибутива: $shareRoot" + + if (-not (Test-Path -LiteralPath $sourceScript)) { + Write-DeployLog "ОШИБКА: на шаре нет файла: $sourceScript" + exit 0 + } + if (-not (Test-Path -LiteralPath $sourceVersionFile)) { + Write-DeployLog "ОШИБКА: на шаре нет version.txt: $sourceVersionFile (добавьте одну строку с версией, как в Login_Monitor.ps1)." + exit 0 + } + + $shareVerRaw = Read-VersionLineFromFile -Path $sourceVersionFile + if ([string]::IsNullOrWhiteSpace($shareVerRaw)) { + Write-DeployLog "ОШИБКА: version.txt пустой или не читается: $sourceVersionFile" + exit 0 + } + + $localVerRaw = Get-LocalDeployedVersion + Write-DeployLog "Версия на шаре: $shareVerRaw; локально установлено: $(if ($localVerRaw) { $localVerRaw } else { '(нет)' })." + + $cmp = Compare-VersionStrings -Left $shareVerRaw -Right $localVerRaw + if ($null -ne $cmp) { + if ($cmp -eq 0) { + Write-DeployLog "Актуально, копирование не требуется." + exit 0 + } + if ($cmp -lt 0 -and -not $AllowDowngrade) { + Write-DeployLog "На шаре версия старше локальной — пропуск (используйте -AllowDowngrade для отката)." + exit 0 + } + } + + if (-not (Test-Path -LiteralPath $InstallRoot)) { + if ($WhatIf) { + Write-DeployLog "[WhatIf] Создать каталог $InstallRoot" + } else { + New-Item -ItemType Directory -Path $InstallRoot -Force | Out-Null + } + } + + if ($WhatIf) { + Write-DeployLog "[WhatIf] Скопировать $sourceScript -> $LocalScript; InstallTasks; версия $shareVerRaw" + exit 0 + } + + Stop-RdpLoginMonitorMainProcesses + + Copy-Item -LiteralPath $sourceScript -Destination $LocalScript -Force + Write-DeployLog "Файл скопирован: $LocalScript" + + $installArgs = @( + '-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', $LocalScript, '-InstallTasks' + ) + $p = Start-Process -FilePath $PsExe -ArgumentList $installArgs -Wait -PassThru -WindowStyle Hidden + if ($p.ExitCode -ne 0) { + Write-DeployLog "Предупреждение: InstallTasks завершился с кодом $($p.ExitCode)." + } else { + Write-DeployLog "InstallTasks выполнен (код 0)." + } + + [System.IO.File]::WriteAllText($VersionStampPath, "$shareVerRaw`r`n", $Utf8Bom) + Write-DeployLog "Записана метка версии: $VersionStampPath" + + if (-not $SkipStartMonitorAfterUpdate) { + Start-Process -FilePath $PsExe -ArgumentList @( + '-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', $LocalScript + ) -WindowStyle Hidden + Write-DeployLog "Запущен процесс монитора (новый файл)." + } else { + Write-DeployLog "Запуск монитора пропущен (-SkipStartMonitorAfterUpdate); поднимется при следующей загрузке или watchdog." + } + + exit 0 +} catch { + Write-DeployLog "КРИТИЧЕСКАЯ ОШИБКА: $($_.Exception.Message)" + exit 0 +} diff --git a/Encrypt-DpapiForRdpMonitor.ps1 b/Encrypt-DpapiForRdpMonitor.ps1 new file mode 100644 index 0000000..4996797 --- /dev/null +++ b/Encrypt-DpapiForRdpMonitor.ps1 @@ -0,0 +1,11 @@ +# Запуск: от администратора на ТОМ ЖЕ компьютере, где будет Login_Monitor.ps1. +# Результат (Base64) вставьте в $TelegramBotTokenProtectedB64 / $TelegramChatIDProtectedB64. +param( + [Parameter(Mandatory = $true)][string]$PlainText +) +Add-Type -AssemblyName System.Security +$bytes = [Text.Encoding]::UTF8.GetBytes($PlainText) +$protected = [System.Security.Cryptography.ProtectedData]::Protect( + $bytes, $null, [System.Security.Cryptography.DataProtectionScope]::LocalMachine +) +[Convert]::ToBase64String($protected) diff --git a/Login_Monitor.ps1 b/Login_Monitor.ps1 index ce821ee..f143283 100644 --- a/Login_Monitor.ps1 +++ b/Login_Monitor.ps1 @@ -1,4 +1,4 @@ -<# +<# .SYNOPSIS Мониторинг логинов/попыток входа с уведомлениями в Telegram .DESCRIPTION @@ -6,18 +6,31 @@ отправляет уведомления в Telegram, делает ротацию логов, heartbeat в файл и ежедневный отчет. .NOTES Требуется: PowerShell 5.0+, запуск от администратора. + Рабочая копия скрипта: C:\ProgramData\RDP-login-monitor\Login_Monitor.ps1 + Логи и бэкапы: C:\ProgramData\RDP-login-monitor\Logs\ (бэкапы 31 день). + Задачи планировщика: RDP-Login-Monitor (запуск при старте ОС), RDP-Login-Monitor-Watchdog (контроль раз в 5 мин). Важное: - На некоторых серверах RDP-логин приходит как LogonType=3, поэтому интерактивные типы: 2/3/10. - Добавлены исключения шума: DWM-*, UMFD-*, HealthMailbox*, Font Driver Host*, NtLmSsp и др. - Heartbeat без дрейфа: используется nextHeartbeatTime (а не "прошло N секунд"). - Win32_OperatingSystem.ProductType: 1 = рабочая станция (4624/4625 только LogonType 10 + при наличии журнала событие 1149 RCM); 2/3 = сервер/КД — прежняя логика (типы 2, 3, 10). + Секреты Telegram (DPAPI LocalMachine): на ЭТОЙ машине, под админом, выполните: + Add-Type -AssemblyName System.Security + $p=[Text.Encoding]::UTF8.GetBytes('<токен>') + [Convert]::ToBase64String([Security.Cryptography.ProtectedData]::Protect($p,$null,'LocalMachine')) + Полученную строку вставьте в $TelegramBotTokenProtectedB64 (и аналогично для chat id). #> [CmdletBinding()] param( - [string]$TelegramBotToken = "", - [string]$TelegramChatID = "" + [string]$TelegramBotToken = '', + [string]$TelegramChatID = '', + [string]$TelegramBotTokenProtectedB64 = "", + [string]$TelegramChatIDProtectedB64 = "", + [switch]$Watchdog, + [switch]$InstallTasks, + [switch]$SkipScheduledTaskMaintenance ) Set-StrictMode -Version Latest @@ -27,17 +40,41 @@ $ErrorActionPreference = "Stop" # при скачивании через IWR, если при переносе в строке испортится UTF-8. function Uc { param([int[]]$C) -join ($C | ForEach-Object { [char]$_ }) } +function Unprotect-RdpMonitorDpapiB64 { + param([Parameter(Mandatory = $true)][string]$Base64) + Add-Type -AssemblyName System.Security + $bytes = [Convert]::FromBase64String($Base64.Trim()) + $plain = [System.Security.Cryptography.ProtectedData]::Unprotect( + $bytes, + $null, + [System.Security.Cryptography.DataProtectionScope]::LocalMachine + ) + return [Text.Encoding]::UTF8.GetString($plain) +} + # ============================================ # КОНФИГУРАЦИЯ # ============================================ -# Версия (обновляйте при значимых изменениях; пишется в лог и в консоль при интерактивном запуске) -$ScriptVersion = "1.2.0" +# Каталог установки (канонический путь к скрипту и данным) +$script:InstallRoot = [System.IO.Path]::GetFullPath("$env:ProgramData\RDP-login-monitor") +$script:CanonicalScriptName = "Login_Monitor.ps1" +$script:ScheduledTaskNameMain = "RDP-Login-Monitor" +$script:ScheduledTaskNameWatchdog = "RDP-Login-Monitor-Watchdog" +$script:MutexName = "Global\RDP-Login-Monitor-Singleton-v1" +$script:RdpInstanceMutex = $null -# Логи -$LogFile = "D:\Soft\Logs\login_monitor.log" -$LogBackupFolder = "D:\Soft\Logs\Backup" -$MaxBackupDays = 30 +# Версия: пишется в лог и в Telegram. При доменном развёртывании через шару см. DEPLOY.md — +# триггер обновления на клиентах даёт файл version.txt на шаре (его номер можно поднять и без смены +# строки ниже, если правки «мелкие» и вы не хотите менять отображаемую версию в логах). +# Рекомендация: при значимых релизах меняйте и $ScriptVersion, и version.txt одинаково; при только +# исправлениях на шаре — достаточно поднять patch в version.txt (например 1.3.0.1). +$ScriptVersion = "1.3.0" + +# Логи (все под InstallRoot) +$LogFile = Join-Path $script:InstallRoot "Logs\login_monitor.log" +$LogBackupFolder = Join-Path $script:InstallRoot "Logs\Backup" +$MaxBackupDays = 31 # Ротация логов (ежедневно) $LogRotationHour = 0 @@ -45,12 +82,12 @@ $LogRotationMinute = 0 # Heartbeat (только файл) $HeartbeatInterval = 3600 -$HeartbeatFile = "D:\Soft\Logs\last_heartbeat.txt" +$HeartbeatFile = Join-Path $script:InstallRoot "Logs\last_heartbeat.txt" # Ежедневный отчет $DailyReportHour = 9 $DailyReportMinute = 0 -$LastReportFile = "D:\Soft\Logs\last_daily_report.txt" +$LastReportFile = Join-Path $script:InstallRoot "Logs\last_daily_report.txt" # RD Gateway $EnableRDGatewayMonitoring = $true @@ -110,6 +147,9 @@ $IgnoreAdvapiNetworkLogonProcessContains = "Advapi" # ИНИЦИАЛИЗАЦИЯ # ============================================ +if (!(Test-Path -LiteralPath $script:InstallRoot)) { + New-Item -ItemType Directory -Path $script:InstallRoot -Force | Out-Null +} $LogDir = Split-Path $LogFile -Parent if (!(Test-Path $LogDir)) { New-Item -ItemType Directory -Path $LogDir -Force | Out-Null } if (!(Test-Path $LogBackupFolder)) { New-Item -ItemType Directory -Path $LogBackupFolder -Force | Out-Null } @@ -149,25 +189,258 @@ function Write-Log { Write-Host ($logMessage.TrimEnd("`r`n")) } +function Get-RdpMonitorThisScriptPath { + $p = $PSCommandPath + if ([string]::IsNullOrWhiteSpace($p)) { $p = $MyInvocation.MyCommand.Path } + if ([string]::IsNullOrWhiteSpace($p)) { return $null } + return [System.IO.Path]::GetFullPath($p) +} + +function Get-RdpMonitorScriptPathFromCommandLine { + param([string]$CommandLine) + if ([string]::IsNullOrWhiteSpace($CommandLine)) { return $null } + $m = [regex]::Match($CommandLine, '(?i)-File\s+"([^"]+)"') + if ($m.Success) { + try { return [System.IO.Path]::GetFullPath($m.Groups[1].Value) } catch { return $null } + } + $m2 = [regex]::Match($CommandLine, '(?i)-File\s+(\S+)') + if ($m2.Success) { + try { return [System.IO.Path]::GetFullPath($m2.Groups[1].Value) } catch { return $null } + } + return $null +} + +function Test-RdpMonitorCommandLineIsWatchdog { + param([string]$CommandLine) + return ($CommandLine -match '(?i)(^|\s)-Watchdog(\s|$)') +} + +function Get-RdpLoginMonitorProcessInfos { + $result = [System.Collections.Generic.List[object]]::new() + try { + $procs = Get-CimInstance Win32_Process -Filter "Name = 'powershell.exe' OR Name = 'pwsh.exe'" -ErrorAction Stop + foreach ($proc in $procs) { + $cl = [string]$proc.CommandLine + if ($cl -notmatch 'Login_Monitor\.ps1') { continue } + $scriptPath = Get-RdpMonitorScriptPathFromCommandLine -CommandLine $cl + $isWd = Test-RdpMonitorCommandLineIsWatchdog -CommandLine $cl + $result.Add([pscustomobject]@{ + ProcessId = [int]$proc.ProcessId + ScriptPath = $scriptPath + IsWatchdog = [bool]$isWd + CommandLine = $cl + }) | Out-Null + } + } catch { + Write-Log "Предупреждение: перечень процессов Win32 (миграция экземпляров): $($_.Exception.Message)" + } + return @($result) +} + +function Invoke-RdpMonitorProcessMigrationAndRelaunch { + $canonicalScript = [System.IO.Path]::GetFullPath((Join-Path $script:InstallRoot $script:CanonicalScriptName)) + $thisPath = Get-RdpMonitorThisScriptPath + if ($null -eq $thisPath) { + Write-Log "Не удалось определить путь к текущему скрипту — продолжение невозможно." + exit 1 + } + + $infos = @(Get-RdpLoginMonitorProcessInfos) + $others = @($infos | Where-Object { $_.ProcessId -ne $PID }) + + foreach ($o in $others) { + if ($o.IsWatchdog) { continue } + if ($null -eq $o.ScriptPath) { continue } + $p = [System.IO.Path]::GetFullPath($o.ScriptPath) + if ($p -ne $canonicalScript) { + Write-Log "Останавливаю экземпляр монитора из другого каталога (PID $($o.ProcessId)): $p" + Stop-Process -Id $o.ProcessId -Force -ErrorAction SilentlyContinue + } + } + + if ($thisPath -ne $canonicalScript) { + if (-not (Test-Path -LiteralPath $canonicalScript)) { + Write-Log "ОШИБКА: Канонический скрипт не найден: $canonicalScript. Скопируйте Login_Monitor.ps1 в $($script:InstallRoot) и запустите снова." + exit 1 + } + Write-Log "Текущий запуск из $thisPath — передаю работу каноническому файлу $canonicalScript и завершаюсь." + Start-Process -FilePath "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe" -ArgumentList @( + '-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', $canonicalScript + ) -WindowStyle Hidden + exit 0 + } + +} + +function Lock-RdpMonitorSingleInstance { + $mtx = New-Object System.Threading.Mutex($false, $script:MutexName) + $script:RdpInstanceMutex = $mtx + if (-not $mtx.WaitOne(0)) { + Write-Log "Экземпляр монитора уже активен (mutex). Выход без дублирования." + exit 0 + } +} + +function Get-RdpMonitorPowerShellExe { + return "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe" +} + +function Test-RdpMonitorScheduledTaskMatches { + param( + [Parameter(Mandatory = $true)][string]$TaskName, + [Parameter(Mandatory = $true)][string]$ExpectedExe, + [Parameter(Mandatory = $true)][string]$ExpectedArguments + ) + try { + $t = Get-ScheduledTask -TaskName $TaskName -ErrorAction Stop | Select-Object -First 1 + $a = @($t.Actions)[0] + if ($null -eq $a) { return $false } + $exe = [string]$a.Execute + $arg = [string]$a.Arguments + return (($exe.Trim() -eq $ExpectedExe.Trim()) -and ($arg.Trim() -eq $ExpectedArguments.Trim())) + } catch { + return $false + } +} + +function Register-RdpMonitorScheduledTasksCore { + $psExe = Get-RdpMonitorPowerShellExe + $canonicalScript = [System.IO.Path]::GetFullPath((Join-Path $script:InstallRoot $script:CanonicalScriptName)) + if (-not (Test-Path -LiteralPath $canonicalScript)) { + Write-Log "Задачи планировщика не созданы: нет файла $canonicalScript" + return + } + + $argMain = "-NoProfile -ExecutionPolicy Bypass -File `"$canonicalScript`"" + $argWd = "-NoProfile -ExecutionPolicy Bypass -File `"$canonicalScript`" -Watchdog" + + $actionMain = New-ScheduledTaskAction -Execute $psExe -Argument $argMain + $triggerBoot = New-ScheduledTaskTrigger -AtStartup + $principal = New-ScheduledTaskPrincipal -UserId "SYSTEM" -LogonType ServiceAccount -RunLevel Highest + $settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -StartWhenAvailable + + Register-ScheduledTask -TaskName $script:ScheduledTaskNameMain -Action $actionMain -Trigger $triggerBoot ` + -Principal $principal -Settings $settings -Force | Out-Null + Write-Log "Задача планировщика: $($script:ScheduledTaskNameMain) (запуск при старте ОС)." + + $actionWd = New-ScheduledTaskAction -Execute $psExe -Argument $argWd + $trigger2 = New-ScheduledTaskTrigger -Once -At (Get-Date) + $trigger2.RepetitionInterval = New-TimeSpan -Minutes 5 + $trigger2.RepetitionDuration = New-TimeSpan -Days 36500 + + Register-ScheduledTask -TaskName $script:ScheduledTaskNameWatchdog -Action $actionWd -Trigger $trigger2 ` + -Principal $principal -Settings $settings -Force | Out-Null + Write-Log "Задача планировщика: $($script:ScheduledTaskNameWatchdog) (каждые 5 минут, контроль процесса)." +} + +function Ensure-RdpMonitorScheduledTasks { + if ($SkipScheduledTaskMaintenance) { + Write-Log "Обслуживание задач планировщика пропущено (-SkipScheduledTaskMaintenance)." + return + } + $psExe = Get-RdpMonitorPowerShellExe + $canonicalScript = [System.IO.Path]::GetFullPath((Join-Path $script:InstallRoot $script:CanonicalScriptName)) + $argMain = "-NoProfile -ExecutionPolicy Bypass -File `"$canonicalScript`"" + $argWd = "-NoProfile -ExecutionPolicy Bypass -File `"$canonicalScript`" -Watchdog" + + $needMain = -not (Test-RdpMonitorScheduledTaskMatches -TaskName $script:ScheduledTaskNameMain -ExpectedExe $psExe -ExpectedArguments $argMain) + $needWd = -not (Test-RdpMonitorScheduledTaskMatches -TaskName $script:ScheduledTaskNameWatchdog -ExpectedExe $psExe -ExpectedArguments $argWd) + + if (-not $needMain -and -not $needWd) { + Write-Log "Задачи планировщика ($($script:ScheduledTaskNameMain), $($script:ScheduledTaskNameWatchdog)) соответствуют каноническим путям." + return + } + + if ($needMain) { Write-Log "Требуется обновить или создать задачу: $($script:ScheduledTaskNameMain)" } + if ($needWd) { Write-Log "Требуется обновить или создать задачу: $($script:ScheduledTaskNameWatchdog)" } + + Register-RdpMonitorScheduledTasksCore +} + +function Start-RdpMonitorWatchdogMain { + $watchdogLog = Join-Path $script:InstallRoot "Logs\watchdog.log" + $canonicalScript = [System.IO.Path]::GetFullPath((Join-Path $script:InstallRoot $script:CanonicalScriptName)) + function Write-WdLog { + param([string]$Message) + $timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss" + $line = "$timestamp - $Message" + [Environment]::NewLine + try { + [System.IO.File]::AppendAllText($watchdogLog, $line, $script:Utf8BomEncoding) + } catch { } + Write-Host ($line.TrimEnd("`r`n")) + } + + Write-WdLog "Watchdog (версия $ScriptVersion): проверка экземпляра монитора. Ожидаемый скрипт: $canonicalScript" + + try { + $mainRunning = $false + $infos = @(Get-RdpLoginMonitorProcessInfos) + foreach ($info in $infos) { + if ($info.IsWatchdog) { continue } + if ($null -eq $info.ScriptPath) { continue } + if ([System.IO.Path]::GetFullPath($info.ScriptPath) -ne $canonicalScript) { continue } + $mainRunning = $true + break + } + + if (-not $mainRunning) { + Write-WdLog "Монитор не найден — запуск $canonicalScript" + Start-Process -FilePath (Get-RdpMonitorPowerShellExe) -ArgumentList @( + '-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', $canonicalScript + ) -WindowStyle Hidden + } else { + Write-WdLog "Монитор работает (канонический экземпляр найден)." + } + } catch { + Write-WdLog "Ошибка watchdog: $($_.Exception.Message)" + exit 1 + } + exit 0 +} + +# --- Учётные данные Telegram (открытый текст или DPAPI Base64) --- +if (-not [string]::IsNullOrWhiteSpace($TelegramBotTokenProtectedB64)) { + try { + $TelegramBotToken = Unprotect-RdpMonitorDpapiB64 -Base64 $TelegramBotTokenProtectedB64 + } catch { + Write-Host "Ошибка расшифровки TelegramBotToken (DPAPI): $($_.Exception.Message)" + exit 1 + } +} +if (-not [string]::IsNullOrWhiteSpace($TelegramChatIDProtectedB64)) { + try { + $TelegramChatID = Unprotect-RdpMonitorDpapiB64 -Base64 $TelegramChatIDProtectedB64 + } catch { + Write-Host "Ошибка расшифровки TelegramChatID (DPAPI): $($_.Exception.Message)" + exit 1 + } +} +if ($TelegramBotToken -eq '') { $TelegramBotToken = "" } +if ($TelegramChatID -eq '') { $TelegramChatID = "" } + +if ($Watchdog) { + Start-RdpMonitorWatchdogMain + exit 0 +} + +if ($InstallTasks) { + $currentUser = [Security.Principal.WindowsIdentity]::GetCurrent() + $principal = New-Object Security.Principal.WindowsPrincipal($currentUser) + if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { + Write-Log "InstallTasks: нужны права администратора." + exit 1 + } + Register-RdpMonitorScheduledTasksCore + Write-Log "InstallTasks: задачи планировщика обновлены." + exit 0 +} + function ConvertTo-TelegramHtml { param([string]$Text) if ($null -eq $Text) { return '' } return [System.Net.WebUtility]::HtmlEncode([string]$Text) } -try { - [System.Net.ServicePointManager]::SecurityProtocol = - [System.Net.ServicePointManager]::SecurityProtocol -bor [System.Net.SecurityProtocolType]::Tls12 - Write-Log "TLS 1.2 включен" -} catch { - try { - [System.Net.ServicePointManager]::SecurityProtocol = [System.Net.SecurityProtocolType]::Tls12 - Write-Log "TLS 1.2 установлен" - } catch { - Write-Log "ВНИМАНИЕ: Не удалось включить TLS 1.2" - } -} - function Send-TelegramMessage { param([string]$Message) @@ -222,6 +495,23 @@ if (-not (Test-Administrator)) { } Write-Log "Скрипт запущен с правами администратора, версия $ScriptVersion" +Invoke-RdpMonitorProcessMigrationAndRelaunch +Lock-RdpMonitorSingleInstance +Ensure-RdpMonitorScheduledTasks + +try { + [System.Net.ServicePointManager]::SecurityProtocol = + [System.Net.ServicePointManager]::SecurityProtocol -bor [System.Net.SecurityProtocolType]::Tls12 + Write-Log "TLS 1.2 включен" +} catch { + try { + [System.Net.ServicePointManager]::SecurityProtocol = [System.Net.SecurityProtocolType]::Tls12 + Write-Log "TLS 1.2 установлен" + } catch { + Write-Log "ВНИМАНИЕ: Не удалось включить TLS 1.2" + } +} + $script:IsWorkstation = $false $script:OsInstallKindLabel = "" @@ -373,7 +663,8 @@ function Enable-SecurityAudit { # Logon/Logoff category GUID (not a user id). Write-Log "Trying Logon/Logoff category set via known GUID (fallback)..." - $guidSet = Invoke-AuditPol -Arguments '/set /category:"{69979849-797A-11D9-BED3-505054503030}" /success:enable /failure:enable' + $logonLogoffCategoryGuid = "69979849-797A-11D9-BED3-505054503030" + $guidSet = Invoke-AuditPol -Arguments ("/set /category:`"$logonLogoffCategoryGuid`" /success:enable /failure:enable") if ($guidSet.ExitCode -ne 0) { Write-Log ("auditpol GUID SET FAIL (code {0}):`n{1}" -f $guidSet.ExitCode, $guidSet.Text) } diff --git a/README.md b/README.md index bd72b42..1471f13 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,14 @@ # RDP Login Monitor -PowerShell-набор для мониторинга входов в Windows с отправкой уведомлений в Telegram: +PowerShell-набор для мониторинга входов в Windows с отправкой уведомлений в Telegram. -- `Login_Monitor.ps1` — основной монитор (Security `4624/4625`, RD Gateway `302/303`, ротация логов, heartbeat, ежедневный отчет). -- `Watchdog_RDP_Monitor.ps1` — watchdog: проверяет, жив ли основной скрипт, и перезапускает его при падении/зависании heartbeat. -- `Install-ScheduledTasks.ps1` — создание в Планировщике заданий с теми же именами и параметрами, что в разделе 3 (нужна повышенная PowerShell). +## Актуальная схема (рекомендуется) + +- **`Login_Monitor.ps1`** — единый монитор: журнал Security (`4624`/`4625`), при необходимости RD Gateway (`302`/`303`), рабочие станции и серверы, ротация логов, heartbeat, отчёты. Устанавливается в **`C:\ProgramData\RDP-login-monitor\`**, задачи **`RDP-Login-Monitor`** и **`RDP-Login-Monitor-Watchdog`** создаются параметром **`-InstallTasks`** (встроено в скрипт). +- **`Deploy-LoginMonitor.ps1`** + **`version.txt`** — доставка с файловой шары по версии (домен, GPO «автозагрузка» компьютера). Подробности, структура шары и правила версий: **[DEPLOY.md](DEPLOY.md)**. +- **`Encrypt-DpapiForRdpMonitor.ps1`** — опционально, для DPAPI-строк токена/chat id на конкретном ПК. + +Разделы ниже про **`D:\Soft\`**, **`Watchdog_RDP_Monitor.ps1`** и **`Install-ScheduledTasks.ps1`** относятся к **старой схеме** и оставлены для совместимости; новые установки ориентируйте на **`ProgramData`** и **DEPLOY.md**. ## Что изменилось (важное) diff --git a/version.txt b/version.txt new file mode 100644 index 0000000..f0bb29e --- /dev/null +++ b/version.txt @@ -0,0 +1 @@ +1.3.0