Compare commits
48 Commits
39f0e6098c
..
1.4.3
| Author | SHA1 | Date | |
|---|---|---|---|
| a3a5b45cf8 | |||
| 362318707e | |||
| 98031a20a1 | |||
| b3da911d51 | |||
| 967e470e39 | |||
| ace7220752 | |||
| bdcf2bdc5f | |||
| 57bc617d26 | |||
| 17befc867e | |||
| d3154ceb1b | |||
| fe600790e8 | |||
| 11736501ec | |||
| e96131d722 | |||
| 99be51467e | |||
| d26784fb7f | |||
| 02424875fb | |||
| 0e2b3f2534 | |||
| b6ac34a52d | |||
| a37a639be1 | |||
| 1c14eec3cd | |||
| cc8925f88a | |||
| a20055f9d3 | |||
| 313cc189e9 | |||
| e0b0fa3d7e | |||
| 80f57c5351 | |||
| 306b0762a1 | |||
| 6bc5173236 | |||
| be0c394ac4 | |||
| 850781a38d | |||
| b032de9d1c | |||
| 4a4a736eb9 | |||
| 383ffb0bf8 | |||
| 3d03d905a1 | |||
| 4f0b6e219c | |||
| b164e51965 | |||
| 0dce7e7017 | |||
| 9f680dfd89 | |||
| 685ef5260a | |||
| 2826e3f9cd | |||
| 848381e098 | |||
| 99ab020347 | |||
| 65469cf1d2 | |||
| 0c9faece92 | |||
| d0171daf8e | |||
| 16baf12094 | |||
| c5a4c26b1e | |||
| 89acb0a914 | |||
| 21ad87d08d |
@@ -34,6 +34,13 @@
|
|||||||
|
|
||||||
Лог установки: **`C:\ProgramData\RDP-login-monitor\Logs\deploy.log`**.
|
Лог установки: **`C:\ProgramData\RDP-login-monitor\Logs\deploy.log`**.
|
||||||
|
|
||||||
|
## Опционально на клиенте: `ignore.lst`
|
||||||
|
|
||||||
|
На каждом компьютере/сервере можно положить файл **`C:\ProgramData\RDP-login-monitor\ignore.lst`** (в том же каталоге, что и **`Login_Monitor.ps1`**). По строкам этого файла монитор **не отправляет в Telegram** отдельные события **Security `4624`/`4625`** (например, шум от известной рабочей станции, тестового пользователя или фиксированного IP).
|
||||||
|
|
||||||
|
- **`Deploy-LoginMonitor.ps1` с шары `ignore.lst` не доставляет** — при необходимости создавайте файл локально или копируйте своим способом.
|
||||||
|
- Примеры синтаксиса — в репозитории в файле **`ignore.lst.example`**.
|
||||||
|
|
||||||
## Задачи планировщика после `-InstallTasks`
|
## Задачи планировщика после `-InstallTasks`
|
||||||
|
|
||||||
Параметр **`Login_Monitor.ps1 -InstallTasks`** создаёт две задачи:
|
Параметр **`Login_Monitor.ps1 -InstallTasks`** создаёт две задачи:
|
||||||
@@ -47,7 +54,11 @@ Watchdog регистрируется через **`schtasks.exe /Create /SC MIN
|
|||||||
|
|
||||||
Перед созданием выполняется **`schtasks /Delete … /F`** (если задачи ещё не было, сообщение об ошибке подавляется — это нормально).
|
Перед созданием выполняется **`schtasks /Delete … /F`** (если задачи ещё не было, сообщение об ошибке подавляется — это нормально).
|
||||||
|
|
||||||
Сразу после успешного **`/Create`** вызывается **`schtasks /Run`** для watchdog: первый прогон не ждёт следующего 5‑минутного окна расписания.
|
Сразу после регистрации задач вызывается **`schtasks /Run`** и для **основной задачи**, и для **watchdog**: первый запуск не ждёт перезагрузку и ближайшее 5‑минутное окно.
|
||||||
|
|
||||||
|
Если watchdog срабатывает, а основной монитор «не поднимается», смотрите **`Logs\watchdog.log`** (сообщение о PID и предупреждение, если процесс сразу завершился) и **конец `Logs\login_monitor.log`** — частая причина (исправлено в **1.3.5+**): пустой **`$PSCommandPath`** у процесса, запущенного через **`Start-Process`**; скрипт теперь подставляет путь через **`$PSScriptRoot`**.
|
||||||
|
|
||||||
|
Один экземпляр монитора фиксируется **файлом блокировки** в **`C:\ProgramData\RDP-login-monitor\.login_monitor_single_instance.lock`** (не Global mutex): так и **SYSTEM** (задача планировщика), и **интерактивный администратор** могут корректно запускать скрипт вручную без «Отказано в доступе» к mutex (версия **1.3.6+**).
|
||||||
|
|
||||||
Проверка:
|
Проверка:
|
||||||
|
|
||||||
@@ -77,25 +88,96 @@ schtasks /Query /TN "RDP-Login-Monitor-Watchdog"
|
|||||||
|
|
||||||
Итого: **обновляться «по сети» обязан именно `version.txt`**; строка **`$ScriptVersion`** нужна для прозрачности в мониторинге и может совпадать с шарой или отставать по «маркетингу», если вы сознательно крутите только patch в **`version.txt`**.
|
Итого: **обновляться «по сети» обязан именно `version.txt`**; строка **`$ScriptVersion`** нужна для прозрачности в мониторинге и может совпадать с шарой или отставать по «маркетингу», если вы сознательно крутите только patch в **`version.txt`**.
|
||||||
|
|
||||||
## Групповая политика (GPO)
|
## Групповая политика (GPO): проверенная схема
|
||||||
|
|
||||||
1. Положите три файла на шару (см. выше).
|
Ниже шаги, которые были проверены на практике для запуска деплоя при старте компьютера.
|
||||||
2. **Конфигурация компьютера** → **Политики** → **Windows** → **Сценарии (запуск/завершение)** → **Автозагрузка**.
|
|
||||||
3. Добавьте сценарий PowerShell с **путём к установщику на шаре**, например:
|
|
||||||
|
|
||||||
`\\contoso.local\NETLOGON\RDP-login-monitor\Deploy-LoginMonitor.ps1`
|
1. Положите три файла на шару (см. выше), например:
|
||||||
|
|
||||||
Параметры можно оставить пустыми, если рядом на шаре лежат **`Login_Monitor.ps1`** и **`version.txt`**.
|
`\\B26\NETLOGON\RDP-login-monitor\`
|
||||||
|
|
||||||
Скрипт выполняется от **SYSTEM** при старте ОС — этого достаточно для записи в **`ProgramData`** и регистрации задач.
|
2. Создайте/настройте GPO и привяжите её к OU с **компьютерами** (не пользователями).
|
||||||
|
|
||||||
Альтернатива: команда
|
3. В GPO откройте:
|
||||||
|
**Конфигурация компьютера** → **Политики** → **Конфигурация Windows** → **Сценарии (запуск/завершение)** → **Автозагрузка**.
|
||||||
|
|
||||||
```text
|
4. На вкладке **«Сценарии PowerShell»** добавьте скрипт:
|
||||||
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "\\...\Deploy-LoginMonitor.ps1"
|
|
||||||
|
`\\B26\NETLOGON\RDP-login-monitor\Deploy-LoginMonitor.ps1`
|
||||||
|
|
||||||
|
Параметры можно оставить пустыми, если рядом на шаре лежат `Login_Monitor.ps1` и `version.txt`.
|
||||||
|
|
||||||
|
5. Security Filtering:
|
||||||
|
- можно убрать `Authenticated Users`,
|
||||||
|
- добавить группу компьютеров (например `B26\RDP-Login`),
|
||||||
|
- убедиться, что у неё есть права **Read** + **Apply group policy**.
|
||||||
|
|
||||||
|
6. Проверьте, что в группе действительно состоит объект компьютера, например `FVG-PC$`.
|
||||||
|
|
||||||
|
7. Проверьте доступ на шару/NTFS для контекста компьютера (обычно через `Domain Computers` или целевую группу), потому что startup-скрипт выполняется от имени **SYSTEM**.
|
||||||
|
|
||||||
|
### Важные нюансы
|
||||||
|
|
||||||
|
- Кнопка **«Показать файлы»** в свойствах Startup показывает папку GPO в `SYSVOL` (`...\Policies\{GUID}\Machine\Scripts\Startup`).
|
||||||
|
- Если вы указали в настройке сценария **UNC путь в NETLOGON**, ваш `.ps1` в этой папке `SYSVOL` не появится — это нормально.
|
||||||
|
- После изменения membership компьютера в security-группе часто требуется **перезагрузка** (не только `gpupdate /force`), чтобы обновился токен компьютера.
|
||||||
|
|
||||||
|
### Как запускать без ожидания перезагрузки
|
||||||
|
|
||||||
|
- Startup-сценарий GPO штатно отрабатывает при загрузке ОС, но сам `Deploy-LoginMonitor.ps1` после `-InstallTasks` теперь инициирует немедленный `schtasks /Run`:
|
||||||
|
- `RDP-Login-Monitor`
|
||||||
|
- `RDP-Login-Monitor-Watchdog`
|
||||||
|
- Поэтому после фактического запуска deploy монитор и watchdog стартуют сразу.
|
||||||
|
|
||||||
|
### Серверы без перезагрузок: периодический Deploy
|
||||||
|
|
||||||
|
Если сервер перезагружается редко, одного Startup-сценария недостаточно для своевременного обновления. В этом случае добавьте отдельную задачу, которая периодически запускает `Deploy-LoginMonitor.ps1` с шары.
|
||||||
|
|
||||||
|
Если серверов немного, можно обойтись без отдельной задачи: при выпуске новой версии выполнять `Deploy-LoginMonitor.ps1` вручную на нужных серверах.
|
||||||
|
|
||||||
|
Готовый скрипт в репозитории:
|
||||||
|
|
||||||
|
- `Install-DeployScheduledTask.ps1`
|
||||||
|
|
||||||
|
Этот вариант удобен для большого парка серверов, когда обновления нужно подтягивать автоматически и без ручного обхода.
|
||||||
|
|
||||||
|
Пример (каждые 60 минут и сразу запустить):
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
powershell.exe -NoProfile -ExecutionPolicy Bypass -File ".\Install-DeployScheduledTask.ps1" `
|
||||||
|
-TaskName "RDP-Login-Monitor-Deploy" `
|
||||||
|
-DeployScriptPath "\\B26\NETLOGON\RDP-login-monitor\Deploy-LoginMonitor.ps1" `
|
||||||
|
-RepeatMinutes 60 `
|
||||||
|
-RunNow
|
||||||
```
|
```
|
||||||
|
|
||||||
в сценарии **cmd** / отдельном **bat**.
|
Проверка:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
schtasks /Query /TN "RDP-Login-Monitor-Deploy" /V /FO LIST
|
||||||
|
```
|
||||||
|
|
||||||
|
Важно: эта задача отвечает только за доставку обновлений (через `version.txt`). За «живость» процесса по-прежнему отвечает `RDP-Login-Monitor-Watchdog`.
|
||||||
|
|
||||||
|
Пример ручного запуска (для небольшого количества серверов):
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "\\B26\NETLOGON\RDP-login-monitor\Deploy-LoginMonitor.ps1"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Диагностика GPO/Startup
|
||||||
|
|
||||||
|
- События применения политики и startup-сценариев:
|
||||||
|
- `Event Viewer -> Applications and Services Logs -> Microsoft -> Windows -> GroupPolicy -> Operational`
|
||||||
|
- Логи на клиенте:
|
||||||
|
- `C:\ProgramData\RDP-login-monitor\Logs\deploy.log`
|
||||||
|
- `C:\ProgramData\RDP-login-monitor\Logs\login_monitor.log`
|
||||||
|
- `C:\ProgramData\RDP-login-monitor\Logs\watchdog.log`
|
||||||
|
- Быстрая ручная проверка deploy:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "\\B26\NETLOGON\RDP-login-monitor\Deploy-LoginMonitor.ps1"
|
||||||
|
```
|
||||||
|
|
||||||
## Ручная проверка
|
## Ручная проверка
|
||||||
|
|
||||||
|
|||||||
+42
-1
@@ -33,9 +33,22 @@ param(
|
|||||||
Set-StrictMode -Version Latest
|
Set-StrictMode -Version Latest
|
||||||
$ErrorActionPreference = "Stop"
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
|
function Test-DeployRunningElevated {
|
||||||
|
try {
|
||||||
|
$id = [Security.Principal.WindowsIdentity]::GetCurrent()
|
||||||
|
# LocalSystem (GPO startup / задачи SYSTEM): не всегда даёт true на BuiltInRole::Administrator.
|
||||||
|
if ($null -ne $id.User -and $id.User.Value -eq 'S-1-5-18') { return $true }
|
||||||
|
$p = New-Object Security.Principal.WindowsPrincipal($id)
|
||||||
|
return $p.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
|
||||||
|
} catch {
|
||||||
|
return $false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
$InstallRoot = [System.IO.Path]::GetFullPath("$env:ProgramData\RDP-login-monitor")
|
$InstallRoot = [System.IO.Path]::GetFullPath("$env:ProgramData\RDP-login-monitor")
|
||||||
$LocalScript = Join-Path $InstallRoot "Login_Monitor.ps1"
|
$LocalScript = Join-Path $InstallRoot "Login_Monitor.ps1"
|
||||||
$VersionStampPath = Join-Path $InstallRoot "deployed_version.txt"
|
$VersionStampPath = Join-Path $InstallRoot "deployed_version.txt"
|
||||||
|
$DeployUpdateMarkerPath = Join-Path $InstallRoot "deploy_last_update.txt"
|
||||||
$DeployLogPath = Join-Path $InstallRoot "Logs\deploy.log"
|
$DeployLogPath = Join-Path $InstallRoot "Logs\deploy.log"
|
||||||
$ScriptName = "Login_Monitor.ps1"
|
$ScriptName = "Login_Monitor.ps1"
|
||||||
$VersionFileName = "version.txt"
|
$VersionFileName = "version.txt"
|
||||||
@@ -205,6 +218,11 @@ try {
|
|||||||
exit 0
|
exit 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (-not (Test-DeployRunningElevated)) {
|
||||||
|
Write-DeployLog "ОШИБКА: запустите Deploy из повышенной консоли PowerShell («Запуск от имени администратора»). Без этого дочерний Login_Monitor.ps1 -InstallTasks завершится с кодом 1 (нет прав на регистрацию задач)."
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
Stop-RdpLoginMonitorMainProcesses
|
Stop-RdpLoginMonitorMainProcesses
|
||||||
|
|
||||||
Copy-Item -LiteralPath $sourceScript -Destination $LocalScript -Force
|
Copy-Item -LiteralPath $sourceScript -Destination $LocalScript -Force
|
||||||
@@ -213,9 +231,23 @@ try {
|
|||||||
$installArgs = @(
|
$installArgs = @(
|
||||||
'-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', $LocalScript, '-InstallTasks'
|
'-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', $LocalScript, '-InstallTasks'
|
||||||
)
|
)
|
||||||
$p = Start-Process -FilePath $PsExe -ArgumentList $installArgs -Wait -PassThru -WindowStyle Hidden
|
$instOut = Join-Path $InstallRoot "Logs\deploy_installtasks_stdout.log"
|
||||||
|
$instErr = Join-Path $InstallRoot "Logs\deploy_installtasks_stderr.log"
|
||||||
|
$p = Start-Process -FilePath $PsExe -ArgumentList $installArgs -Wait -PassThru -WindowStyle Hidden `
|
||||||
|
-RedirectStandardOutput $instOut -RedirectStandardError $instErr
|
||||||
if ($p.ExitCode -ne 0) {
|
if ($p.ExitCode -ne 0) {
|
||||||
Write-DeployLog "Предупреждение: InstallTasks завершился с кодом $($p.ExitCode)."
|
Write-DeployLog "Предупреждение: InstallTasks завершился с кодом $($p.ExitCode)."
|
||||||
|
foreach ($pair in @(@($instOut, 'stdout'), @($instErr, 'stderr'))) {
|
||||||
|
$lp = $pair[0]
|
||||||
|
$lbl = $pair[1]
|
||||||
|
if (Test-Path -LiteralPath $lp) {
|
||||||
|
$tail = Get-Content -LiteralPath $lp -Tail 40 -ErrorAction SilentlyContinue
|
||||||
|
if ($tail) {
|
||||||
|
Write-DeployLog "InstallTasks $lbl (хвост): $($tail -join ' | ')"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Write-DeployLog "Подсказка: полный вывод в Logs\deploy_installtasks_*.log; также см. login_monitor.log за это время."
|
||||||
} else {
|
} else {
|
||||||
Write-DeployLog "InstallTasks выполнен (код 0)."
|
Write-DeployLog "InstallTasks выполнен (код 0)."
|
||||||
}
|
}
|
||||||
@@ -223,6 +255,15 @@ try {
|
|||||||
[System.IO.File]::WriteAllText($VersionStampPath, "$shareVerRaw`r`n", $Utf8Bom)
|
[System.IO.File]::WriteAllText($VersionStampPath, "$shareVerRaw`r`n", $Utf8Bom)
|
||||||
Write-DeployLog "Записана метка версии: $VersionStampPath"
|
Write-DeployLog "Записана метка версии: $VersionStampPath"
|
||||||
|
|
||||||
|
$updStamp = Get-Date -Format "dd.MM.yyyy HH:mm:ss"
|
||||||
|
$updMarker = @(
|
||||||
|
"Version=$shareVerRaw"
|
||||||
|
"UpdatedAt=$updStamp"
|
||||||
|
"PendingStartupNotice=1"
|
||||||
|
) -join "`r`n"
|
||||||
|
[System.IO.File]::WriteAllText($DeployUpdateMarkerPath, "$updMarker`r`n", $Utf8Bom)
|
||||||
|
Write-DeployLog "Записана метка обновления: $DeployUpdateMarkerPath (Version=$shareVerRaw; UpdatedAt=$updStamp)."
|
||||||
|
|
||||||
if (-not $SkipStartMonitorAfterUpdate) {
|
if (-not $SkipStartMonitorAfterUpdate) {
|
||||||
Start-Process -FilePath $PsExe -ArgumentList @(
|
Start-Process -FilePath $PsExe -ArgumentList @(
|
||||||
'-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', $LocalScript
|
'-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', $LocalScript
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
#Requires -RunAsAdministrator
|
||||||
|
[CmdletBinding()]
|
||||||
|
param(
|
||||||
|
[string]$TaskName = "RDP-Login-Monitor-Deploy",
|
||||||
|
[string]$DeployScriptPath = "\\B26\NETLOGON\RDP-login-monitor\Deploy-LoginMonitor.ps1",
|
||||||
|
[ValidateRange(5, 1440)][int]$RepeatMinutes = 60,
|
||||||
|
[switch]$RunNow
|
||||||
|
)
|
||||||
|
|
||||||
|
Set-StrictMode -Version Latest
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
|
if ([string]::IsNullOrWhiteSpace($DeployScriptPath)) {
|
||||||
|
throw "DeployScriptPath не задан."
|
||||||
|
}
|
||||||
|
|
||||||
|
$psExe = "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe"
|
||||||
|
if (-not (Test-Path -LiteralPath $psExe)) {
|
||||||
|
throw "Не найден powershell.exe по пути: $psExe"
|
||||||
|
}
|
||||||
|
|
||||||
|
$taskCmd = "`"$psExe`" -NoProfile -ExecutionPolicy Bypass -File `"$DeployScriptPath`""
|
||||||
|
$schtasksExe = Join-Path $env:SystemRoot "System32\schtasks.exe"
|
||||||
|
|
||||||
|
Write-Host "Создаю/обновляю задачу: $TaskName" -ForegroundColor Cyan
|
||||||
|
Write-Host "Команда: $taskCmd" -ForegroundColor DarkGray
|
||||||
|
Write-Host "Периодичность: каждые $RepeatMinutes мин." -ForegroundColor DarkGray
|
||||||
|
|
||||||
|
& $schtasksExe /Create /F /TN $TaskName /RU SYSTEM /RL HIGHEST /SC MINUTE /MO $RepeatMinutes /TR $taskCmd | Out-Host
|
||||||
|
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
throw "schtasks /Create вернул код $LASTEXITCODE"
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "Задача создана/обновлена: $TaskName" -ForegroundColor Green
|
||||||
|
|
||||||
|
if ($RunNow) {
|
||||||
|
Write-Host "Запускаю задачу немедленно..." -ForegroundColor Cyan
|
||||||
|
& $schtasksExe /Run /TN $TaskName | Out-Host
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
throw "schtasks /Run вернул код $LASTEXITCODE"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "Готово." -ForegroundColor Green
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 Andrey Lutsenko (PapaTramp)
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
+397
-38
@@ -61,15 +61,15 @@ $script:InstallRoot = [System.IO.Path]::GetFullPath("$env:ProgramData\RDP-login-
|
|||||||
$script:CanonicalScriptName = "Login_Monitor.ps1"
|
$script:CanonicalScriptName = "Login_Monitor.ps1"
|
||||||
$script:ScheduledTaskNameMain = "RDP-Login-Monitor"
|
$script:ScheduledTaskNameMain = "RDP-Login-Monitor"
|
||||||
$script:ScheduledTaskNameWatchdog = "RDP-Login-Monitor-Watchdog"
|
$script:ScheduledTaskNameWatchdog = "RDP-Login-Monitor-Watchdog"
|
||||||
$script:MutexName = "Global\RDP-Login-Monitor-Singleton-v1"
|
# Один экземпляр: эксклюзивная блокировка файла в InstallRoot (SYSTEM и интерактивный админ — одинаково; Global mutex давал «Отказано в доступе» между контекстами).
|
||||||
$script:RdpInstanceMutex = $null
|
$script:MonitorSingletonLockStream = $null
|
||||||
|
|
||||||
# Версия: пишется в лог и в Telegram. При доменном развёртывании через шару см. DEPLOY.md —
|
# Версия: пишется в лог и в Telegram. При доменном развёртывании через шару см. DEPLOY.md —
|
||||||
# триггер обновления на клиентах даёт файл version.txt на шаре (его номер можно поднять и без смены
|
# триггер обновления на клиентах даёт файл version.txt на шаре (его номер можно поднять и без смены
|
||||||
# строки ниже, если правки «мелкие» и вы не хотите менять отображаемую версию в логах).
|
# строки ниже, если правки «мелкие» и вы не хотите менять отображаемую версию в логах).
|
||||||
# Рекомендация: при значимых релизах меняйте и $ScriptVersion, и version.txt одинаково; при только
|
# Рекомендация: при значимых релизах меняйте и $ScriptVersion, и version.txt одинаково; при только
|
||||||
# исправлениях на шаре — достаточно поднять patch в version.txt (например 1.3.0.1).
|
# исправлениях на шаре — достаточно поднять patch в version.txt (например 1.3.0.1).
|
||||||
$ScriptVersion = "1.3.4"
|
$ScriptVersion = "1.4.3"
|
||||||
|
|
||||||
# Логи (все под InstallRoot)
|
# Логи (все под InstallRoot)
|
||||||
$LogFile = Join-Path $script:InstallRoot "Logs\login_monitor.log"
|
$LogFile = Join-Path $script:InstallRoot "Logs\login_monitor.log"
|
||||||
@@ -83,6 +83,11 @@ $LogRotationMinute = 0
|
|||||||
# Heartbeat (только файл)
|
# Heartbeat (только файл)
|
||||||
$HeartbeatInterval = 3600
|
$HeartbeatInterval = 3600
|
||||||
$HeartbeatFile = Join-Path $script:InstallRoot "Logs\last_heartbeat.txt"
|
$HeartbeatFile = Join-Path $script:InstallRoot "Logs\last_heartbeat.txt"
|
||||||
|
$DeployUpdateMarkerFile = Join-Path $script:InstallRoot "deploy_last_update.txt"
|
||||||
|
# Построчные правила подавления уведомлений Security 4624/4625 (см. ignore.lst.example в репозитории).
|
||||||
|
$script:IgnoreListPath = Join-Path $script:InstallRoot "ignore.lst"
|
||||||
|
$script:IgnoreListCache = $null
|
||||||
|
$script:IgnoreListCacheStampUtc = $null
|
||||||
|
|
||||||
# Ежедневный отчет
|
# Ежедневный отчет
|
||||||
$DailyReportHour = 9
|
$DailyReportHour = 9
|
||||||
@@ -192,6 +197,10 @@ function Write-Log {
|
|||||||
function Get-RdpMonitorThisScriptPath {
|
function Get-RdpMonitorThisScriptPath {
|
||||||
$p = $PSCommandPath
|
$p = $PSCommandPath
|
||||||
if ([string]::IsNullOrWhiteSpace($p)) { $p = $MyInvocation.MyCommand.Path }
|
if ([string]::IsNullOrWhiteSpace($p)) { $p = $MyInvocation.MyCommand.Path }
|
||||||
|
# При запуске дочернего процесса через Start-Process иногда нет PSCommandPath — используем каталог скрипта.
|
||||||
|
if ([string]::IsNullOrWhiteSpace($p) -and -not [string]::IsNullOrWhiteSpace($PSScriptRoot)) {
|
||||||
|
$p = Join-Path $PSScriptRoot $script:CanonicalScriptName
|
||||||
|
}
|
||||||
if ([string]::IsNullOrWhiteSpace($p)) { return $null }
|
if ([string]::IsNullOrWhiteSpace($p)) { return $null }
|
||||||
return [System.IO.Path]::GetFullPath($p)
|
return [System.IO.Path]::GetFullPath($p)
|
||||||
}
|
}
|
||||||
@@ -273,11 +282,47 @@ function Invoke-RdpMonitorProcessMigrationAndRelaunch {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function Lock-RdpMonitorSingleInstance {
|
function Lock-RdpMonitorSingleInstance {
|
||||||
$mtx = New-Object System.Threading.Mutex($false, $script:MutexName)
|
# Эксклюзивный поток (FileShare.None): пока монитор работает, файл нельзя удалить вручную —
|
||||||
$script:RdpInstanceMutex = $mtx
|
# это нормально. DeleteOnClose: при любом завершении процесса ОС удалит файл при закрытии
|
||||||
if (-not $mtx.WaitOne(0)) {
|
# дескриптора (в т.ч. при «Снять задачу»), без «вечных» сирот на диске.
|
||||||
Write-Log "Экземпляр монитора уже активен (mutex). Выход без дублирования."
|
$lockPath = Join-Path $script:InstallRoot '.login_monitor_single_instance.lock'
|
||||||
|
try {
|
||||||
|
$script:MonitorSingletonLockStream = New-Object System.IO.FileStream(
|
||||||
|
$lockPath,
|
||||||
|
[System.IO.FileMode]::OpenOrCreate,
|
||||||
|
[System.IO.FileAccess]::ReadWrite,
|
||||||
|
[System.IO.FileShare]::None,
|
||||||
|
4096,
|
||||||
|
[System.IO.FileOptions]::DeleteOnClose
|
||||||
|
)
|
||||||
|
} catch [System.IO.IOException] {
|
||||||
|
Write-Log "Экземпляр монитора уже активен (блокировка файла). Выход без дублирования."
|
||||||
exit 0
|
exit 0
|
||||||
|
} catch {
|
||||||
|
Write-Log "Не удалось занять блокировку экземпляра (${lockPath}): $($_.Exception.Message)"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Release-RdpMonitorSingletonLock {
|
||||||
|
$lockPath = Join-Path $script:InstallRoot '.login_monitor_single_instance.lock'
|
||||||
|
if ($null -ne $script:MonitorSingletonLockStream) {
|
||||||
|
try {
|
||||||
|
$script:MonitorSingletonLockStream.Close()
|
||||||
|
} catch { }
|
||||||
|
try {
|
||||||
|
$script:MonitorSingletonLockStream.Dispose()
|
||||||
|
} catch { }
|
||||||
|
$script:MonitorSingletonLockStream = $null
|
||||||
|
}
|
||||||
|
if (Test-Path -LiteralPath $lockPath) {
|
||||||
|
try {
|
||||||
|
Remove-Item -LiteralPath $lockPath -Force -ErrorAction Stop
|
||||||
|
} catch {
|
||||||
|
try {
|
||||||
|
[System.IO.File]::Delete($lockPath)
|
||||||
|
} catch { }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -341,7 +386,22 @@ function Register-RdpMonitorScheduledTasksCore {
|
|||||||
}
|
}
|
||||||
Write-Log "Задача планировщика: $($script:ScheduledTaskNameWatchdog) (schtasks, каждые 5 минут, контроль процесса)."
|
Write-Log "Задача планировщика: $($script:ScheduledTaskNameWatchdog) (schtasks, каждые 5 минут, контроль процесса)."
|
||||||
|
|
||||||
# Первый запуск по расписанию может быть почти через 5 мин — сразу ставим одноразовый прогон в очередь.
|
# Не ждём перезагрузку: сразу запускаем основную задачу.
|
||||||
|
$runMainEa = $ErrorActionPreference
|
||||||
|
try {
|
||||||
|
$ErrorActionPreference = 'SilentlyContinue'
|
||||||
|
$runMainOut = & $schtasksExe /Run /TN $script:ScheduledTaskNameMain 2>&1
|
||||||
|
foreach ($line in @($runMainOut)) {
|
||||||
|
if ($null -ne $line -and "$line".Trim().Length -gt 0) {
|
||||||
|
Write-Log "schtasks main /Run: $line"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
$ErrorActionPreference = $runMainEa
|
||||||
|
}
|
||||||
|
Write-Log "Немедленный прогон основной задачи запрошен (schtasks /Run)."
|
||||||
|
|
||||||
|
# Первый запуск watchdog по расписанию может быть почти через 5 мин — ставим одноразовый прогон в очередь.
|
||||||
$runEa = $ErrorActionPreference
|
$runEa = $ErrorActionPreference
|
||||||
try {
|
try {
|
||||||
$ErrorActionPreference = 'SilentlyContinue'
|
$ErrorActionPreference = 'SilentlyContinue'
|
||||||
@@ -409,9 +469,18 @@ function Start-RdpMonitorWatchdogMain {
|
|||||||
|
|
||||||
if (-not $mainRunning) {
|
if (-not $mainRunning) {
|
||||||
Write-WdLog "Монитор не найден — запуск $canonicalScript"
|
Write-WdLog "Монитор не найден — запуск $canonicalScript"
|
||||||
Start-Process -FilePath (Get-RdpMonitorPowerShellExe) -ArgumentList @(
|
$psExeWd = Get-RdpMonitorPowerShellExe
|
||||||
|
$started = Start-Process -FilePath $psExeWd -WorkingDirectory $script:InstallRoot -ArgumentList @(
|
||||||
'-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', $canonicalScript
|
'-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', $canonicalScript
|
||||||
) -WindowStyle Hidden
|
) -WindowStyle Hidden -PassThru
|
||||||
|
Write-WdLog "Монитор: создан процесс PID=$($started.Id)."
|
||||||
|
Start-Sleep -Seconds 5
|
||||||
|
$still = Get-Process -Id $started.Id -ErrorAction SilentlyContinue
|
||||||
|
if (-not $still) {
|
||||||
|
Write-WdLog "ПРЕДУПРЕЖДЕНИЕ: процесс монитора PID $($started.Id) завершился за несколько секунд — см. конец $script:LogFile (ошибка до цикла мониторинга)."
|
||||||
|
} else {
|
||||||
|
Write-WdLog "Монитор: процесс PID $($started.Id) ещё работает после старта."
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
Write-WdLog "Монитор работает (канонический экземпляр найден)."
|
Write-WdLog "Монитор работает (канонический экземпляр найден)."
|
||||||
}
|
}
|
||||||
@@ -422,6 +491,24 @@ function Start-RdpMonitorWatchdogMain {
|
|||||||
exit 0
|
exit 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Watchdog и InstallTasks — до DPAPI/Telegram: Deploy вызывает -InstallTasks без зависимости от секретов на машине.
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
# --- Учётные данные Telegram (открытый текст или DPAPI Base64) ---
|
# --- Учётные данные Telegram (открытый текст или DPAPI Base64) ---
|
||||||
if (-not [string]::IsNullOrWhiteSpace($TelegramBotTokenProtectedB64)) {
|
if (-not [string]::IsNullOrWhiteSpace($TelegramBotTokenProtectedB64)) {
|
||||||
try {
|
try {
|
||||||
@@ -442,23 +529,6 @@ if (-not [string]::IsNullOrWhiteSpace($TelegramChatIDProtectedB64)) {
|
|||||||
if ($TelegramBotToken -eq '<TELEGRAM_BOT_TOKEN>') { $TelegramBotToken = "" }
|
if ($TelegramBotToken -eq '<TELEGRAM_BOT_TOKEN>') { $TelegramBotToken = "" }
|
||||||
if ($TelegramChatID -eq '<TELEGRAM_CHAT_ID>') { $TelegramChatID = "" }
|
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 {
|
function ConvertTo-TelegramHtml {
|
||||||
param([string]$Text)
|
param([string]$Text)
|
||||||
if ($null -eq $Text) { return '' }
|
if ($null -eq $Text) { return '' }
|
||||||
@@ -819,8 +889,53 @@ function Send-Heartbeat {
|
|||||||
$timestamp = Get-Date -Format "dd.MM.yyyy HH:mm:ss"
|
$timestamp = Get-Date -Format "dd.MM.yyyy HH:mm:ss"
|
||||||
$hHost = (ConvertTo-TelegramHtml $env:COMPUTERNAME)
|
$hHost = (ConvertTo-TelegramHtml $env:COMPUTERNAME)
|
||||||
|
|
||||||
|
function Get-DeployUpdateMarker {
|
||||||
|
$info = [pscustomobject]@{
|
||||||
|
Version = $null
|
||||||
|
UpdatedAt = $null
|
||||||
|
PendingStartupNotice = $false
|
||||||
|
}
|
||||||
|
if (-not (Test-Path -LiteralPath $DeployUpdateMarkerFile)) { return $info }
|
||||||
|
try {
|
||||||
|
$lines = @((Get-Content -LiteralPath $DeployUpdateMarkerFile -ErrorAction Stop) | Where-Object { $_ -match '\S' })
|
||||||
|
foreach ($ln in $lines) {
|
||||||
|
$parts = $ln -split '=', 2
|
||||||
|
if ($parts.Count -ne 2) { continue }
|
||||||
|
$k = ($parts[0]).Trim()
|
||||||
|
$v = ($parts[1]).Trim()
|
||||||
|
switch ($k) {
|
||||||
|
'Version' { $info.Version = $v }
|
||||||
|
'UpdatedAt' { $info.UpdatedAt = $v }
|
||||||
|
'PendingStartupNotice' { $info.PendingStartupNotice = ($v -eq '1' -or $v -ieq 'true') }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch { }
|
||||||
|
return $info
|
||||||
|
}
|
||||||
|
|
||||||
|
function Set-DeployUpdateMarkerPendingOff {
|
||||||
|
param([pscustomobject]$Marker)
|
||||||
|
try {
|
||||||
|
$ver = if ([string]::IsNullOrWhiteSpace($Marker.Version)) { '' } else { $Marker.Version }
|
||||||
|
$upd = if ([string]::IsNullOrWhiteSpace($Marker.UpdatedAt)) { '' } else { $Marker.UpdatedAt }
|
||||||
|
$content = @(
|
||||||
|
"Version=$ver"
|
||||||
|
"UpdatedAt=$upd"
|
||||||
|
"PendingStartupNotice=0"
|
||||||
|
) -join "`r`n"
|
||||||
|
Write-TextFileUtf8Bom -Path $DeployUpdateMarkerFile -Text $content
|
||||||
|
} catch { }
|
||||||
|
}
|
||||||
|
|
||||||
if ($IsStartup) {
|
if ($IsStartup) {
|
||||||
$message = "<b>✅ Мониторинг логинов ЗАПУЩЕН</b>`r`n"
|
$message = "<b>✅ Мониторинг логинов ЗАПУЩЕН</b>`r`n"
|
||||||
|
$message += "🏷️ Версия скрипта: $(ConvertTo-TelegramHtml $ScriptVersion)"
|
||||||
|
$upd = Get-DeployUpdateMarker
|
||||||
|
if ($upd.PendingStartupNotice -and $upd.Version -eq $ScriptVersion -and -not [string]::IsNullOrWhiteSpace($upd.UpdatedAt)) {
|
||||||
|
$message += " (обновлён $(ConvertTo-TelegramHtml $upd.UpdatedAt))"
|
||||||
|
Set-DeployUpdateMarkerPendingOff -Marker $upd
|
||||||
|
}
|
||||||
|
$message += "`r`n"
|
||||||
$message += "🖥️ Сервер: $hHost`r`n"
|
$message += "🖥️ Сервер: $hHost`r`n"
|
||||||
$message += "🕐 Время запуска: $timestamp"
|
$message += "🕐 Время запуска: $timestamp"
|
||||||
if ($script:OsInstallKindLabel) {
|
if ($script:OsInstallKindLabel) {
|
||||||
@@ -831,6 +946,23 @@ function Send-Heartbeat {
|
|||||||
} else {
|
} else {
|
||||||
$message += "`r`n📌 <b>Режим:</b> сервер — Security 4624/4625, типы входа 2, 3, 10."
|
$message += "`r`n📌 <b>Режим:</b> сервер — Security 4624/4625, типы входа 2, 3, 10."
|
||||||
}
|
}
|
||||||
|
$ignoreEntries = @(Get-RdpMonitorIgnoreListEntries)
|
||||||
|
if ($ignoreEntries.Count -gt 0) {
|
||||||
|
$message += "`r`n🚫 <b>Игнорируются:</b> Security 4624/4625 по правилам ignore.lst`r`n"
|
||||||
|
foreach ($e in $ignoreEntries) {
|
||||||
|
$v = ConvertTo-TelegramHtml ([string]$e.Value)
|
||||||
|
$kindLabel = switch ($e.Kind) {
|
||||||
|
'User' { 'Пользователь' }
|
||||||
|
'Workstation' { 'Рабочая станция' }
|
||||||
|
'Ip' { 'IP' }
|
||||||
|
'Any' { 'Универсальное правило' }
|
||||||
|
default { 'Правило' }
|
||||||
|
}
|
||||||
|
$message += ('• {0}: {1}' -f $kindLabel, $v) + "`r`n"
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$message += "`r`n🚫 <b>Игнорируются:</b> не задано (ignore.lst отсутствует или пуст)."
|
||||||
|
}
|
||||||
if (Test-RDSDeploymentPresent) {
|
if (Test-RDSDeploymentPresent) {
|
||||||
$message += "`r`n🔐 <b>RDS (хост сессий):</b> обнаружены компоненты RDS помимо чистого шлюза — в мониторинг входят входы по RDP/RDS на этом узле (Security 4624/4625, типы входа по настройке скрипта)."
|
$message += "`r`n🔐 <b>RDS (хост сессий):</b> обнаружены компоненты RDS помимо чистого шлюза — в мониторинг входят входы по RDP/RDS на этом узле (Security 4624/4625, типы входа по настройке скрипта)."
|
||||||
}
|
}
|
||||||
@@ -995,6 +1127,137 @@ function Get-LogonTypeName {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function Test-RdpMonitorUsernameMatchesToken {
|
||||||
|
param([string]$Username, [string]$Token)
|
||||||
|
if ([string]::IsNullOrWhiteSpace($Username) -or [string]::IsNullOrWhiteSpace($Token)) { return $false }
|
||||||
|
if ($Username -ieq $Token) { return $true }
|
||||||
|
if ($Token.Contains('\')) {
|
||||||
|
return ($Username -ieq $Token)
|
||||||
|
}
|
||||||
|
$i = $Username.LastIndexOf('\')
|
||||||
|
if ($i -ge 0 -and $i -lt ($Username.Length - 1)) {
|
||||||
|
$sam = $Username.Substring($i + 1)
|
||||||
|
if ($sam -ieq $Token) { return $true }
|
||||||
|
}
|
||||||
|
return $false
|
||||||
|
}
|
||||||
|
|
||||||
|
function Parse-RdpMonitorIgnoreListLine {
|
||||||
|
param([string]$RawLine)
|
||||||
|
$line = ([string]$RawLine).Trim()
|
||||||
|
if ($line.Length -eq 0) { return $null }
|
||||||
|
if ($line[0] -eq '#' -or $line[0] -eq ';') { return $null }
|
||||||
|
if ($line.StartsWith([char]0xFEFF)) { $line = $line.TrimStart([char]0xFEFF) }
|
||||||
|
|
||||||
|
if ($line -notmatch ':') {
|
||||||
|
return [pscustomobject]@{ Kind = 'Any'; Value = $line }
|
||||||
|
}
|
||||||
|
|
||||||
|
$idx = $line.IndexOf(':')
|
||||||
|
$left = $line.Substring(0, $idx).Trim()
|
||||||
|
$right = $line.Substring($idx + 1).Trim()
|
||||||
|
if ([string]::IsNullOrWhiteSpace($right)) { return $null }
|
||||||
|
|
||||||
|
if ($left -match '(?i)(рабоч|workstation|wks)') {
|
||||||
|
return [pscustomobject]@{ Kind = 'Workstation'; Value = $right }
|
||||||
|
}
|
||||||
|
if ($left -match '(?i)(польз|username|subject|account|target\s*user|\buser\b)') {
|
||||||
|
return [pscustomobject]@{ Kind = 'User'; Value = $right }
|
||||||
|
}
|
||||||
|
if ($left -match '(?i)(\bip\b|ip\s*адрес|ipaddress|адрес\s*ip)') {
|
||||||
|
return [pscustomobject]@{ Kind = 'Ip'; Value = $right }
|
||||||
|
}
|
||||||
|
|
||||||
|
return [pscustomobject]@{ Kind = 'Any'; Value = $right }
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-RdpMonitorIgnoreListEntries {
|
||||||
|
if (-not (Test-Path -LiteralPath $script:IgnoreListPath)) {
|
||||||
|
$script:IgnoreListCache = @()
|
||||||
|
$script:IgnoreListCacheStampUtc = $null
|
||||||
|
return $script:IgnoreListCache
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
$fi = Get-Item -LiteralPath $script:IgnoreListPath -ErrorAction Stop
|
||||||
|
$stamp = $fi.LastWriteTimeUtc
|
||||||
|
if ($null -ne $script:IgnoreListCache -and $script:IgnoreListCacheStampUtc -eq $stamp) {
|
||||||
|
return $script:IgnoreListCache
|
||||||
|
}
|
||||||
|
$entries = [System.Collections.Generic.List[object]]::new()
|
||||||
|
foreach ($ln in (Get-Content -LiteralPath $script:IgnoreListPath -Encoding UTF8 -ErrorAction Stop)) {
|
||||||
|
$e = Parse-RdpMonitorIgnoreListLine -RawLine $ln
|
||||||
|
if ($null -ne $e) { $entries.Add($e) | Out-Null }
|
||||||
|
}
|
||||||
|
$script:IgnoreListCache = @($entries)
|
||||||
|
$script:IgnoreListCacheStampUtc = $stamp
|
||||||
|
$arr = $script:IgnoreListCache
|
||||||
|
$nIp = @($arr | Where-Object { $_.Kind -eq 'Ip' }).Count
|
||||||
|
$nUser = @($arr | Where-Object { $_.Kind -eq 'User' }).Count
|
||||||
|
$nWks = @($arr | Where-Object { $_.Kind -eq 'Workstation' }).Count
|
||||||
|
$nAny = @($arr | Where-Object { $_.Kind -eq 'Any' }).Count
|
||||||
|
$nTotal = $arr.Count
|
||||||
|
if ($nTotal -eq 0) {
|
||||||
|
Write-Log "ignore.lst обновлён: список правил пуст, игнорирование по файлу для Security 4624/4625 не задаётся."
|
||||||
|
} else {
|
||||||
|
Write-Log ("ignore.lst обновлён: добавлено игнорирование событий 4624/4625 по IP ({0}), пользователю ({1}), рабочей станции ({2}); универсальных правил ({3}). Всего записей: {4}." -f $nIp, $nUser, $nWks, $nAny, $nTotal)
|
||||||
|
}
|
||||||
|
return $script:IgnoreListCache
|
||||||
|
} catch {
|
||||||
|
Write-Log "Предупреждение: не удалось прочитать ignore.lst: $($_.Exception.Message)"
|
||||||
|
$script:IgnoreListCache = @()
|
||||||
|
$script:IgnoreListCacheStampUtc = $null
|
||||||
|
return $script:IgnoreListCache
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Test-RdpMonitorIgnoreListMatch {
|
||||||
|
param(
|
||||||
|
[string]$Username,
|
||||||
|
[string]$ComputerName,
|
||||||
|
[string]$SourceIP
|
||||||
|
)
|
||||||
|
$entries = @(Get-RdpMonitorIgnoreListEntries)
|
||||||
|
if ($entries.Count -eq 0) { return $false }
|
||||||
|
|
||||||
|
foreach ($e in $entries) {
|
||||||
|
$v = [string]$e.Value
|
||||||
|
if ([string]::IsNullOrWhiteSpace($v)) { continue }
|
||||||
|
|
||||||
|
switch ($e.Kind) {
|
||||||
|
'User' {
|
||||||
|
if (Test-RdpMonitorUsernameMatchesToken -Username $Username -Token $v) { return $true }
|
||||||
|
}
|
||||||
|
'Workstation' {
|
||||||
|
if (-not [string]::IsNullOrWhiteSpace($ComputerName) -and $ComputerName -ne '-' -and ($ComputerName -ieq $v)) {
|
||||||
|
return $true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
'Ip' {
|
||||||
|
if (-not [string]::IsNullOrWhiteSpace($SourceIP) -and $SourceIP -ne '-' -and ($SourceIP -ieq $v)) {
|
||||||
|
return $true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
'Any' {
|
||||||
|
if (Test-RdpMonitorStringLooksLikeIPv4 $v) {
|
||||||
|
if (-not [string]::IsNullOrWhiteSpace($SourceIP) -and $SourceIP -ne '-' -and ($SourceIP -ieq $v)) {
|
||||||
|
return $true
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if ($v.Contains('\')) {
|
||||||
|
if (Test-RdpMonitorUsernameMatchesToken -Username $Username -Token $v) { return $true }
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (-not [string]::IsNullOrWhiteSpace($ComputerName) -and $ComputerName -ne '-' -and ($ComputerName -ieq $v)) {
|
||||||
|
return $true
|
||||||
|
}
|
||||||
|
if (Test-RdpMonitorUsernameMatchesToken -Username $Username -Token $v) { return $true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $false
|
||||||
|
}
|
||||||
|
|
||||||
function Should-IgnoreEvent {
|
function Should-IgnoreEvent {
|
||||||
param(
|
param(
|
||||||
[string]$Username,
|
[string]$Username,
|
||||||
@@ -1052,6 +1315,12 @@ function Should-IgnoreEvent {
|
|||||||
if ($ComputerName -like $pattern) { return $true }
|
if ($ComputerName -like $pattern) { return $true }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($EventID -in 4624, 4625) {
|
||||||
|
if (Test-RdpMonitorIgnoreListMatch -Username $Username -ComputerName $ComputerName -SourceIP $SourceIP) {
|
||||||
|
return $true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return $false
|
return $false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1149,6 +1418,13 @@ function Test-RDGatewayLog {
|
|||||||
return $false
|
return $false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function Test-RdpMonitorStringLooksLikeIPv4 {
|
||||||
|
param([string]$Value)
|
||||||
|
if ([string]::IsNullOrWhiteSpace($Value)) { return $false }
|
||||||
|
$t = $Value.Trim()
|
||||||
|
return [bool]($t -match '^(?:(?:25[0-5]|2[0-4]\d|[01]?\d\d?)\.){3}(?:25[0-5]|2[0-4]\d|[01]?\d\d?)$')
|
||||||
|
}
|
||||||
|
|
||||||
function Get-RDGatewayEventInfo {
|
function Get-RDGatewayEventInfo {
|
||||||
param($Event)
|
param($Event)
|
||||||
$eventData = @{
|
$eventData = @{
|
||||||
@@ -1160,22 +1436,92 @@ function Get-RDGatewayEventInfo {
|
|||||||
ErrorCode = "N/A"
|
ErrorCode = "N/A"
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
|
$map = Get-EventDataMap -Event $Event
|
||||||
|
$lc = @{}
|
||||||
|
foreach ($k in $map.Keys) {
|
||||||
|
try { $lc[$k.ToLowerInvariant()] = $map[$k] } catch { }
|
||||||
|
}
|
||||||
|
function Get-RdpGwMapVal([string[]]$Keys) {
|
||||||
|
foreach ($key in $Keys) {
|
||||||
|
$lk = $key.ToLowerInvariant()
|
||||||
|
if (-not $lc.ContainsKey($lk)) { continue }
|
||||||
|
$v = $lc[$lk]
|
||||||
|
if (-not [string]::IsNullOrWhiteSpace($v)) { return [string]$v }
|
||||||
|
}
|
||||||
|
return $null
|
||||||
|
}
|
||||||
|
|
||||||
|
$u = Get-RdpGwMapVal @('username','user','authusername')
|
||||||
|
$ext = Get-RdpGwMapVal @('clientaddress','clientip','ipaddress','address','remoteaddress','sourceaddress')
|
||||||
|
$int = Get-RdpGwMapVal @('targetserver','targetname','resource','server','internaladdress','destinationaddress','targetaddress','devicename','computername')
|
||||||
|
$proto = Get-RdpGwMapVal @('protocol','transport','connectionprotocol','tunneltype')
|
||||||
|
$err = Get-RdpGwMapVal @('errorcode','statuscode','error','resultcode','status')
|
||||||
|
|
||||||
|
if ($u) { $eventData.Username = $u }
|
||||||
|
if ($ext) { $eventData.ExternalIP = $ext }
|
||||||
|
if ($int) { $eventData.InternalIP = $int }
|
||||||
|
if ($proto) { $eventData.Protocol = $proto }
|
||||||
|
if ($err) { $eventData.ErrorCode = $err }
|
||||||
|
|
||||||
|
$fillFromProps = ($lc.Count -eq 0) -or (
|
||||||
|
(($eventData.InternalIP -eq 'N/A' -or [string]::IsNullOrWhiteSpace($eventData.InternalIP)) -and (
|
||||||
|
($eventData.Protocol -eq 'N/A' -or [string]::IsNullOrWhiteSpace($eventData.Protocol)) -or
|
||||||
|
(Test-RdpMonitorStringLooksLikeIPv4 $eventData.Protocol)
|
||||||
|
))
|
||||||
|
)
|
||||||
|
|
||||||
|
if ($fillFromProps) {
|
||||||
switch ($Event.Id) {
|
switch ($Event.Id) {
|
||||||
302 {
|
302 {
|
||||||
if ($Event.Properties.Count -gt 0) { $eventData.Username = $Event.Properties[0].Value }
|
if ($Event.Properties.Count -gt 0) { $eventData.Username = [string]$Event.Properties[0].Value }
|
||||||
if ($Event.Properties.Count -gt 1) { $eventData.ExternalIP = $Event.Properties[1].Value }
|
if ($Event.Properties.Count -gt 1) { $eventData.ExternalIP = [string]$Event.Properties[1].Value }
|
||||||
if ($Event.Properties.Count -gt 2) { $eventData.InternalIP = $Event.Properties[2].Value }
|
if ($Event.Properties.Count -gt 2) {
|
||||||
if ($Event.Properties.Count -gt 3) { $eventData.Protocol = $Event.Properties[3].Value }
|
$p2 = [string]$Event.Properties[2].Value
|
||||||
|
$p3 = if ($Event.Properties.Count -gt 3) { [string]$Event.Properties[3].Value } else { '' }
|
||||||
|
$p4 = if ($Event.Properties.Count -gt 4) { [string]$Event.Properties[4].Value } else { '' }
|
||||||
|
if ([string]::IsNullOrWhiteSpace($p2) -and (Test-RdpMonitorStringLooksLikeIPv4 $p3)) {
|
||||||
|
$eventData.InternalIP = $p3.Trim()
|
||||||
|
if (-not [string]::IsNullOrWhiteSpace($p4)) {
|
||||||
|
$eventData.Protocol = $p4.Trim()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$eventData.InternalIP = $p2.Trim()
|
||||||
|
if (-not [string]::IsNullOrWhiteSpace($p3)) { $eventData.Protocol = $p3.Trim() }
|
||||||
|
}
|
||||||
|
}
|
||||||
$eventData.ErrorCode = "0"
|
$eventData.ErrorCode = "0"
|
||||||
}
|
}
|
||||||
303 {
|
303 {
|
||||||
if ($Event.Properties.Count -gt 0) { $eventData.Username = $Event.Properties[0].Value }
|
if ($Event.Properties.Count -gt 0) { $eventData.Username = [string]$Event.Properties[0].Value }
|
||||||
if ($Event.Properties.Count -gt 1) { $eventData.ExternalIP = $Event.Properties[1].Value }
|
if ($Event.Properties.Count -gt 1) { $eventData.ExternalIP = [string]$Event.Properties[1].Value }
|
||||||
if ($Event.Properties.Count -gt 2) { $eventData.InternalIP = $Event.Properties[2].Value }
|
if ($Event.Properties.Count -gt 2) {
|
||||||
if ($Event.Properties.Count -gt 3) { $eventData.Protocol = $Event.Properties[3].Value }
|
$p2 = [string]$Event.Properties[2].Value
|
||||||
if ($Event.Properties.Count -gt 4) { $eventData.ErrorCode = $Event.Properties[4].Value }
|
$p3 = if ($Event.Properties.Count -gt 3) { [string]$Event.Properties[3].Value } else { '' }
|
||||||
|
if ([string]::IsNullOrWhiteSpace($p2) -and (Test-RdpMonitorStringLooksLikeIPv4 $p3)) {
|
||||||
|
$eventData.InternalIP = $p3.Trim()
|
||||||
|
if ($Event.Properties.Count -gt 4) { $eventData.ErrorCode = [string]$Event.Properties[4].Value }
|
||||||
|
if ($Event.Properties.Count -gt 5) { $eventData.Protocol = [string]$Event.Properties[5].Value }
|
||||||
|
} else {
|
||||||
|
$eventData.InternalIP = $p2.Trim()
|
||||||
|
if (-not [string]::IsNullOrWhiteSpace($p3)) { $eventData.Protocol = $p3.Trim() }
|
||||||
|
if ($Event.Properties.Count -gt 4) { $eventData.ErrorCode = [string]$Event.Properties[4].Value }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} elseif ($Event.Id -eq 302 -and $eventData.ErrorCode -eq 'N/A') {
|
||||||
|
$eventData.ErrorCode = "0"
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($Event.Id -eq 303 -and ($eventData.ErrorCode -eq 'N/A' -or [string]::IsNullOrWhiteSpace($eventData.ErrorCode)) -and $Event.Properties.Count -gt 4) {
|
||||||
|
$eventData.ErrorCode = [string]$Event.Properties[4].Value
|
||||||
|
}
|
||||||
|
|
||||||
|
if (($eventData.InternalIP -eq 'N/A' -or [string]::IsNullOrWhiteSpace($eventData.InternalIP)) -and
|
||||||
|
(Test-RdpMonitorStringLooksLikeIPv4 $eventData.Protocol)) {
|
||||||
|
$eventData.InternalIP = $eventData.Protocol.Trim()
|
||||||
|
$eventData.Protocol = 'N/A'
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
Write-Log "Ошибка при извлечении RD Gateway события: $($_.Exception.Message)"
|
Write-Log "Ошибка при извлечении RD Gateway события: $($_.Exception.Message)"
|
||||||
}
|
}
|
||||||
@@ -1200,8 +1546,14 @@ function Format-RDGatewayEvent {
|
|||||||
$hTime = (ConvertTo-TelegramHtml ($TimeCreated.ToString('dd.MM.yyyy HH:mm:ss')))
|
$hTime = (ConvertTo-TelegramHtml ($TimeCreated.ToString('dd.MM.yyyy HH:mm:ss')))
|
||||||
|
|
||||||
$message = "<b>"
|
$message = "<b>"
|
||||||
if ($EventID -eq 302) { $message += "🖥️ УСПЕШНОЕ ПОДКЛЮЧЕНИЕ ЧЕРЕЗ RD GATEWAY" }
|
if ($EventID -eq 302) { $message += "🖥️ ПОДКЛЮЧЕНИЕ ЧЕРЕЗ RD GATEWAY" }
|
||||||
elseif ($EventID -eq 303) { $message += "❌ НЕУДАЧНОЕ ПОДКЛЮЧЕНИЕ ЧЕРЕЗ RD GATEWAY" }
|
elseif ($EventID -eq 303) {
|
||||||
|
if ($ErrorCode -ne "0" -and $ErrorCode -ne "N/A" -and -not [string]::IsNullOrWhiteSpace($ErrorCode)) {
|
||||||
|
$message += "⚠️ СЕАНС ЧЕРЕЗ RD GATEWAY ЗАВЕРШЁН С ОШИБКОЙ"
|
||||||
|
} else {
|
||||||
|
$message += "ℹ️ СЕАНС ЧЕРЕЗ RD GATEWAY ЗАВЕРШЁН"
|
||||||
|
}
|
||||||
|
}
|
||||||
else { $message += "⚠️ СОБЫТИЕ RD GATEWAY" }
|
else { $message += "⚠️ СОБЫТИЕ RD GATEWAY" }
|
||||||
$message += "</b>`r`n"
|
$message += "</b>`r`n"
|
||||||
|
|
||||||
@@ -1446,6 +1798,7 @@ function Start-LoginMonitor {
|
|||||||
$nextReportCheck = Check-AndSendDailyReport
|
$nextReportCheck = Check-AndSendDailyReport
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
|
if ($_.Exception -is [System.Management.Automation.PipelineStoppedException]) { throw }
|
||||||
Write-Log "Ошибка цикла мониторинга: $($_.Exception.Message)"
|
Write-Log "Ошибка цикла мониторинга: $($_.Exception.Message)"
|
||||||
}
|
}
|
||||||
Start-Sleep -Seconds $MonitorInterval
|
Start-Sleep -Seconds $MonitorInterval
|
||||||
@@ -1457,11 +1810,17 @@ try {
|
|||||||
Test-TelegramConnection | Out-Null
|
Test-TelegramConnection | Out-Null
|
||||||
Start-LoginMonitor -MonitorInterval 5 -MonitorInteractiveOnly
|
Start-LoginMonitor -MonitorInterval 5 -MonitorInteractiveOnly
|
||||||
} catch {
|
} catch {
|
||||||
|
if ($_.Exception -is [System.Management.Automation.PipelineStoppedException]) {
|
||||||
|
Write-Log "Выполнение прервано (Ctrl+C / Stop-Pipeline)."
|
||||||
|
$script:StopNotificationSent = $true
|
||||||
|
} else {
|
||||||
Write-Log "Критическая ошибка: $($_.Exception.Message)"
|
Write-Log "Критическая ошибка: $($_.Exception.Message)"
|
||||||
Send-StopNotification -Reason "Критическая ошибка: $($_.Exception.Message)"
|
Send-StopNotification -Reason "Критическая ошибка: $($_.Exception.Message)"
|
||||||
$script:StopNotificationSent = $true
|
$script:StopNotificationSent = $true
|
||||||
throw
|
throw
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
|
Release-RdpMonitorSingletonLock
|
||||||
if (-not $script:StopNotificationSent) {
|
if (-not $script:StopNotificationSent) {
|
||||||
Send-StopNotification -Reason "Скрипт завершил работу"
|
Send-StopNotification -Reason "Скрипт завершил работу"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,17 @@
|
|||||||
# RDP Login Monitor
|
# RDP Login Monitor
|
||||||
|
|
||||||
PowerShell-набор для мониторинга входов в Windows с отправкой уведомлений в Telegram.
|
PowerShell-набор для мониторинга входов в Windows с отправкой уведомлений в Telegram.
|
||||||
|
|
||||||
## Актуальная схема (рекомендуется)
|
## Актуальная схема (рекомендуется)
|
||||||
|
|
||||||
- **`Login_Monitor.ps1`** — единый монитор: журнал Security (`4624`/`4625`), при необходимости RD Gateway (`302`/`303`), рабочие станции и серверы, ротация логов, heartbeat, отчёты. Устанавливается в **`C:\ProgramData\RDP-login-monitor\`**, задачи **`RDP-Login-Monitor`** и **`RDP-Login-Monitor-Watchdog`** создаются параметром **`-InstallTasks`** (watchdog через **`schtasks`**, см. **DEPLOY.md**).
|
- Базовый путь установки: **`C:\ProgramData\RDP-login-monitor\`**.
|
||||||
- **`Deploy-LoginMonitor.ps1`** + **`version.txt`** — доставка с файловой шары по версии (домен, GPO «автозагрузка» компьютера). Подробности, структура шары и правила версий: **[DEPLOY.md](DEPLOY.md)**.
|
- Основной скрипт: **`Login_Monitor.ps1`** — журнал Security **`4624`/`4625`** (логика зависит от типа ОС: рабочая станция или сервер/КД), при наличии журнала — **Remote Connection Manager `1149`** (часто актуально для РС с RDP), при роли **RD Gateway** — **`302`/`303`**, **ежедневный отчёт** в Telegram (активные сессии через `quser`), **heartbeat**, **ротация логов**, уведомления в Telegram.
|
||||||
- **`Encrypt-DpapiForRdpMonitor.ps1`** — опционально, для DPAPI-строк токена/chat id на конкретном ПК.
|
- Установка задач: запуск **`Login_Monitor.ps1 -InstallTasks`** создаёт:
|
||||||
|
- `RDP-Login-Monitor` (основной монитор),
|
||||||
Разделы ниже про **`D:\Soft\`**, **`Watchdog_RDP_Monitor.ps1`** и **`Install-ScheduledTasks.ps1`** относятся к **старой схеме** и оставлены для совместимости; новые установки ориентируйте на **`ProgramData`** и **DEPLOY.md**.
|
- `RDP-Login-Monitor-Watchdog` (контроль процесса каждые 5 минут).
|
||||||
|
- Доменная доставка и обновления: **`Deploy-LoginMonitor.ps1`** + **`version.txt`** с шары `NETLOGON`. После успешного деплоя в приветственном сообщении Telegram может появиться отметка об обновлении (файл **`deploy_last_update.txt`** рядом с логами).
|
||||||
|
- Для полной инструкции по деплою/GPO используйте **[DEPLOY.md](DEPLOY.md)**.
|
||||||
|
- **`Encrypt-DpapiForRdpMonitor.ps1`** — опционально для подготовки DPAPI-строк токена/chat id.
|
||||||
|
|
||||||
## Что изменилось (важное)
|
## Что изменилось (важное)
|
||||||
|
|
||||||
@@ -19,86 +22,109 @@ PowerShell-набор для мониторинга входов в Windows с
|
|||||||
|
|
||||||
## 1) Подготовка
|
## 1) Подготовка
|
||||||
|
|
||||||
1. Скопируйте в `D:\Soft\` как минимум `Login_Monitor.ps1` и `Watchdog_RDP_Monitor.ps1` (для установки из скрипта — ещё `Install-ScheduledTasks.ps1`), либо измените пути в параметрах установщика / в watchdog.
|
1. Подготовьте папку установки:
|
||||||
2. Откройте `Login_Monitor.ps1` и задайте:
|
- `C:\ProgramData\RDP-login-monitor\`
|
||||||
- `$TelegramBotToken`
|
2. Скопируйте в неё как минимум:
|
||||||
- `$TelegramChatID`
|
- `Login_Monitor.ps1`
|
||||||
3. Убедитесь, что существует `D:\Soft\Logs\` (скрипт сам создаст при запуске).
|
- (для доменного развёртывания отдельно на шаре) `Deploy-LoginMonitor.ps1` и `version.txt`.
|
||||||
4. Запускайте от имени администратора (нужен доступ к журналу `Security`).
|
3. Откройте `Login_Monitor.ps1` и задайте токен/чат:
|
||||||
|
- `$TelegramBotToken` или `...ProtectedB64`
|
||||||
|
- `$TelegramChatID` или `...ProtectedB64`
|
||||||
|
4. Запускайте с правами администратора (чтение `Security` журнала и регистрация задач).
|
||||||
|
5. Логи и служебные файлы будут в:
|
||||||
|
- `C:\ProgramData\RDP-login-monitor\Logs\`
|
||||||
|
6. (Опционально) Подавление части алертов по списку — см. раздел **«7) ignore.lst»** ниже.
|
||||||
|
|
||||||
## 2) Ручной запуск
|
## 2) Ручной запуск
|
||||||
|
|
||||||
|
Используйте этот вариант для быстрой проверки старта/логики без установки задач планировщика.
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "D:\Soft\Login_Monitor.ps1"
|
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "C:\ProgramData\RDP-login-monitor\Login_Monitor.ps1"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Примечание: при ручном запуске монитор работает в текущей сессии до остановки (например, `Ctrl+C`).
|
||||||
|
|
||||||
## 3) Запуск через Планировщик заданий (Task Scheduler)
|
## 3) Запуск через Планировщик заданий (Task Scheduler)
|
||||||
|
|
||||||
Задания можно завести **вручную в GUI** (ниже) или **одной командой из PowerShell** — см. `Install-ScheduledTasks.ps1` (в начале файла стоит `#Requires -RunAsAdministrator`). Скрипт регистрирует **`RDP Login Monitor`** (старт ОС, запуск `Login_Monitor.ps1`) и **`RDP Login Monitor Watchdog`** (старт ОС + повтор каждые 5 минут, запуск `Watchdog_RDP_Monitor.ps1` из того же каталога). Пути по умолчанию: `D:\Soft\`. Параметры: `-InstallRoot`, `-MainTaskName`, `-WatchdogTaskName`, `-WatchdogRepeatMinutes`, задержки случайного старта и т.д.
|
Текущая схема: вручную задачи в GUI создавать не нужно.
|
||||||
|
|
||||||
Пример после копирования файлов в `D:\Soft\`:
|
Достаточно запустить:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "D:\Soft\Install-ScheduledTasks.ps1"
|
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "C:\ProgramData\RDP-login-monitor\Login_Monitor.ps1" -InstallTasks
|
||||||
```
|
```
|
||||||
|
|
||||||
Это не заменяет настройку токена Telegram в `Login_Monitor.ps1`, а только регистрирует задания.
|
Скрипт сам зарегистрирует `RDP-Login-Monitor` и `RDP-Login-Monitor-Watchdog`, а также запросит немедленный первый запуск задач.
|
||||||
|
|
||||||
### Задание №1: основной монитор (вручную)
|
Для доменной установки/обновления с шары вручную ничего в планировщике на клиенте настраивать не требуется: используйте `Deploy-LoginMonitor.ps1` (подробно в `DEPLOY.md`).
|
||||||
|
|
||||||
Создайте задачу `RDP Login Monitor`:
|
|
||||||
|
|
||||||
- **General**
|
|
||||||
- `Run whether user is logged on or not`
|
|
||||||
- `Run with highest privileges`
|
|
||||||
- `Configure for`: ваша версия Windows Server/Windows
|
|
||||||
- **Triggers**
|
|
||||||
- `At startup` (при запуске системы)
|
|
||||||
- **Actions**
|
|
||||||
- Program/script: `powershell.exe`
|
|
||||||
- Add arguments:
|
|
||||||
```text
|
|
||||||
-NoProfile -ExecutionPolicy Bypass -File "D:\Soft\Login_Monitor.ps1"
|
|
||||||
```
|
|
||||||
- **Settings**
|
|
||||||
- `If the task is already running`: `Do not start a new instance`
|
|
||||||
|
|
||||||
### Задание №2: watchdog (вручную)
|
|
||||||
|
|
||||||
Создайте задачу `RDP Login Monitor Watchdog`:
|
|
||||||
|
|
||||||
- **General**
|
|
||||||
- `Run whether user is logged on or not`
|
|
||||||
- `Run with highest privileges`
|
|
||||||
- **Triggers**
|
|
||||||
- `At startup`
|
|
||||||
- Дополнительно: `Repeat task every 5 minutes` (или отдельный триггер по расписанию каждые 5 минут)
|
|
||||||
- **Actions**
|
|
||||||
- Program/script: `powershell.exe`
|
|
||||||
- Add arguments:
|
|
||||||
```text
|
|
||||||
-NoProfile -ExecutionPolicy Bypass -File "D:\Soft\Watchdog_RDP_Monitor.ps1"
|
|
||||||
```
|
|
||||||
- **Settings**
|
|
||||||
- `If the task is already running`: `Do not start a new instance`
|
|
||||||
|
|
||||||
> Если watchdog лежит не в `D:\Soft\`, поправьте путь в аргументах или параметр `-MainScriptPath`.
|
|
||||||
|
|
||||||
## 4) Что проверять после запуска
|
## 4) Что проверять после запуска
|
||||||
|
|
||||||
- Логи:
|
- Логи:
|
||||||
- `D:\Soft\Logs\login_monitor.log`
|
- `C:\ProgramData\RDP-login-monitor\Logs\login_monitor.log`
|
||||||
- `D:\Soft\Logs\watchdog.log`
|
- `C:\ProgramData\RDP-login-monitor\Logs\watchdog.log`
|
||||||
- Heartbeat:
|
- Heartbeat:
|
||||||
- `D:\Soft\Logs\last_heartbeat.txt` обновляется примерно раз в час (по `$HeartbeatInterval`).
|
- `C:\ProgramData\RDP-login-monitor\Logs\last_heartbeat.txt` обновляется по интервалу **`$HeartbeatInterval`** (по умолчанию раз в час).
|
||||||
|
- Ежедневный отчёт: после первого прохождения дневного слота (по умолчанию **09:00**, задаётся **`$DailyReportHour`** / **`$DailyReportMinute`** в `Login_Monitor.ps1`) в Telegram уходит сводка по **`quser`**; метка последнего отчёта — `Logs\last_daily_report.txt`.
|
||||||
- Telegram при старте: при установленном **RD Session Host** (или аналогичных компонентах RDS, не только шлюз) — строка про входы по RDP/RDS на этом сервере; при доступном журнале **RD Gateway** — отдельная строка про подключения к **внутренним целевым ПК** через шлюз (302/303). Узел только с ролью RD Gateway не дублирует формулировку «хост сессий».
|
- Telegram при старте: при установленном **RD Session Host** (или аналогичных компонентах RDS, не только шлюз) — строка про входы по RDP/RDS на этом сервере; при доступном журнале **RD Gateway** — отдельная строка про подключения к **внутренним целевым ПК** через шлюз (302/303). Узел только с ролью RD Gateway не дублирует формулировку «хост сессий».
|
||||||
|
|
||||||
## 5) Автоматический перезапуск при падении
|
## 5) Автоматический перезапуск при падении
|
||||||
|
|
||||||
`Watchdog_RDP_Monitor.ps1` делает:
|
Режим `-Watchdog` внутри `Login_Monitor.ps1` делает:
|
||||||
|
|
||||||
- проверяет, есть ли процесс `powershell.exe/pwsh.exe` с `Login_Monitor.ps1` в командной строке;
|
- проверяет, есть ли процесс `powershell.exe/pwsh.exe` с `Login_Monitor.ps1` в командной строке;
|
||||||
- если процесса нет — запускает монитор;
|
- если процесса нет — запускает монитор;
|
||||||
- если heartbeat старше `$HeartbeatStaleMinutes` (по умолчанию 90 минут) — перезапускает монитор.
|
- если монитор уже есть — не дублирует экземпляр.
|
||||||
|
|
||||||
|
## 6) Дополнительные параметры и прочие файлы
|
||||||
|
|
||||||
|
- **`-SkipScheduledTaskMaintenance`**: при обычном запуске монитора не выполнять проверку/пересоздание задач планировщика (если регистрацию задач ведёте только через **`-InstallTasks`** или вручную).
|
||||||
|
- **`Install-DeployScheduledTask.ps1`** — helper для периодического запуска **`Deploy-LoginMonitor.ps1`** с шары (см. **[DEPLOY.md](DEPLOY.md)**).
|
||||||
|
- **`Watchdog_RDP_Monitor.ps1`** и **`Install-ScheduledTasks.ps1`** — **альтернативная** схема с отдельным watchdog-файлом и путями по умолчанию **`D:\Soft`**. Для новых установок рекомендуется встроенный режим **`-Watchdog`** в **`Login_Monitor.ps1`** и задачи **`RDP-Login-Monitor`** / **`RDP-Login-Monitor-Watchdog`**.
|
||||||
|
- **`ignore.lst.example`** в репозитории — образец файла **`ignore.lst`** для подавления отдельных уведомлений Security (см. раздел 7).
|
||||||
|
|
||||||
|
## 7) Подавление уведомлений Security: `ignore.lst`
|
||||||
|
|
||||||
|
В каталоге установки можно положить файл **`C:\ProgramData\RDP-login-monitor\ignore.lst`** (рядом с **`Login_Monitor.ps1`**). Правила из списка проверяются **только** для Telegram-уведомлений по событиям **`4624`/`4625`** журнала Security (успех/неудача входа). Жёстко заданные в скрипте исключения (`ExcludedUsers`, локальный IP, сервисные учётные записи и т.д.) по-прежнему действуют для всех типов событий; **`ignore.lst`** добавляет к ним **дополнительные** совпадения именно для **4624/4625**.
|
||||||
|
|
||||||
|
События **RD Gateway (`302`/`303`)**, **RCM `1149`**, ежедневный отчёт и heartbeat **этим файлом не настраиваются** (для `1149` список не используется, даже если формально вызывается общая функция фильтрации).
|
||||||
|
|
||||||
|
### Как читается файл
|
||||||
|
|
||||||
|
- Чтение выполняется по мере обработки событий; содержимое **кэшируется в памяти**. Если **`LastWriteTimeUtc`** файла изменился (редактирование и сохранение), список **перечитывается автоматически** — перезапуск монитора не обязателен.
|
||||||
|
- Кодировка: **UTF-8** (`Get-Content -Encoding UTF8`). Строка может начинаться с BOM — он отбрасывается при разборе.
|
||||||
|
- Пустые строки пропускаются. Строки, начинающиеся с **`#`** или **`;`**, считаются комментариями.
|
||||||
|
- Строка с **`:`**: берётся **первая** двоеточие — всё слева (после обрезки пробелов) определяет тип правила, всё справа — значение. Если справа пусто, строка игнорируется.
|
||||||
|
- Строка **без** **`:`**: целиком трактуется как правило типа «любое совпадение» (см. ниже).
|
||||||
|
|
||||||
|
### Типы правил (левая часть до первого `:`)
|
||||||
|
|
||||||
|
| Левая часть (фрагменты совпадают как regex, без учёта регистра) | Поле события |
|
||||||
|
| --- | --- |
|
||||||
|
| `рабоч`, `workstation`, `wks` | имя рабочей станции (**WorkstationName** и аналоги в XML события) |
|
||||||
|
| `польз`, `username`, `subject`, `account`, `target user`, целое слово `user` | имя пользователя (**TargetUserName** и др.) |
|
||||||
|
| `ip`, `ip адрес`, `ipaddress`, `адрес ip` | IP источника (**IpAddress** и др.), только если в событии есть непустой IP |
|
||||||
|
|
||||||
|
Если левая часть **не** подошла ни к одному типу, но двоеточие есть — используется режим как в разборе строк Telegram: тип **«любое»**, значение — **только правая часть** (метка слева отбрасывается).
|
||||||
|
|
||||||
|
### Совпадение для типа «любое» (строка без `:` или «неизвестная» метка слева от `:`)
|
||||||
|
|
||||||
|
Проверка по очереди:
|
||||||
|
|
||||||
|
1. Если значение похоже на **IPv4** — сравнивается с IP источника в событии (точное совпадение, без учёта регистра для текста не применимо).
|
||||||
|
2. Если значение содержит **`\`** — сравнивается с **учётной записью**: полное совпадение с `DOMAIN\user` **или** совпадение с **SAM** после последнего `\` (как `DOMAIN\IVANOV` при правиле `IVANOV`).
|
||||||
|
3. Иначе сначала полное совпадение с **именем рабочей станции**, затем с **учётной записью** по тем же правилам, что в п.2.
|
||||||
|
|
||||||
|
Для явных типов **User** / **Workstation** / **Ip** используется только соответствующее поле (для пользователя — те же правила полного имени и SAM, что в п.2).
|
||||||
|
|
||||||
|
### Примеры и поставка
|
||||||
|
|
||||||
|
- Расширенные примеры строк — в **`ignore.lst.example`** в корне репозитория (скопируйте на сервер как **`ignore.lst`** и отредактируйте).
|
||||||
|
- **`Deploy-LoginMonitor.ps1`** этот файл **не копирует**: правила обычно разные на каждой машине; при необходимости создайте `ignore.lst` вручную или через вашу систему конфигурации.
|
||||||
|
|
||||||
|
## Ключевые слова (для поиска репозитория)
|
||||||
|
|
||||||
|
`rdp`, `rd-gateway`, `rdp-gateway`, `rds`, `remote-desktop`, `windows-security-log`, `eventlog`, `event-id-4624`, `event-id-4625`, `event-id-302`, `event-id-303`, `powershell`, `telegram-bot`, `watchdog`, `gpo`, `netlogon`, `domain-deployment`, `windows-server`, `monitoring`
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+128
@@ -0,0 +1,128 @@
|
|||||||
|
# RDP Login Monitor
|
||||||
|
|
||||||
|
PowerShell toolkit for monitoring Windows logons with Telegram notifications.
|
||||||
|
|
||||||
|
## Recommended layout
|
||||||
|
|
||||||
|
- Installation root: **`C:\ProgramData\RDP-login-monitor\`**.
|
||||||
|
- Main script: **`Login_Monitor.ps1`** — Security log **`4624`/`4625`** (behavior depends on OS type: workstation vs server/domain controller), optional **Remote Connection Manager `1149`** when the log is available (often useful for RDP-enabled workstations), **RD Gateway** events **`302`/`303`** when the gateway role/log is present, **daily report** to Telegram (active sessions via `quser`), **heartbeat**, **log rotation**, Telegram alerts.
|
||||||
|
- Scheduled tasks: run **`Login_Monitor.ps1 -InstallTasks`** to register:
|
||||||
|
- `RDP-Login-Monitor` (main monitor),
|
||||||
|
- `RDP-Login-Monitor-Watchdog` (process health check every 5 minutes).
|
||||||
|
- Domain delivery and upgrades: **`Deploy-LoginMonitor.ps1`** + **`version.txt`** on a share such as `NETLOGON`. After a successful deploy, the startup Telegram message may include an update note (file **`deploy_last_update.txt`** next to logs).
|
||||||
|
- Full deploy/GPO guidance: **[DEPLOY.md](DEPLOY.md)**.
|
||||||
|
- **`Encrypt-DpapiForRdpMonitor.ps1`** — optional helper to prepare DPAPI-protected Base64 for the bot token / chat id.
|
||||||
|
|
||||||
|
## Notable behavior
|
||||||
|
|
||||||
|
- **`.ps1` encoding**: `.editorconfig` and `.gitattributes` encourage **`*.ps1`** as **UTF-8 with BOM** and **CRLF**, reducing mojibake and PowerShell parse issues.
|
||||||
|
- **Log encoding**: `login_monitor.log` / `watchdog.log` are written as **UTF-8 with BOM** (BOM is applied to existing files if missing) so viewers like **FAR Manager** do not mis-detect encoding.
|
||||||
|
- **`auditpol` on Russian Windows**: auditing checks use the **`Вход/выход`** category and **`Вход в систему` / `Выход из системы`** subcategories (expect **`Успех и сбой`**), avoiding errors such as `0x00000057` when English names like `Logon` are absent on a localized OS.
|
||||||
|
- **Stability**: `auditpol` is invoked via `cmd.exe` with merged stdout/stderr so `$ErrorActionPreference = 'Stop'` does not abort on stderr-only output.
|
||||||
|
|
||||||
|
## 1) Preparation
|
||||||
|
|
||||||
|
1. Create the install folder:
|
||||||
|
- `C:\ProgramData\RDP-login-monitor\`
|
||||||
|
2. Copy at least:
|
||||||
|
- `Login_Monitor.ps1`
|
||||||
|
- (for domain rollout on a share) `Deploy-LoginMonitor.ps1` and `version.txt`.
|
||||||
|
3. Edit `Login_Monitor.ps1` and set the bot token / chat:
|
||||||
|
- `$TelegramBotToken` or `$TelegramBotTokenProtectedB64`
|
||||||
|
- `$TelegramChatID` or `$TelegramChatIDProtectedB64`
|
||||||
|
4. Run elevated (Security log access and task registration).
|
||||||
|
5. Logs and auxiliary files:
|
||||||
|
- `C:\ProgramData\RDP-login-monitor\Logs\`
|
||||||
|
6. (Optional) Suppress some Security alerts via **`ignore.lst`** — see **section 7** below.
|
||||||
|
|
||||||
|
## 2) Manual run
|
||||||
|
|
||||||
|
Use this to validate startup/logic without registering scheduled tasks.
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "C:\ProgramData\RDP-login-monitor\Login_Monitor.ps1"
|
||||||
|
```
|
||||||
|
|
||||||
|
The monitor keeps running in the session until you stop it (for example `Ctrl+C`).
|
||||||
|
|
||||||
|
## 3) Task Scheduler
|
||||||
|
|
||||||
|
You do not need to create tasks manually in the GUI.
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "C:\ProgramData\RDP-login-monitor\Login_Monitor.ps1" -InstallTasks
|
||||||
|
```
|
||||||
|
|
||||||
|
The script registers `RDP-Login-Monitor` and `RDP-Login-Monitor-Watchdog`, then triggers an immediate first run.
|
||||||
|
|
||||||
|
For domain deployment from a share you do not configure the scheduler on clients by hand — use `Deploy-LoginMonitor.ps1` (see `DEPLOY.md`).
|
||||||
|
|
||||||
|
## 4) Post-install checks
|
||||||
|
|
||||||
|
- Logs:
|
||||||
|
- `C:\ProgramData\RDP-login-monitor\Logs\login_monitor.log`
|
||||||
|
- `C:\ProgramData\RDP-login-monitor\Logs\watchdog.log`
|
||||||
|
- Heartbeat:
|
||||||
|
- `C:\ProgramData\RDP-login-monitor\Logs\last_heartbeat.txt` updates on **`$HeartbeatInterval`** (hourly by default).
|
||||||
|
- Daily report: after the first daily window (default **09:00**, controlled by **`$DailyReportHour`** / **`$DailyReportMinute`** in `Login_Monitor.ps1`), Telegram receives a `quser` summary; last run marker: `Logs\last_daily_report.txt`.
|
||||||
|
- Startup Telegram message: with **RD Session Host** (or broader RDS session components, not gateway-only) you get the RDS/RDP session-host line; when the **RD Gateway** log is available you get a separate line about connections to **internal targets** through the gateway (302/303). A gateway-only node does not duplicate the “session host” wording.
|
||||||
|
|
||||||
|
## 5) Automatic restart on failure
|
||||||
|
|
||||||
|
`-Watchdog` inside `Login_Monitor.ps1`:
|
||||||
|
|
||||||
|
- looks for `powershell.exe` / `pwsh.exe` whose command line references `Login_Monitor.ps1`;
|
||||||
|
- if the main monitor is missing, starts it;
|
||||||
|
- if the monitor is already running, does not spawn a second instance.
|
||||||
|
|
||||||
|
## 6) Extra parameters and other repo files
|
||||||
|
|
||||||
|
- **`-SkipScheduledTaskMaintenance`**: during normal monitor startup, skip verification/recreation of scheduled tasks (if you manage tasks only via **`-InstallTasks`** or manually).
|
||||||
|
- **`Install-DeployScheduledTask.ps1`** — helper to run **`Deploy-LoginMonitor.ps1`** from a share on a schedule (see **[DEPLOY.md](DEPLOY.md)**).
|
||||||
|
- **`Watchdog_RDP_Monitor.ps1`** and **`Install-ScheduledTasks.ps1`** — **alternate** layout with a separate watchdog script and default paths under **`D:\Soft`**. For new installs, prefer the built-in **`-Watchdog`** in **`Login_Monitor.ps1`** and tasks **`RDP-Login-Monitor`** / **`RDP-Login-Monitor-Watchdog`**.
|
||||||
|
- **`ignore.lst.example`** in the repo is a template for **`ignore.lst`** to suppress selected Security notifications (see section 7).
|
||||||
|
|
||||||
|
## 7) Suppressing Security alerts: `ignore.lst`
|
||||||
|
|
||||||
|
Place a file **`C:\ProgramData\RDP-login-monitor\ignore.lst`** next to **`Login_Monitor.ps1`**. Rules in this list are evaluated **only** for Telegram notifications from Security events **`4624`/`4625`** (successful/failed logons). Built-in exclusions in the script (`ExcludedUsers`, loopback IPs, service-style accounts, etc.) still apply to all event paths; **`ignore.lst`** adds **extra** matches **for 4624/4625 only**.
|
||||||
|
|
||||||
|
**RD Gateway (`302`/`303`)**, **RCM `1149`**, the daily report, and heartbeat **are not controlled** by this file (for `1149` the list is not applied, even though a shared filter function runs).
|
||||||
|
|
||||||
|
### How the file is loaded
|
||||||
|
|
||||||
|
- The file is read during event processing; entries are **cached in memory**. If **`LastWriteTimeUtc`** changes (save after edit), the list is **reloaded automatically** — no monitor restart required.
|
||||||
|
- Encoding: **UTF-8** (`Get-Content -Encoding UTF8`). A UTF-8 BOM at the start of a line is stripped when parsing.
|
||||||
|
- Blank lines are skipped. Lines starting with **`#`** or **`;`** are comments.
|
||||||
|
- If the line contains **`:`**, the **first** colon splits the line: the left part (trimmed) selects the rule kind, the right part is the value. If the right part is empty, the line is ignored.
|
||||||
|
- If there is **no** colon, the whole trimmed line is one “match-any” value (see below).
|
||||||
|
|
||||||
|
### Rule kinds (left part before the first `:`)
|
||||||
|
|
||||||
|
| Left part (case-insensitive regex fragments) | Event field |
|
||||||
|
| --- | --- |
|
||||||
|
| `рабоч`, `workstation`, `wks` | workstation name (**WorkstationName** and similar XML fields) |
|
||||||
|
| `польз`, `username`, `subject`, `account`, `target user`, whole word `user` | user name (**TargetUserName**, etc.) |
|
||||||
|
| `ip`, `ip адрес`, `ipaddress`, `адрес ip` | source IP (**IpAddress**, etc.) when present |
|
||||||
|
|
||||||
|
If the left part matches none of the above but a colon exists, the parser behaves like the Telegram line splitter: kind is **Any**, value is **only the right part** (the label on the left is discarded).
|
||||||
|
|
||||||
|
### “Any” matching (no colon, or unknown label before `:`)
|
||||||
|
|
||||||
|
Evaluated in order:
|
||||||
|
|
||||||
|
1. If the value looks like **IPv4**, it is compared to the event source IP (exact match).
|
||||||
|
2. If the value contains **`\`**, it is matched against the **account**: full `DOMAIN\user` equality **or** SAM after the last `\` (e.g. rule `IVANOV` matches `DOMAIN\IVANOV`).
|
||||||
|
3. Otherwise: exact match against **workstation name** first, then the same account rules as in step 2.
|
||||||
|
|
||||||
|
Explicit **User** / **Workstation** / **Ip** kinds only compare their respective field (user rules use the same full-name and SAM behavior as step 2).
|
||||||
|
|
||||||
|
### Examples and deployment
|
||||||
|
|
||||||
|
- See **`ignore.lst.example`** in the repo; copy it to the server as **`ignore.lst`** and edit.
|
||||||
|
- **`Deploy-LoginMonitor.ps1`** does **not** copy this file — rules are usually host-specific; create **`ignore.lst`** manually or via your configuration tooling.
|
||||||
|
|
||||||
|
## Keywords (for discovery)
|
||||||
|
|
||||||
|
`rdp`, `rd-gateway`, `rdp-gateway`, `rds`, `remote-desktop`, `windows-security-log`, `eventlog`, `event-id-4624`, `event-id-4625`, `event-id-302`, `event-id-303`, `powershell`, `telegram-bot`, `watchdog`, `gpo`, `netlogon`, `domain-deployment`, `windows-server`, `monitoring`
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# Файл ignore.lst (UTF-8): положите рядом с Login_Monitor.ps1 в каталоге установки:
|
||||||
|
# C:\ProgramData\RDP-login-monitor\ignore.lst
|
||||||
|
#
|
||||||
|
# Каждая непустая строка — одно правило. Строки с # или ; в начале — комментарии.
|
||||||
|
# Подавляет только уведомления Security 4624/4625 (не RD Gateway, не RCM 1149).
|
||||||
|
#
|
||||||
|
# Форматы:
|
||||||
|
# user:domain\user
|
||||||
|
# user:user
|
||||||
|
# workstation:IVANOV
|
||||||
|
# ip:111.222.333.444
|
||||||
|
# Можно вставить «как в Telegram» (берётся значение после первого «:»):
|
||||||
|
# 👤 Пользователь: user
|
||||||
|
# 🖥️ Рабочая станция (клиент из события): IVANOV
|
||||||
|
# 🌐 IP адрес: 111.222.333.444
|
||||||
|
#
|
||||||
|
# Строка без префикса:
|
||||||
|
# IVANOV — совпадение с именем рабочей станции ИЛИ с пользователем (sam) ИЛИ с IP (если строка — IPv4)
|
||||||
|
# 111.222.333.444 — только IP (в реальной конфигурации укажите действительный IPv4 клиента)
|
||||||
|
# domain\user — пользователь целиком
|
||||||
|
|
||||||
|
# user:domain\user
|
||||||
|
# workstation:IVANOV
|
||||||
|
# ip:111.222.333.444
|
||||||
+1
-1
@@ -1 +1 @@
|
|||||||
1.3.4
|
1.4.3
|
||||||
|
|||||||
Reference in New Issue
Block a user