Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ab0504c3f9 | |||
| d8ab28098e | |||
| 4386a58942 | |||
| d951f54874 | |||
| 4bef73001f | |||
| 4f6385d483 | |||
| 0ce6b8abde | |||
| 6072d70e59 | |||
| 7b9d7dcd40 | |||
| b39d9c0deb |
@@ -0,0 +1,86 @@
|
||||
#Requires -RunAsAdministrator
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Регистрирует в Планировщике заданий основной монитор и watchdog (как в README).
|
||||
.DESCRIPTION
|
||||
Запускайте из повышенной PowerShell. Пути по умолчанию — D:\Soft\.
|
||||
Watchdog использует Watchdog_RDP_Monitor.ps1 из репозитория (проверка процесса и heartbeat).
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string]$InstallRoot = "D:\Soft",
|
||||
[string]$MainTaskName = "RDP Login Monitor",
|
||||
[string]$WatchdogTaskName = "RDP Login Monitor Watchdog",
|
||||
[int]$WatchdogRepeatMinutes = 5,
|
||||
[int]$MainStartupRandomDelayMinutes = 1,
|
||||
[int]$WatchdogStartupRandomDelayMinutes = 2
|
||||
)
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$LoginScriptPath = Join-Path $InstallRoot "Login_Monitor.ps1"
|
||||
$WatchdogScriptPath = Join-Path $InstallRoot "Watchdog_RDP_Monitor.ps1"
|
||||
$LogsDir = Join-Path $InstallRoot "Logs"
|
||||
|
||||
if (-not (Test-Path -LiteralPath $LoginScriptPath)) {
|
||||
throw "Не найден основной скрипт: $LoginScriptPath"
|
||||
}
|
||||
if (-not (Test-Path -LiteralPath $WatchdogScriptPath)) {
|
||||
throw "Не найден watchdog: $WatchdogScriptPath"
|
||||
}
|
||||
if (-not (Test-Path -LiteralPath $LogsDir)) {
|
||||
New-Item -ItemType Directory -Path $LogsDir -Force | Out-Null
|
||||
}
|
||||
|
||||
$principal = New-ScheduledTaskPrincipal `
|
||||
-UserId "NT AUTHORITY\SYSTEM" `
|
||||
-LogonType ServiceAccount `
|
||||
-RunLevel Highest
|
||||
|
||||
# --- Задание 1: основной монитор ---
|
||||
$mainArgs = "-NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -File `"$LoginScriptPath`""
|
||||
$mainAction = New-ScheduledTaskAction -Execute "PowerShell.exe" -Argument $mainArgs
|
||||
$mainTrigger = New-ScheduledTaskTrigger -AtStartup -RandomDelay (New-TimeSpan -Minutes $MainStartupRandomDelayMinutes)
|
||||
$mainSettings = New-ScheduledTaskSettingsSet `
|
||||
-AllowStartIfOnBatteries `
|
||||
-DontStopIfGoingOnBatteries `
|
||||
-StartWhenAvailable `
|
||||
-MultipleInstances IgnoreNew
|
||||
|
||||
Register-ScheduledTask `
|
||||
-TaskName $MainTaskName `
|
||||
-Action $mainAction `
|
||||
-Trigger $mainTrigger `
|
||||
-Principal $principal `
|
||||
-Settings $mainSettings `
|
||||
-Force | Out-Null
|
||||
|
||||
Write-Host "Создано задание: $MainTaskName" -ForegroundColor Cyan
|
||||
|
||||
# --- Задание 2: watchdog (старт + периодический запуск, как в README) ---
|
||||
$wdArgs = "-NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -File `"$WatchdogScriptPath`""
|
||||
$wdAction = New-ScheduledTaskAction -Execute "PowerShell.exe" -Argument $wdArgs
|
||||
$wdTriggerStartup = New-ScheduledTaskTrigger -AtStartup -RandomDelay (New-TimeSpan -Minutes $WatchdogStartupRandomDelayMinutes)
|
||||
$repeatDuration = New-TimeSpan -Days 3650
|
||||
$anchor = (Get-Date).AddMinutes([Math]::Max(3, $WatchdogRepeatMinutes))
|
||||
$wdTriggerRepeat = New-ScheduledTaskTrigger -Once -At $anchor `
|
||||
-RepetitionInterval (New-TimeSpan -Minutes $WatchdogRepeatMinutes) `
|
||||
-RepetitionDuration $repeatDuration
|
||||
|
||||
$wdSettings = New-ScheduledTaskSettingsSet `
|
||||
-AllowStartIfOnBatteries `
|
||||
-DontStopIfGoingOnBatteries `
|
||||
-StartWhenAvailable `
|
||||
-MultipleInstances IgnoreNew
|
||||
|
||||
Register-ScheduledTask `
|
||||
-TaskName $WatchdogTaskName `
|
||||
-Action $wdAction `
|
||||
-Trigger @($wdTriggerStartup, $wdTriggerRepeat) `
|
||||
-Principal $principal `
|
||||
-Settings $wdSettings `
|
||||
-Force | Out-Null
|
||||
|
||||
Write-Host "Создано задание: $WatchdogTaskName (триггеры: при старте ОС и каждые $WatchdogRepeatMinutes мин.)" -ForegroundColor Cyan
|
||||
Write-Host "Готово. При необходимости сразу запустите: Start-ScheduledTask -TaskName '$MainTaskName'" -ForegroundColor Green
|
||||
+236
-53
@@ -101,12 +101,39 @@ $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 }
|
||||
|
||||
# UTF-8 с BOM: иначе часть просмотрщиков (FAR и др.) открывает лог как ANSI/OEM и показывает "кракозябры".
|
||||
$script:Utf8BomEncoding = New-Object System.Text.UTF8Encoding $true
|
||||
|
||||
function Ensure-FileStartsWithUtf8Bom {
|
||||
param([Parameter(Mandatory = $true)][string]$Path)
|
||||
if (-not (Test-Path -LiteralPath $Path)) { return }
|
||||
$bytes = [System.IO.File]::ReadAllBytes($Path)
|
||||
if ($bytes.Length -ge 3 -and $bytes[0] -eq 0xEF -and $bytes[1] -eq 0xBB -and $bytes[2] -eq 0xBF) { return }
|
||||
$bom = [byte[]](0xEF, 0xBB, 0xBF)
|
||||
$combined = New-Object byte[] ($bom.Length + $bytes.Length)
|
||||
[Buffer]::BlockCopy($bom, 0, $combined, 0, $bom.Length)
|
||||
if ($bytes.Length -gt 0) {
|
||||
[Buffer]::BlockCopy($bytes, 0, $combined, $bom.Length, $bytes.Length)
|
||||
}
|
||||
[System.IO.File]::WriteAllBytes($Path, $combined)
|
||||
}
|
||||
|
||||
function Write-TextFileUtf8Bom {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$Path,
|
||||
[Parameter(Mandatory = $true)][string]$Text
|
||||
)
|
||||
[System.IO.File]::WriteAllText($Path, $Text, $script:Utf8BomEncoding)
|
||||
}
|
||||
|
||||
Ensure-FileStartsWithUtf8Bom -Path $LogFile
|
||||
|
||||
function Write-Log {
|
||||
param([string]$Message)
|
||||
$timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
|
||||
$logMessage = "$timestamp - $Message"
|
||||
Add-Content -Path $LogFile -Value $logMessage -Force -Encoding UTF8
|
||||
Write-Host $logMessage
|
||||
$logMessage = "$timestamp - $Message" + [Environment]::NewLine
|
||||
[System.IO.File]::AppendAllText($LogFile, $logMessage, $script:Utf8BomEncoding)
|
||||
Write-Host ($logMessage.TrimEnd("`r`n"))
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -192,63 +219,176 @@ function Enable-SecurityAudit {
|
||||
}
|
||||
}
|
||||
|
||||
function Test-LogonAuditEnabled {
|
||||
# На русской Windows подкатегория называется "Вход в систему", а не "Logon".
|
||||
# В выводе auditpol обычно встречаются слова "успех" и "отказ" (или Success/Failure).
|
||||
$candidates = @(
|
||||
'/get /subcategory:"Вход в систему"',
|
||||
'/get /subcategory:"Logon"'
|
||||
)
|
||||
function Test-RussianUiPreferred {
|
||||
try {
|
||||
if ((Get-Culture).TwoLetterISOLanguageName -eq 'ru') { return $true }
|
||||
} catch { }
|
||||
|
||||
foreach ($args in $candidates) {
|
||||
$r = Invoke-AuditPol -Arguments $args
|
||||
if ($r.ExitCode -ne 0) {
|
||||
Write-Log ("Не удалось прочитать auditpol (код {0}) для: {1}. Вывод:`n{2}" -f $r.ExitCode, $args.Trim(), $r.Text)
|
||||
continue
|
||||
}
|
||||
|
||||
$t = $r.Text
|
||||
if (($t -match '(?i)Success') -and ($t -match '(?i)Failure')) { return $true }
|
||||
if (($t -match '(?i)успех') -and ($t -match '(?i)отказ')) { return $true }
|
||||
if ($PSUICulture -like 'ru*') { return $true }
|
||||
|
||||
$r = Invoke-AuditPol -Arguments '/list /subcategory:*'
|
||||
if ($r.ExitCode -ne 0) { return $false }
|
||||
return ($r.Text -match 'Вход/выход')
|
||||
}
|
||||
|
||||
function Test-SuccessAndFailureText {
|
||||
param([string]$Line)
|
||||
if ([string]::IsNullOrWhiteSpace($Line)) { return $false }
|
||||
# RU: "Успех и сбой" (часто в выводе категории)
|
||||
if ($Line -match '(?i)успех\s+и\s+сбой') { return $true }
|
||||
# Иногда встречается без пробелов вокруг "и" из-за форматирования/переносов
|
||||
if ($Line -match '(?i)успех\s*и\s*сбой') { return $true }
|
||||
# EN fallback
|
||||
if (($Line -match '(?i)Success') -and ($Line -match '(?i)Failure')) { return $true }
|
||||
return $false
|
||||
}
|
||||
|
||||
if (Test-LogonAuditEnabled) {
|
||||
Write-Log "Аудит входа уже настроен (Success+Failure)"
|
||||
return
|
||||
function Get-CategorySettingLine {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$CategoryName,
|
||||
[Parameter(Mandatory = $true)][string]$SubcategoryLabel
|
||||
)
|
||||
$r = Invoke-AuditPol -Arguments ('/get /category:"{0}"' -f $CategoryName)
|
||||
if ($r.ExitCode -ne 0) {
|
||||
return [pscustomobject]@{ Ok = $false; ExitCode = $r.ExitCode; Text = $r.Text; Line = $null }
|
||||
}
|
||||
|
||||
Write-Log "Аудит входа не настроен полностью. Пытаюсь включить..."
|
||||
$lines = $r.Text -split "`r?`n"
|
||||
foreach ($ln in $lines) {
|
||||
$t = ($ln -replace '\s+', ' ').Trim()
|
||||
if ([string]::IsNullOrWhiteSpace($t)) { continue }
|
||||
|
||||
# Порядок попыток: от самого "каноничного" к более совместимому.
|
||||
$attempts = @(
|
||||
# RU Windows (как в вашем выводе auditpol /list /subcategory:*)
|
||||
'/set /subcategory:"Вход в систему" /success:enable /failure:enable',
|
||||
'/set /category:"Вход/выход" /success:enable /failure:enable',
|
||||
# EN Windows
|
||||
'/set /subcategory:"Logon" /success:enable /failure:enable',
|
||||
'/set /category:"Logon/Logoff" /success:enable /failure:enable',
|
||||
# GUID категории (как было у вас изначально)
|
||||
'/set /category:"{69979849-797A-11D9-BED3-505054503030}" /success:enable /failure:enable'
|
||||
# Ищем по подстроке без жёсткой привязки к количеству пробелов
|
||||
if ($t -notlike ('*{0}*' -f $SubcategoryLabel)) { continue }
|
||||
return [pscustomobject]@{ Ok = $true; ExitCode = 0; Text = $r.Text; Line = $t }
|
||||
}
|
||||
|
||||
return [pscustomobject]@{ Ok = $true; ExitCode = 0; Text = $r.Text; Line = $null }
|
||||
}
|
||||
|
||||
function Ensure-RuLogonLogoffSubcategories {
|
||||
# Как вы описали: смотрим категорию "Вход/выход" и добиваем две ключевые подкатегории.
|
||||
$category = "Вход/выход"
|
||||
$targets = @(
|
||||
"Вход в систему",
|
||||
"Выход из системы"
|
||||
)
|
||||
|
||||
foreach ($a in $attempts) {
|
||||
$r = Invoke-AuditPol -Arguments $a
|
||||
if ($r.ExitCode -eq 0) {
|
||||
Write-Log ("auditpol OK: {0}" -f $a.Trim())
|
||||
} else {
|
||||
Write-Log ("auditpol FAIL (код {0}): {1}`n{2}" -f $r.ExitCode, $a.Trim(), $r.Text)
|
||||
foreach ($sub in $targets) {
|
||||
$cur = Get-CategorySettingLine -CategoryName $category -SubcategoryLabel $sub
|
||||
if (-not $cur.Ok) {
|
||||
Write-Log ("Не удалось прочитать auditpol /get /category для '{0}' (код {1}). Вывод:`n{2}" -f $category, $cur.ExitCode, $cur.Text)
|
||||
return $false
|
||||
}
|
||||
|
||||
if (Test-LogonAuditEnabled) {
|
||||
Write-Log "Аудит входа успешно включен (Success+Failure)"
|
||||
if ($null -eq $cur.Line) {
|
||||
$frag = $cur.Text
|
||||
if ($frag.Length -gt 4000) { $frag = $frag.Substring(0, 4000) + "`n... (truncated)" }
|
||||
Write-Log ("В выводе категории '{0}' не найдена строка подкатегории '{1}'. Вывод (фрагмент):`n{2}" -f $category, $sub, $frag)
|
||||
return $false
|
||||
}
|
||||
|
||||
if (Test-SuccessAndFailureText -Line $cur.Line) {
|
||||
Write-Log ("Аудит уже 'Успех и сбой' для: {0} :: {1}" -f $sub, $cur.Line)
|
||||
continue
|
||||
}
|
||||
|
||||
Write-Log ("Требуется включить Success+Failure для подкатегории: {0}. Текущая строка: {1}" -f $sub, $cur.Line)
|
||||
$setArgs = ('/set /subcategory:"{0}" /success:enable /failure:enable' -f $sub)
|
||||
$set = Invoke-AuditPol -Arguments $setArgs
|
||||
if ($set.ExitCode -ne 0) {
|
||||
Write-Log ("auditpol SET FAIL (код {0}): {1}`n{2}" -f $set.ExitCode, $setArgs, $set.Text)
|
||||
return $false
|
||||
}
|
||||
|
||||
$after = Get-CategorySettingLine -CategoryName $category -SubcategoryLabel $sub
|
||||
if ($null -ne $after.Line -and (Test-SuccessAndFailureText -Line $after.Line)) {
|
||||
Write-Log ("OK: подкатегория '{0}' приведена к 'Успех и сбой'. Строка: {1}" -f $sub, $after.Line)
|
||||
} else {
|
||||
Write-Log ("ПОСЛЕ SET строка для '{0}' всё ещё не похожа на 'Успех и сбой': {1}" -f $sub, $after.Line)
|
||||
return $false
|
||||
}
|
||||
}
|
||||
|
||||
return $true
|
||||
}
|
||||
|
||||
function Ensure-EnLogonLogoffSubcategories {
|
||||
$category = "Logon/Logoff"
|
||||
$targets = @("Logon", "Logoff")
|
||||
foreach ($sub in $targets) {
|
||||
$cur = Get-CategorySettingLine -CategoryName $category -SubcategoryLabel $sub
|
||||
if (-not $cur.Ok) {
|
||||
Write-Log ("Не удалось прочитать auditpol /get /category для '{0}' (код {1}). Вывод:`n{2}" -f $category, $cur.ExitCode, $cur.Text)
|
||||
return $false
|
||||
}
|
||||
if ($null -eq $cur.Line) {
|
||||
Write-Log ("В выводе категории '{0}' не найдена строка подкатегории '{1}'." -f $category, $sub)
|
||||
return $false
|
||||
}
|
||||
if (Test-SuccessAndFailureText -Line $cur.Line) { continue }
|
||||
|
||||
$setArgs = ('/set /subcategory:"{0}" /success:enable /failure:enable' -f $sub)
|
||||
$set = Invoke-AuditPol -Arguments $setArgs
|
||||
if ($set.ExitCode -ne 0) {
|
||||
Write-Log ("auditpol SET FAIL (код {0}): {1}`n{2}" -f $set.ExitCode, $setArgs, $set.Text)
|
||||
return $false
|
||||
}
|
||||
}
|
||||
return $true
|
||||
}
|
||||
|
||||
$preferRu = Test-RussianUiPreferred
|
||||
|
||||
if ($preferRu) {
|
||||
if (Ensure-RuLogonLogoffSubcategories) {
|
||||
Write-Log "Проверка аудита (RU): OK для 'Вход в систему' и 'Выход из системы'"
|
||||
return
|
||||
}
|
||||
|
||||
Write-Log "ВНИМАНИЕ (RU): не удалось автоматически привести аудит подкатегорий 'Вход/выход' к 'Успех и сбой' через auditpol. Скрипт продолжит работу, но часть событий может отсутствовать. Проверьте доменную/локальную GPO (часто мешает централизованный Advanced Audit Policy)."
|
||||
return
|
||||
}
|
||||
|
||||
Write-Log "ВНИМАНИЕ: не удалось автоматически включить аудит входа через auditpol. Скрипт продолжит работу, но часть событий может отсутствовать. Проверьте политику аудита вручную (локальная/доменная GPO)."
|
||||
if (Ensure-EnLogonLogoffSubcategories) {
|
||||
Write-Log "Проверка аудита (EN): OK для Logon/Logoff"
|
||||
return
|
||||
}
|
||||
|
||||
# Fallback: старый GUID категории Logon/Logoff (Microsoft). Это не "UID пользователя", а GUID категории политики аудита.
|
||||
Write-Log "Пробую fallback через GUID категории Logon/Logoff..."
|
||||
$guidSet = Invoke-AuditPol -Arguments '/set /category:"{69979849-797A-11D9-BED3-505054503030}" /success:enable /failure:enable'
|
||||
if ($guidSet.ExitCode -ne 0) {
|
||||
Write-Log ("auditpol GUID SET FAIL (код {0}):`n{1}" -f $guidSet.ExitCode, $guidSet.Text)
|
||||
}
|
||||
|
||||
Write-Log "ВНИМАНИЕ: не удалось автоматически настроить аудит входа/выхода через auditpol. Скрипт продолжит работу, но часть событий может отсутствовать. Проверьте политику аудита вручную (локальная/доменная GPO)."
|
||||
}
|
||||
|
||||
function Test-RDSDeploymentPresent {
|
||||
# Узел только с RD Gateway (RDS-Gateway и т.п.) не считаем «полноценным RDS по сессиям» —
|
||||
# для шлюза отдельное сообщение в Telegram (журнал Gateway, 302/303 к целевым ПК).
|
||||
$gatewayOnlyFeatureNames = @('RDS-Gateway', 'RDS-WEB-ACCESS')
|
||||
|
||||
try {
|
||||
if (Get-Command Get-WindowsFeature -ErrorAction SilentlyContinue) {
|
||||
$rdsFeatures = @(Get-WindowsFeature -ErrorAction SilentlyContinue | Where-Object {
|
||||
$_.Name -like 'RDS*' -and $_.InstallState -eq 'Installed'
|
||||
})
|
||||
$sessionOrHostLike = @($rdsFeatures | Where-Object { $gatewayOnlyFeatureNames -notcontains $_.Name })
|
||||
if ($sessionOrHostLike.Count -gt 0) {
|
||||
return $true
|
||||
}
|
||||
}
|
||||
} catch { }
|
||||
|
||||
try {
|
||||
if (Get-Service -Name 'UmRdpService' -ErrorAction SilentlyContinue) {
|
||||
return $true
|
||||
}
|
||||
} catch { }
|
||||
|
||||
return $false
|
||||
}
|
||||
|
||||
function Send-Heartbeat {
|
||||
@@ -260,10 +400,21 @@ function Send-Heartbeat {
|
||||
$message = "<b>✅ Мониторинг логинов ЗАПУЩЕН</b>`r`n"
|
||||
$message += "🖥️ Сервер: $env:COMPUTERNAME`r`n"
|
||||
$message += "🕐 Время запуска: $timestamp"
|
||||
if (Test-RDSDeploymentPresent) {
|
||||
$message += "`r`n🔐 <b>RDS (хост сессий):</b> обнаружены компоненты RDS помимо чистого шлюза — в мониторинг входят входы по RDP/RDS на этом узле (Security 4624/4625, типы входа по настройке скрипта)."
|
||||
}
|
||||
if ($EnableRDGatewayMonitoring) {
|
||||
try {
|
||||
$gwLog = Get-WinEvent -ListLog $RDGatewayLogName -ErrorAction SilentlyContinue
|
||||
if ($gwLog) {
|
||||
$message += "`r`n🌐 <b>RD Gateway:</b> журнал шлюза доступен — дополнительно фиксируются подключения пользователей к <b>внутренним целевым компьютерам</b> через RD Gateway (события 302/303 в журнале шлюза)."
|
||||
}
|
||||
} catch { }
|
||||
}
|
||||
Send-TelegramMessage -Message $message | Out-Null
|
||||
Write-Log "Отправлено уведомление о запуске скрипта"
|
||||
} else {
|
||||
$timestamp | Out-File -FilePath $HeartbeatFile -Force -Encoding UTF8
|
||||
Write-TextFileUtf8Bom -Path $HeartbeatFile -Text $timestamp
|
||||
}
|
||||
}
|
||||
|
||||
@@ -289,6 +440,8 @@ function Rotate-LogFile {
|
||||
|
||||
Copy-Item -Path $LogFile -Destination $backupFilePath -Force
|
||||
Clear-Content -Path $LogFile -Force
|
||||
# После Clear-Content файл пустой без BOM — восстановим UTF-8 BOM для корректного просмотра в FAR/редакторах
|
||||
Ensure-FileStartsWithUtf8Bom -Path $LogFile
|
||||
Write-Log "Лог-файл скопирован в бэкап: $backupFilePath"
|
||||
|
||||
$oldBackups = Get-ChildItem -Path $LogBackupFolder -Filter "LoginLog_*.bak" |
|
||||
@@ -327,7 +480,7 @@ function Check-AndRotateLog {
|
||||
elseif ($currentTime -ge $targetRotationTime) { $shouldRotate = $true }
|
||||
|
||||
if ($shouldRotate -and (Rotate-LogFile)) {
|
||||
$currentTime.ToString("yyyy-MM-dd HH:mm:ss") | Out-File -FilePath $lastRotationFile -Force -Encoding UTF8
|
||||
Write-TextFileUtf8Bom -Path $lastRotationFile -Text ($currentTime.ToString("yyyy-MM-dd HH:mm:ss"))
|
||||
}
|
||||
return $targetRotationTime
|
||||
}
|
||||
@@ -503,9 +656,13 @@ function Format-LoginEvent {
|
||||
[string]$ProcessName,
|
||||
[datetime]$TimeCreated,
|
||||
[int]$LogonType,
|
||||
[string]$LogonTypeName
|
||||
[string]$LogonTypeName,
|
||||
[string]$SecurityLogComputerName
|
||||
)
|
||||
|
||||
$logHost = $SecurityLogComputerName
|
||||
if ([string]::IsNullOrWhiteSpace($logHost)) { $logHost = $env:COMPUTERNAME }
|
||||
|
||||
$message = "<b>"
|
||||
if ($EventID -eq 4624) { $message += "✅ УСПЕШНЫЙ ВХОД" }
|
||||
elseif ($EventID -eq 4625) { $message += "❌ НЕУДАЧНАЯ ПОПЫТКА" }
|
||||
@@ -513,7 +670,8 @@ function Format-LoginEvent {
|
||||
$message += "</b>`r`n"
|
||||
|
||||
$message += "👤 Пользователь: $Username`r`n"
|
||||
$message += "🖥️ Компьютер: $ComputerName`r`n"
|
||||
$message += "🏢 Сервер (журнал Security): $logHost`r`n"
|
||||
$message += "🖥️ Рабочая станция (клиент из события): $ComputerName`r`n"
|
||||
$message += "🌐 IP адрес: $SourceIP`r`n"
|
||||
$message += "⚙️ Процесс/Код: $ProcessName`r`n"
|
||||
$message += "🔑 Тип входа: $LogonTypeName ($LogonType)`r`n"
|
||||
@@ -600,15 +758,39 @@ function Format-RDGatewayEvent {
|
||||
|
||||
function Send-DailyReport {
|
||||
try {
|
||||
$quserOutput = & quser 2>$null
|
||||
$count = 0
|
||||
if ($quserOutput -and $quserOutput.Count -gt 1) { $count = $quserOutput.Count - 1 }
|
||||
$quserOutput = @(& quser 2>$null)
|
||||
$usernames = [System.Collections.Generic.List[string]]::new()
|
||||
if ($quserOutput -and $quserOutput.Count -gt 1) {
|
||||
$sessionLines = @($quserOutput | Select-Object -Skip 1)
|
||||
foreach ($raw in $sessionLines) {
|
||||
$line = ($raw -replace '\s+', ' ').Trim()
|
||||
if ([string]::IsNullOrWhiteSpace($line)) { continue }
|
||||
$parts = $line -split ' ', 2
|
||||
if ($parts.Count -lt 1) { continue }
|
||||
$u = $parts[0].Trim()
|
||||
if (-not [string]::IsNullOrWhiteSpace($u) -and $u -ne 'USERNAME') {
|
||||
$usernames.Add($u) | Out-Null
|
||||
}
|
||||
}
|
||||
}
|
||||
$count = $usernames.Count
|
||||
# PS 5.1: без @() один логин даёт скаляр String (нет .Count); пустой список даёт $null.
|
||||
$uniqueUsers = @($usernames | Sort-Object -Unique)
|
||||
$message = "<b>📊 ЕЖЕДНЕВНЫЙ ОТЧЕТ</b>`r`n"
|
||||
$message += "🖥️ Сервер: $env:COMPUTERNAME`r`n"
|
||||
$message += "🕐 Время отчета: $(Get-Date -Format 'dd.MM.yyyy HH:mm:ss')`r`n"
|
||||
$message += "👥 Активных сессий (quser): $count"
|
||||
$message += "👥 Активных сессий (quser): $count`r`n"
|
||||
if ($uniqueUsers.Count -gt 0) {
|
||||
$message += "`r`n<b>Уникальные логины ($($uniqueUsers.Count)):</b>`r`n"
|
||||
foreach ($name in $uniqueUsers) {
|
||||
$safe = [System.Net.WebUtility]::HtmlEncode($name)
|
||||
$message += " • $safe`r`n"
|
||||
}
|
||||
} else {
|
||||
$message += "`r`n<i>Список пользователей недоступен (quser пуст или недостаточно прав).</i>"
|
||||
}
|
||||
Send-TelegramMessage -Message $message | Out-Null
|
||||
(Get-Date).ToString("yyyy-MM-dd HH:mm:ss") | Out-File -FilePath $LastReportFile -Force -Encoding UTF8
|
||||
Write-TextFileUtf8Bom -Path $LastReportFile -Text ((Get-Date).ToString("yyyy-MM-dd HH:mm:ss"))
|
||||
Write-Log "Ежедневный отчет отправлен"
|
||||
return $true
|
||||
} catch {
|
||||
@@ -708,7 +890,8 @@ function Start-LoginMonitor {
|
||||
-ProcessName $eventInfo.ProcessName `
|
||||
-TimeCreated $eventInfo.TimeCreated `
|
||||
-LogonType $eventInfo.LogonType `
|
||||
-LogonTypeName $logonTypeName
|
||||
-LogonTypeName $logonTypeName `
|
||||
-SecurityLogComputerName $event.MachineName
|
||||
|
||||
Write-Log "Notify: ID=$($event.Id) User=$($eventInfo.Username) LT=$($eventInfo.LogonType) IP=$($eventInfo.SourceIP)"
|
||||
Send-TelegramMessage -Message $formattedMessage | Out-Null
|
||||
|
||||
@@ -4,10 +4,18 @@ PowerShell-набор для мониторинга входов в Windows с
|
||||
|
||||
- `Login_Monitor.ps1` — основной монитор (Security `4624/4625`, RD Gateway `302/303`, ротация логов, heartbeat, ежедневный отчет).
|
||||
- `Watchdog_RDP_Monitor.ps1` — watchdog: проверяет, жив ли основной скрипт, и перезапускает его при падении/зависании heartbeat.
|
||||
- `Install-ScheduledTasks.ps1` — создание в Планировщике заданий с теми же именами и параметрами, что в разделе 3 (нужна повышенная PowerShell).
|
||||
|
||||
## Что изменилось (важное)
|
||||
|
||||
- **Кодировка `.ps1`**: в репозитории добавлены `.editorconfig` и `.gitattributes`, чтобы `*.ps1` по умолчанию сохранялись как **UTF-8 with BOM** и с **CRLF** (это сильно снижает “кракозябры” и ошибки парсинга PowerShell).
|
||||
- **Кодировка логов**: `login_monitor.log` / `watchdog.log` пишутся как **UTF-8 с BOM** (и при необходимости BOM добавляется к уже существующему файлу), чтобы в **FAR/старых просмотрщиках** не было ситуации “в консоли нормально, а в файле РЈРІРµ…” из‑за неверной авто-кодировки.
|
||||
- **`auditpol` на русской Windows**: настройка/проверка аудита опирается на категорию **`Вход/выход`** и подкатегории **`Вход в систему` / `Выход из системы`** (ожидается строка **`Успех и сбой`**). Это устраняет ошибки вида `0x00000057` из‑за несуществующего на RU ОС имени `Logon`.
|
||||
- **Стабильность**: `auditpol` запускается через `cmd.exe` с перехватом `stdout+stderr`, чтобы не ломать выполнение при `$ErrorActionPreference = "Stop"`.
|
||||
|
||||
## 1) Подготовка
|
||||
|
||||
1. Скопируйте `Login_Monitor.ps1` в `D:\Soft\Login_Monitor.ps1` (или измените путь в watchdog).
|
||||
1. Скопируйте в `D:\Soft\` как минимум `Login_Monitor.ps1` и `Watchdog_RDP_Monitor.ps1` (для установки из скрипта — ещё `Install-ScheduledTasks.ps1`), либо измените пути в параметрах установщика / в watchdog.
|
||||
2. Откройте `Login_Monitor.ps1` и задайте:
|
||||
- `$TelegramBotToken`
|
||||
- `$TelegramChatID`
|
||||
@@ -22,7 +30,17 @@ powershell.exe -NoProfile -ExecutionPolicy Bypass -File "D:\Soft\Login_Monitor.p
|
||||
|
||||
## 3) Запуск через Планировщик заданий (Task Scheduler)
|
||||
|
||||
### Задание №1: основной монитор
|
||||
Задания можно завести **вручную в 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`, задержки случайного старта и т.д.
|
||||
|
||||
Пример после копирования файлов в `D:\Soft\`:
|
||||
|
||||
```powershell
|
||||
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "D:\Soft\Install-ScheduledTasks.ps1"
|
||||
```
|
||||
|
||||
Это не заменяет настройку токена Telegram в `Login_Monitor.ps1`, а только регистрирует задания.
|
||||
|
||||
### Задание №1: основной монитор (вручную)
|
||||
|
||||
Создайте задачу `RDP Login Monitor`:
|
||||
|
||||
@@ -41,7 +59,7 @@ powershell.exe -NoProfile -ExecutionPolicy Bypass -File "D:\Soft\Login_Monitor.p
|
||||
- **Settings**
|
||||
- `If the task is already running`: `Do not start a new instance`
|
||||
|
||||
### Задание №2: watchdog
|
||||
### Задание №2: watchdog (вручную)
|
||||
|
||||
Создайте задачу `RDP Login Monitor Watchdog`:
|
||||
|
||||
@@ -69,6 +87,7 @@ powershell.exe -NoProfile -ExecutionPolicy Bypass -File "D:\Soft\Login_Monitor.p
|
||||
- `D:\Soft\Logs\watchdog.log`
|
||||
- Heartbeat:
|
||||
- `D:\Soft\Logs\last_heartbeat.txt` обновляется примерно раз в час (по `$HeartbeatInterval`).
|
||||
- Telegram при старте: при установленном **RD Session Host** (или аналогичных компонентах RDS, не только шлюз) — строка про входы по RDP/RDS на этом сервере; при доступном журнале **RD Gateway** — отдельная строка про подключения к **внутренним целевым ПК** через шлюз (302/303). Узел только с ролью RD Gateway не дублирует формулировку «хост сессий».
|
||||
|
||||
## 5) Автоматический перезапуск при падении
|
||||
|
||||
@@ -78,7 +97,4 @@ powershell.exe -NoProfile -ExecutionPolicy Bypass -File "D:\Soft\Login_Monitor.p
|
||||
- если процесса нет — запускает монитор;
|
||||
- если heartbeat старше `$HeartbeatStaleMinutes` (по умолчанию 90 минут) — перезапускает монитор.
|
||||
|
||||
<<<<<<< HEAD
|
||||
=======
|
||||
|
||||
>>>>>>> 68f8bcb (Add editor/gitattributes defaults for PowerShell encoding)
|
||||
|
||||
@@ -18,15 +18,36 @@ param(
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$script:Utf8BomEncoding = New-Object System.Text.UTF8Encoding $true
|
||||
$script:WatchdogLogBomChecked = $false
|
||||
|
||||
function Ensure-FileStartsWithUtf8Bom {
|
||||
param([Parameter(Mandatory = $true)][string]$Path)
|
||||
if (-not (Test-Path -LiteralPath $Path)) { return }
|
||||
$bytes = [System.IO.File]::ReadAllBytes($Path)
|
||||
if ($bytes.Length -ge 3 -and $bytes[0] -eq 0xEF -and $bytes[1] -eq 0xBB -and $bytes[2] -eq 0xBF) { return }
|
||||
$bom = [byte[]](0xEF, 0xBB, 0xBF)
|
||||
$combined = New-Object byte[] ($bom.Length + $bytes.Length)
|
||||
[Buffer]::BlockCopy($bom, 0, $combined, 0, $bom.Length)
|
||||
if ($bytes.Length -gt 0) {
|
||||
[Buffer]::BlockCopy($bytes, 0, $combined, $bom.Length, $bytes.Length)
|
||||
}
|
||||
[System.IO.File]::WriteAllBytes($Path, $combined)
|
||||
}
|
||||
|
||||
function Write-WatchdogLog {
|
||||
param([string]$Message)
|
||||
$ts = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
|
||||
$line = "$ts - $Message"
|
||||
$line = "$ts - $Message" + [Environment]::NewLine
|
||||
$dir = Split-Path -Parent $WatchdogLog
|
||||
if ($dir -and -not (Test-Path $dir)) {
|
||||
New-Item -ItemType Directory -Path $dir -Force | Out-Null
|
||||
}
|
||||
Add-Content -Path $WatchdogLog -Value $line -Encoding UTF8
|
||||
if (-not $script:WatchdogLogBomChecked) {
|
||||
Ensure-FileStartsWithUtf8Bom -Path $WatchdogLog
|
||||
$script:WatchdogLogBomChecked = $true
|
||||
}
|
||||
[System.IO.File]::AppendAllText($WatchdogLog, $line, $script:Utf8BomEncoding)
|
||||
}
|
||||
|
||||
function Get-MainScriptProcesses {
|
||||
|
||||
Reference in New Issue
Block a user