Добавлен доменный деплой (Deploy-LoginMonitor.ps1, version.txt), DEPLOY.md, правки README и комментарий к версии в Login_Monitor.ps1
This commit is contained in:
@@ -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**, чтобы не блокировать загрузку ОС; проблемы смотрите по логу на ПК.
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
+316
-25
@@ -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 = "<TELEGRAM_BOT_TOKEN>",
|
||||
[string]$TelegramChatID = "<TELEGRAM_CHAT_ID>"
|
||||
[string]$TelegramBotToken = '<TELEGRAM_BOT_TOKEN>',
|
||||
[string]$TelegramChatID = '<TELEGRAM_CHAT_ID>',
|
||||
[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 '<TELEGRAM_BOT_TOKEN>') { $TelegramBotToken = "" }
|
||||
if ($TelegramChatID -eq '<TELEGRAM_CHAT_ID>') { $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)
|
||||
}
|
||||
|
||||
@@ -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**.
|
||||
|
||||
## Что изменилось (важное)
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
1.3.0
|
||||
Reference in New Issue
Block a user