commit 21ad87d08dc23ae397eb7cd96b7822b633970b1f Author: Луценко Андрей Анатольевич Date: Thu Apr 9 16:36:27 2026 +1000 Add login monitor, watchdog and Task Scheduler docs diff --git a/Login_Monitor.ps1 b/Login_Monitor.ps1 new file mode 100644 index 0000000..b3d79b9 --- /dev/null +++ b/Login_Monitor.ps1 @@ -0,0 +1,697 @@ +<# +.SYNOPSIS + Мониторинг логинов/попыток входа с уведомлениями в Telegram +.DESCRIPTION + Отслеживает события входа в систему (Security 4624/4625) и события RD Gateway (302/303), + отправляет уведомления в Telegram, делает ротацию логов, heartbeat в файл и ежедневный отчет. +.NOTES + Требуется: PowerShell 5.0+, запуск от администратора. + Важное: + - На некоторых серверах RDP-логин приходит как LogonType=3, поэтому интерактивные типы: 2/3/10. + - Добавлены исключения шума: DWM-*, UMFD-*, HealthMailbox*, Font Driver Host*, NtLmSsp и др. + - Heartbeat без дрейфа: используется nextHeartbeatTime (а не "прошло N секунд"). +#> + +[CmdletBinding()] +param( + [string]$TelegramBotToken = "", + [string]$TelegramChatID = "" +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +# ============================================ +# КОНФИГУРАЦИЯ +# ============================================ + +# Логи +$LogFile = "D:\Soft\Logs\login_monitor.log" +$LogBackupFolder = "D:\Soft\Logs\Backup" +$MaxBackupDays = 30 + +# Ротация логов (ежедневно) +$LogRotationHour = 0 +$LogRotationMinute = 0 + +# Heartbeat (только файл) +$HeartbeatInterval = 3600 +$HeartbeatFile = "D:\Soft\Logs\last_heartbeat.txt" + +# Ежедневный отчет +$DailyReportHour = 9 +$DailyReportMinute = 0 +$LastReportFile = "D:\Soft\Logs\last_daily_report.txt" + +# RD Gateway +$EnableRDGatewayMonitoring = $true +$RDGatewayLogName = "Microsoft-Windows-TerminalServices-Gateway/Operational" +$RDGatewayEvents = @(302, 303) + +$ExcludedProcesses = @( + "HTTP", "HTTP/*", "W3WP.EXE", "MSExchange", "SYSTEM", "LOCAL SERVICE", + "NETWORK SERVICE", "OUTLOOK.EXE", "EXCHANGE", "EDGETRANSPORT", "STORE.EXE", + "MAD.EXE", "UMservice", "MSExchangeADTopology", "MSExchangeAntispam", + "MSExchangeDelivery", "MSExchangeFrontendTransport", "MSExchangeHM", + "MSExchangeMailboxAssistants", "MSExchangeMailboxReplication", "MSExchangeRPC", + "MSExchangeSubmission", "MSExchangeThrottling", "MSExchangeTransport", + "MSExchangeTransportLogSearch", "IIS", "SQLSERVR.EXE", "MSSQL$", + "WINLOGON.EXE", "LSASS.EXE", "SVCHOST.EXE", + "%%2310", + "%%2313" +) + +$ExcludedUsers = @( + "SYSTEM", "LOCAL SERVICE", "NETWORK SERVICE", "ANONYMOUS LOGON" +) + +$ExcludedUserPatterns = @( + "HealthMailbox*", + "DWM-*", + "UMFD-*", + "Font Driver Host*" +) + +$ExcludedLogonProcesses = @( + "NtLmSsp" +) + +$ExcludedComputerPatterns = @( + "00000000-0000-0000-0000-000000000000", + "*-*-*-*-*", + "NT AUTHORITY", + "NtLmSsp", + "NtLmSsp*", + "Authz", + "Authz*" +) + +# ============================================ +# ИНИЦИАЛИЗАЦИЯ +# ============================================ + +$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 } + +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 +} + +try { + [System.Net.ServicePointManager]::SecurityProtocol = + [System.Net.ServicePointManager]::SecurityProtocol -bor [System.Net.SecurityProtocolType]::Tls12 + Write-Log "TLS 1.2 включен" +} catch { + try { + [System.Net.ServicePointManager]::SecurityProtocol = [System.Net.SecurityProtocolType]::Tls12 + Write-Log "TLS 1.2 установлен" + } catch { + Write-Log "ВНИМАНИЕ: Не удалось включить TLS 1.2" + } +} + +function Send-TelegramMessage { + param([string]$Message) + + if ([string]::IsNullOrWhiteSpace($TelegramBotToken) -or [string]::IsNullOrWhiteSpace($TelegramChatID)) { + Write-Log "Telegram: не задан токен/chat_id" + return $false + } + + $uri = "https://api.telegram.org/bot$TelegramBotToken/sendMessage" + $body = @{ + chat_id = $TelegramChatID + text = $Message + parse_mode = "HTML" + } + + try { + [System.Net.ServicePointManager]::SecurityProtocol = [System.Net.SecurityProtocolType]::Tls12 + $null = Invoke-RestMethod -Uri $uri -Method Post -Body $body -ErrorAction Stop -TimeoutSec 30 + return $true + } catch { + Write-Log "Ошибка отправки в Telegram: $($_.Exception.Message)" + return $false + } +} + +function Test-TelegramConnection { + Write-Log "Проверка подключения к Telegram API..." + try { + [System.Net.ServicePointManager]::SecurityProtocol = [System.Net.SecurityProtocolType]::Tls12 + $testUrl = "https://api.telegram.org/bot$TelegramBotToken/getMe" + $response = Invoke-RestMethod -Uri $testUrl -Method Get -TimeoutSec 10 -ErrorAction Stop + if ($response.ok) { + Write-Log "Подключение к Telegram успешно. Бот: @$($response.result.username)" + return $true + } + } catch { + Write-Log "Ошибка подключения к Telegram: $($_.Exception.Message)" + return $false + } + return $false +} + +function Test-Administrator { + $currentUser = [Security.Principal.WindowsIdentity]::GetCurrent() + $principal = New-Object Security.Principal.WindowsPrincipal($currentUser) + return $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) +} + +if (-not (Test-Administrator)) { + Write-Log "ОШИБКА: Скрипт должен быть запущен от имени администратора!" + exit 1 +} +Write-Log "Скрипт запущен с правами администратора" + +function Enable-SecurityAudit { + Write-Log "Проверка настроек аудита..." + $auditPolicy = auditpol /get /subcategory:"Logon" 2>$null + if ($auditPolicy -like "*Success*Failure*") { + Write-Log "Аудит входа уже настроен" + } else { + Write-Log "Включение аудита входа..." + try { + auditpol /set /category:"{69979849-797A-11D9-BED3-505054503030}" /success:enable /failure:enable + Write-Log "Аудит входа успешно включен" + } catch { + Write-Log "Ошибка включения аудита: $($_.Exception.Message)" + } + } +} + +function Send-Heartbeat { + param([switch]$IsStartup = $false) + + $timestamp = Get-Date -Format "dd.MM.yyyy HH:mm:ss" + + if ($IsStartup) { + $message = "✅ Мониторинг логинов ЗАПУЩЕН`r`n" + $message += "🖥️ Сервер: $env:COMPUTERNAME`r`n" + $message += "🕐 Время запуска: $timestamp" + Send-TelegramMessage -Message $message | Out-Null + Write-Log "Отправлено уведомление о запуске скрипта" + } else { + $timestamp | Out-File -FilePath $HeartbeatFile -Force -Encoding UTF8 + } +} + +function Send-StopNotification { + param([string]$Reason) + + $timestamp = Get-Date -Format "dd.MM.yyyy HH:mm:ss" + $message = "⚠️ МОНИТОРИНГ ЛОГИНОВ ОСТАНОВЛЕН`r`n" + $message += "🖥️ Сервер: $env:COMPUTERNAME`r`n" + $message += "🕐 Время остановки: $timestamp`r`n" + $message += "📋 Причина: $Reason" + + Send-TelegramMessage -Message $message | Out-Null + Write-Log "Уведомление об остановке отправлено: $Reason" +} + +function Rotate-LogFile { + try { + if (Test-Path $LogFile) { + $backupDate = Get-Date -Format "yyyy-MM-dd_HH-mm-ss" + $backupFileName = "LoginLog_$backupDate.bak" + $backupFilePath = Join-Path $LogBackupFolder $backupFileName + + Copy-Item -Path $LogFile -Destination $backupFilePath -Force + Clear-Content -Path $LogFile -Force + Write-Log "Лог-файл скопирован в бэкап: $backupFilePath" + + $oldBackups = Get-ChildItem -Path $LogBackupFolder -Filter "LoginLog_*.bak" | + Where-Object { $_.LastWriteTime -lt (Get-Date).AddDays(-$MaxBackupDays) } + + foreach ($oldBackup in $oldBackups) { + Remove-Item -Path $oldBackup.FullName -Force + Write-Log "Удален старый бэкап: $($oldBackup.Name)" + } + return $true + } + } catch { + Write-Log "Ошибка при ротации лог-файла: $($_.Exception.Message)" + } + return $false +} + +function Check-AndRotateLog { + $lastRotationFile = Join-Path $LogBackupFolder "last_rotation.txt" + $lastRotation = $null + + if (Test-Path $lastRotationFile) { + $lastRotationRaw = Get-Content $lastRotationFile -ErrorAction SilentlyContinue + if ($lastRotationRaw) { + $lastRotation = [datetime]::ParseExact($lastRotationRaw, "yyyy-MM-dd HH:mm:ss", $null) + } + } + + $currentTime = Get-Date + $targetRotationTime = Get-Date -Year $currentTime.Year -Month $currentTime.Month -Day $currentTime.Day ` + -Hour $LogRotationHour -Minute $LogRotationMinute -Second 0 + if ($currentTime -ge $targetRotationTime) { $targetRotationTime = $targetRotationTime.AddDays(1) } + + $shouldRotate = $false + if ($lastRotation -eq $null) { $shouldRotate = $true } + 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 + } + return $targetRotationTime +} + +function Cleanup-OldLogs { + try { + if (Test-Path $LogBackupFolder) { + $oldBackups = Get-ChildItem -Path $LogBackupFolder -Filter "LoginLog_*.bak" | + Where-Object { $_.LastWriteTime -lt (Get-Date).AddDays(-$MaxBackupDays) } + foreach ($oldBackup in $oldBackups) { + Remove-Item -Path $oldBackup.FullName -Force + Write-Log "Удален старый бэкап: $($oldBackup.Name)" + } + } + } catch { + Write-Log "Ошибка при очистке старых логов: $($_.Exception.Message)" + } +} + +function Get-EventDataMap { + param($Event) + $map = @{} + try { + $xml = [xml]$Event.ToXml() + foreach ($d in $xml.Event.EventData.Data) { + $name = [string]$d.Name + if ([string]::IsNullOrWhiteSpace($name)) { continue } + $map[$name] = [string]$d.'#text' + } + } catch { } + return $map +} + +function Get-FirstNonEmptyMapValue { + param([hashtable]$DataMap, [string[]]$Keys) + foreach ($k in $Keys) { + if (-not $DataMap.ContainsKey($k)) { continue } + $v = $DataMap[$k] + if (-not [string]::IsNullOrWhiteSpace($v)) { return [string]$v } + } + return $null +} + +function Convert-ToIntSafe { + param([object]$Value) + if ($null -eq $Value) { return 0 } + $s = [string]$Value + if ($s -match '(\d+)') { return [int]$Matches[1] } + return 0 +} + +function Get-LogonTypeName { + param([int]$LogonType) + switch ($LogonType) { + 2 { return "Интерактивный (консоль)" } + 3 { return "Сеть/RDP (Network) (3)" } + 10 { return "Удаленный интерактивный (RDP) (10)" } + 4 { return "Пакетный (Batch)" } + 5 { return "Сервис (Service)" } + 7 { return "Разблокировка (Unlock)" } + 8 { return "Сетевой с явными данными" } + 9 { return "Новые учетные данные" } + default { return "Тип $LogonType (неизвестный)" } + } +} + +function Should-IgnoreEvent { + param( + [string]$Username, + [string]$ProcessName, + [string]$ComputerName, + [int]$EventID, + [int]$LogonType = 0, + [string]$SourceIP = "" + ) + + if ($null -ne $Username) { $Username = $Username.Trim() } + if ($null -ne $ComputerName) { $ComputerName = $ComputerName.Trim() } + if ($null -ne $ProcessName) { $ProcessName = $ProcessName.Trim() } + if ($null -ne $SourceIP) { $SourceIP = $SourceIP.Trim() } + + if ($EventID -eq 4648) { return $true } + if ([string]::IsNullOrWhiteSpace($Username)) { return $true } + + if ($Username -like "*\DWM-*" -or $Username -like "DWM-*") { return $true } + if ($Username -like "*\UMFD-*" -or $Username -like "UMFD-*") { return $true } + if ($Username -like "*$") { return $true } + + foreach ($excludedUser in $ExcludedUsers) { + if ($Username -like "*$excludedUser*") { return $true } + } + foreach ($p in $ExcludedUserPatterns) { + if ($Username -like $p) { return $true } + } + + if ($ComputerName -eq "Authz" -or $ComputerName -like "Authz*") { return $true } + if ($ComputerName -eq "NtLmSsp" -or $ComputerName -like "NtLmSsp*" -or $ComputerName -like "*NtLmSsp*") { return $true } + + foreach ($lp in $ExcludedLogonProcesses) { + if ($ProcessName -like "*$lp*") { return $true } + } + foreach ($excludedProcess in $ExcludedProcesses) { + if ($ProcessName -like "*$excludedProcess*") { return $true } + } + + if ($SourceIP -eq "127.0.0.1" -or $SourceIP -like "fe80:*") { return $true } + if ($ComputerName -eq "-" -or $ComputerName -eq "N/A") { return $true } + + foreach ($pattern in $ExcludedComputerPatterns) { + if ($ComputerName -like $pattern) { return $true } + } + + return $false +} + +function Get-LoginEventInfo { + param($Event) + + $eventData = @{ + TimeCreated = $Event.TimeCreated + Username = "-" + ComputerName = "-" + SourceIP = "-" + ProcessName = "-" + LogonType = 0 + } + + try { + $map = Get-EventDataMap -Event $Event + $eventData.Username = Get-FirstNonEmptyMapValue -DataMap $map -Keys @("TargetUserName","AccountName","UserName","SubjectUserName") + $eventData.ComputerName = Get-FirstNonEmptyMapValue -DataMap $map -Keys @("WorkstationName","ComputerName","TargetWorkstationName") + $eventData.SourceIP = Get-FirstNonEmptyMapValue -DataMap $map -Keys @("IpAddress","SourceNetworkAddress","Ip") + $eventData.LogonType = Convert-ToIntSafe (Get-FirstNonEmptyMapValue -DataMap $map -Keys @("LogonType")) + + if ($Event.Id -eq 4624) { + $eventData.ProcessName = Get-FirstNonEmptyMapValue -DataMap $map -Keys @( + "LogonProcessName","AuthenticationPackageName","AuthenticationPackage","ProcessName" + ) + } elseif ($Event.Id -eq 4625) { + $eventData.ProcessName = Get-FirstNonEmptyMapValue -DataMap $map -Keys @( + "SubStatus","Status","FailureReason","FailureReasonCode" + ) + } + } catch { + Write-Log "Ошибка при извлечении данных события: $($_.Exception.Message)" + } + + if ([string]::IsNullOrWhiteSpace($eventData.Username)) { $eventData.Username = "-" } + if ([string]::IsNullOrWhiteSpace($eventData.ComputerName)) { $eventData.ComputerName = "-" } + if ([string]::IsNullOrWhiteSpace($eventData.SourceIP)) { $eventData.SourceIP = "-" } + if ([string]::IsNullOrWhiteSpace($eventData.ProcessName)) { $eventData.ProcessName = "-" } + + return $eventData +} + +function Format-LoginEvent { + param( + [int]$EventID, + [string]$Username, + [string]$ComputerName, + [string]$SourceIP, + [string]$ProcessName, + [datetime]$TimeCreated, + [int]$LogonType, + [string]$LogonTypeName + ) + + $message = "" + if ($EventID -eq 4624) { $message += "✅ УСПЕШНЫЙ ВХОД" } + elseif ($EventID -eq 4625) { $message += "❌ НЕУДАЧНАЯ ПОПЫТКА" } + else { $message += "⚠️ СОБЫТИЕ" } + $message += "`r`n" + + $message += "👤 Пользователь: $Username`r`n" + $message += "🖥️ Компьютер: $ComputerName`r`n" + $message += "🌐 IP адрес: $SourceIP`r`n" + $message += "⚙️ Процесс/Код: $ProcessName`r`n" + $message += "🔑 Тип входа: $LogonTypeName ($LogonType)`r`n" + $message += "🕐 Время: $($TimeCreated.ToString('dd.MM.yyyy HH:mm:ss'))`r`n" + $message += "🔢 Event ID: $EventID" + + return $message +} + +function Test-RDGatewayLog { + try { + $logExists = Get-WinEvent -ListLog $RDGatewayLogName -ErrorAction SilentlyContinue + if ($logExists) { + Write-Log "Журнал RD Gateway доступен: $RDGatewayLogName" + return $true + } + } catch { + Write-Log "Ошибка при проверке журнала RD Gateway: $($_.Exception.Message)" + } + return $false +} + +function Get-RDGatewayEventInfo { + param($Event) + $eventData = @{ + TimeCreated = $Event.TimeCreated + Username = "N/A" + ExternalIP = "N/A" + InternalIP = "N/A" + Protocol = "N/A" + ErrorCode = "N/A" + } + try { + switch ($Event.Id) { + 302 { + if ($Event.Properties.Count -gt 0) { $eventData.Username = $Event.Properties[0].Value } + if ($Event.Properties.Count -gt 1) { $eventData.ExternalIP = $Event.Properties[1].Value } + if ($Event.Properties.Count -gt 2) { $eventData.InternalIP = $Event.Properties[2].Value } + if ($Event.Properties.Count -gt 3) { $eventData.Protocol = $Event.Properties[3].Value } + $eventData.ErrorCode = "0" + } + 303 { + if ($Event.Properties.Count -gt 0) { $eventData.Username = $Event.Properties[0].Value } + if ($Event.Properties.Count -gt 1) { $eventData.ExternalIP = $Event.Properties[1].Value } + if ($Event.Properties.Count -gt 2) { $eventData.InternalIP = $Event.Properties[2].Value } + if ($Event.Properties.Count -gt 3) { $eventData.Protocol = $Event.Properties[3].Value } + if ($Event.Properties.Count -gt 4) { $eventData.ErrorCode = $Event.Properties[4].Value } + } + } + } catch { + Write-Log "Ошибка при извлечении RD Gateway события: $($_.Exception.Message)" + } + return $eventData +} + +function Format-RDGatewayEvent { + param( + [int]$EventID, + [string]$Username, + [string]$ExternalIP, + [string]$InternalIP, + [string]$Protocol, + [string]$ErrorCode, + [datetime]$TimeCreated + ) + + $message = "" + if ($EventID -eq 302) { $message += "🖥️ УСПЕШНОЕ ПОДКЛЮЧЕНИЕ ЧЕРЕЗ RD GATEWAY" } + elseif ($EventID -eq 303) { $message += "❌ НЕУДАЧНОЕ ПОДКЛЮЧЕНИЕ ЧЕРЕЗ RD GATEWAY" } + else { $message += "⚠️ СОБЫТИЕ RD GATEWAY" } + $message += "`r`n" + + $message += "👤 Пользователь: $Username`r`n" + $message += "🌐 IP пользователя (внешний): $ExternalIP`r`n" + $message += "🖥️ IP внутренний: $InternalIP`r`n" + $message += "🔌 Протокол: $Protocol`r`n" + if ($EventID -eq 303 -and $ErrorCode -ne "0" -and $ErrorCode -ne "N/A") { + $message += "⚠️ Код ошибки: $ErrorCode`r`n" + } + $message += "🕐 Время: $($TimeCreated.ToString('dd.MM.yyyy HH:mm:ss'))`r`n" + $message += "🔢 Event ID: $EventID" + return $message +} + +function Send-DailyReport { + try { + $quserOutput = & quser 2>$null + $count = 0 + if ($quserOutput -and $quserOutput.Count -gt 1) { $count = $quserOutput.Count - 1 } + $message = "📊 ЕЖЕДНЕВНЫЙ ОТЧЕТ`r`n" + $message += "🖥️ Сервер: $env:COMPUTERNAME`r`n" + $message += "🕐 Время отчета: $(Get-Date -Format 'dd.MM.yyyy HH:mm:ss')`r`n" + $message += "👥 Активных сессий (quser): $count" + Send-TelegramMessage -Message $message | Out-Null + (Get-Date).ToString("yyyy-MM-dd HH:mm:ss") | Out-File -FilePath $LastReportFile -Force -Encoding UTF8 + Write-Log "Ежедневный отчет отправлен" + return $true + } catch { + Write-Log "Ошибка ежедневного отчета: $($_.Exception.Message)" + return $false + } +} + +function Check-AndSendDailyReport { + $lastReport = $null + if (Test-Path $LastReportFile) { + $txt = Get-Content $LastReportFile -ErrorAction SilentlyContinue + if ($txt) { $lastReport = [datetime]::ParseExact($txt, "yyyy-MM-dd HH:mm:ss", $null) } + } + + $now = Get-Date + $target = Get-Date -Year $now.Year -Month $now.Month -Day $now.Day -Hour $DailyReportHour -Minute $DailyReportMinute -Second 0 + if ($now -ge $target) { $target = $target.AddDays(1) } + + $shouldSend = $false + if ($lastReport -eq $null) { $shouldSend = $true } + elseif ($now -ge $target) { $shouldSend = $true } + if ($shouldSend) { Send-DailyReport | Out-Null } + + return $target +} + +function Start-LoginMonitor { + param( + [int]$MonitorInterval = 5, + [switch]$MonitorAllEvents = $false, + [switch]$MonitorInteractiveOnly = $true + ) + + Write-Log "========================================" + Write-Log "Запуск мониторинга логинов" + Write-Log "Интерактивные типы: 2,3,10" + Write-Log "========================================" + + Cleanup-OldLogs + Send-Heartbeat -IsStartup + Enable-SecurityAudit + + $rdGatewayAvailable = $false + if ($EnableRDGatewayMonitoring) { $rdGatewayAvailable = Test-RDGatewayLog } + + $nextHeartbeatTime = (Get-Date).AddSeconds($HeartbeatInterval) + $nextRotationCheck = Check-AndRotateLog + $nextReportCheck = Check-AndSendDailyReport + $lastCheckTime = (Get-Date).AddSeconds(-10) + $lastGatewayCheckTime = (Get-Date).AddSeconds(-10) + $monitorEvents = @(4624, 4625, 4648) + + while ($true) { + try { + $events = Get-WinEvent -FilterHashtable @{ + LogName = 'Security' + ID = $monitorEvents + StartTime = $lastCheckTime + } -ErrorAction SilentlyContinue + + if ($events) { + foreach ($event in $events) { + if ($event.TimeCreated -le $lastCheckTime) { continue } + + $eventInfo = Get-LoginEventInfo -Event $event + $logonTypeName = Get-LogonTypeName -LogonType $eventInfo.LogonType + $shouldIgnore = $false + + if ($MonitorInteractiveOnly -and -not $MonitorAllEvents) { + if ($event.Id -eq 4648) { + $shouldIgnore = $true + } elseif ($event.Id -in 4624, 4625) { + $interactiveTypes = @(2, 3, 10) + if ($interactiveTypes -notcontains $eventInfo.LogonType) { + $shouldIgnore = $true + } + } else { + $shouldIgnore = $true + } + } + + if (-not $shouldIgnore -and -not $MonitorAllEvents) { + $shouldIgnore = Should-IgnoreEvent -Username $eventInfo.Username ` + -ProcessName $eventInfo.ProcessName ` + -ComputerName $eventInfo.ComputerName ` + -EventID $event.Id ` + -LogonType $eventInfo.LogonType ` + -SourceIP $eventInfo.SourceIP + } + + if (-not $shouldIgnore) { + $formattedMessage = Format-LoginEvent -EventID $event.Id ` + -Username $eventInfo.Username ` + -ComputerName $eventInfo.ComputerName ` + -SourceIP $eventInfo.SourceIP ` + -ProcessName $eventInfo.ProcessName ` + -TimeCreated $eventInfo.TimeCreated ` + -LogonType $eventInfo.LogonType ` + -LogonTypeName $logonTypeName + + Write-Log "Notify: ID=$($event.Id) User=$($eventInfo.Username) LT=$($eventInfo.LogonType) IP=$($eventInfo.SourceIP)" + Send-TelegramMessage -Message $formattedMessage | Out-Null + } + } + $lastCheckTime = ($events | Measure-Object -Property TimeCreated -Maximum | Select-Object -ExpandProperty Maximum).AddSeconds(1) + } + + if ($rdGatewayAvailable) { + $gatewayEvents = Get-WinEvent -FilterHashtable @{ + LogName = $RDGatewayLogName + ID = $RDGatewayEvents + StartTime = $lastGatewayCheckTime + } -ErrorAction SilentlyContinue + + if ($gatewayEvents) { + foreach ($event in $gatewayEvents) { + if ($event.TimeCreated -le $lastGatewayCheckTime) { continue } + $ei = Get-RDGatewayEventInfo -Event $event + if ($ei.Username -like "*$") { continue } + $msg = Format-RDGatewayEvent -EventID $event.Id ` + -Username $ei.Username ` + -ExternalIP $ei.ExternalIP ` + -InternalIP $ei.InternalIP ` + -Protocol $ei.Protocol ` + -ErrorCode $ei.ErrorCode ` + -TimeCreated $ei.TimeCreated + Write-Log "Notify RDG: ID=$($event.Id) User=$($ei.Username)" + Send-TelegramMessage -Message $msg | Out-Null + } + $lastGatewayCheckTime = ($gatewayEvents | Measure-Object -Property TimeCreated -Maximum | Select-Object -ExpandProperty Maximum).AddSeconds(1) + } + } + + $now = Get-Date + if ($now -ge $nextHeartbeatTime) { + Send-Heartbeat + $nextHeartbeatTime = $nextHeartbeatTime.AddSeconds($HeartbeatInterval) + } + if ($now -ge $nextRotationCheck) { + $nextRotationCheck = Check-AndRotateLog + } + if ($now -ge $nextReportCheck) { + $nextReportCheck = Check-AndSendDailyReport + } + } catch { + Write-Log "Ошибка цикла мониторинга: $($_.Exception.Message)" + } + Start-Sleep -Seconds $MonitorInterval + } +} + +try { + Test-TelegramConnection | Out-Null + Start-LoginMonitor -MonitorInterval 5 -MonitorInteractiveOnly +} catch { + Write-Log "Критическая ошибка: $($_.Exception.Message)" + Send-StopNotification -Reason "Критическая ошибка: $($_.Exception.Message)" + throw +} finally { + Send-StopNotification -Reason "Скрипт завершил работу" +} + diff --git a/README.md b/README.md new file mode 100644 index 0000000..bfa9a8c --- /dev/null +++ b/README.md @@ -0,0 +1,85 @@ +# RDP Login Monitor + +PowerShell-набор для мониторинга входов в Windows с отправкой уведомлений в Telegram: + +- `Login_Monitor.ps1` — основной монитор (Security `4624/4625`, RD Gateway `302/303`, ротация логов, heartbeat, ежедневный отчет). +- `Watchdog_RDP_Monitor.ps1` — watchdog: проверяет, жив ли основной скрипт, и перезапускает его при падении/зависании heartbeat. + +## 1) Подготовка + +1. Скопируйте `Login_Monitor.ps1` в `D:\Soft\Login_Monitor.ps1` (или измените путь в watchdog). +2. Откройте `Login_Monitor.ps1` и задайте: + - `$TelegramBotToken` + - `$TelegramChatID` +3. Убедитесь, что существует `D:\Soft\Logs\` (скрипт сам создаст при запуске). +4. Запускайте от имени администратора (нужен доступ к журналу `Security`). + +## 2) Ручной запуск + +```powershell +powershell.exe -NoProfile -ExecutionPolicy Bypass -File "D:\Soft\Login_Monitor.ps1" +``` + +## 3) Запуск через Планировщик заданий (Task Scheduler) + +### Задание №1: основной монитор + +Создайте задачу `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) Что проверять после запуска + +- Логи: + - `D:\Soft\Logs\login_monitor.log` + - `D:\Soft\Logs\watchdog.log` +- Heartbeat: + - `D:\Soft\Logs\last_heartbeat.txt` обновляется примерно раз в час (по `$HeartbeatInterval`). + +## 5) Автоматический перезапуск при падении + +`Watchdog_RDP_Monitor.ps1` делает: + +- проверяет, есть ли процесс `powershell.exe/pwsh.exe` с `Login_Monitor.ps1` в командной строке; +- если процесса нет — запускает монитор; +- если heartbeat старше `$HeartbeatStaleMinutes` (по умолчанию 90 минут) — перезапускает монитор. + +## 6) Безопасность + +- Не храните реальный токен Telegram в публичном репозитории. +- Лучше передавать токен/chat id через защищенные параметры, переменные окружения или секреты CI/CD. + diff --git a/Watchdog_RDP_Monitor.ps1 b/Watchdog_RDP_Monitor.ps1 new file mode 100644 index 0000000..83c50d1 --- /dev/null +++ b/Watchdog_RDP_Monitor.ps1 @@ -0,0 +1,96 @@ +<# +.SYNOPSIS + Watchdog для Login_Monitor.ps1 +.DESCRIPTION + Проверяет, запущен ли основной скрипт Login_Monitor.ps1. + Если нет — запускает его и пишет лог. + Дополнительно проверяет heartbeat-файл и перезапускает скрипт, если heartbeat "протух". +#> + +[CmdletBinding()] +param( + [string]$MainScriptPath = "D:\Soft\Login_Monitor.ps1", + [string]$HeartbeatFile = "D:\Soft\Logs\last_heartbeat.txt", + [int]$HeartbeatStaleMinutes = 90, + [string]$WatchdogLog = "D:\Soft\Logs\watchdog.log" +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +function Write-WatchdogLog { + param([string]$Message) + $ts = Get-Date -Format "yyyy-MM-dd HH:mm:ss" + $line = "$ts - $Message" + $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 +} + +function Get-MainScriptProcesses { + try { + $procs = Get-CimInstance Win32_Process -Filter "Name = 'powershell.exe' OR Name = 'pwsh.exe'" -ErrorAction Stop + return $procs | Where-Object { $_.CommandLine -and ($_.CommandLine -like "*$MainScriptPath*") } + } catch { + Write-WatchdogLog "Ошибка проверки процессов: $($_.Exception.Message)" + return @() + } +} + +function Start-MainScript { + if (-not (Test-Path $MainScriptPath)) { + Write-WatchdogLog "Основной скрипт не найден: $MainScriptPath" + return + } + $args = "-NoProfile -ExecutionPolicy Bypass -File `"$MainScriptPath`"" + Start-Process -FilePath "powershell.exe" -ArgumentList $args -WindowStyle Hidden | Out-Null + Write-WatchdogLog "Основной скрипт запущен: $MainScriptPath" +} + +function Stop-MainScript { + $procs = Get-MainScriptProcesses + foreach ($p in $procs) { + try { + Stop-Process -Id $p.ProcessId -Force -ErrorAction Stop + Write-WatchdogLog "Остановлен зависший экземпляр PID=$($p.ProcessId)" + } catch { + Write-WatchdogLog "Ошибка остановки PID=$($p.ProcessId): $($_.Exception.Message)" + } + } +} + +function Is-HeartbeatStale { + if (-not (Test-Path $HeartbeatFile)) { + Write-WatchdogLog "Heartbeat файл отсутствует: $HeartbeatFile" + return $true + } + try { + $raw = (Get-Content $HeartbeatFile -ErrorAction Stop | Select-Object -First 1).Trim() + if (-not $raw) { return $true } + $hb = [datetime]::ParseExact($raw, "dd.MM.yyyy HH:mm:ss", $null) + $age = (Get-Date) - $hb + return ($age.TotalMinutes -gt $HeartbeatStaleMinutes) + } catch { + Write-WatchdogLog "Ошибка чтения heartbeat: $($_.Exception.Message)" + return $true + } +} + +$running = Get-MainScriptProcesses +if (-not $running -or $running.Count -eq 0) { + Write-WatchdogLog "Основной скрипт не запущен, выполняю старт." + Start-MainScript + exit 0 +} + +if (Is-HeartbeatStale) { + Write-WatchdogLog "Heartbeat устарел, перезапускаю основной скрипт." + Stop-MainScript + Start-Sleep -Seconds 2 + Start-MainScript +} else { + Write-WatchdogLog "Проверка пройдена: процесс запущен, heartbeat свежий." +} +