From 746f9e23b2f5bb247a66291eaa0a34d0aea90f52 Mon Sep 17 00:00:00 2001 From: PTah Date: Wed, 17 Jun 2026 10:42:40 +1000 Subject: [PATCH] feat: detect OS reboot on startup via System log and LastBootUpTime Co-authored-by: Cursor --- Login_Monitor.ps1 | 150 ++++++++++++++++++++++++++++- login_monitor.settings.example.ps1 | 3 + version.txt | 2 +- 3 files changed, 151 insertions(+), 4 deletions(-) diff --git a/Login_Monitor.ps1 b/Login_Monitor.ps1 index 0420a6a..7c29092 100644 --- a/Login_Monitor.ps1 +++ b/Login_Monitor.ps1 @@ -90,7 +90,7 @@ $script:SkipLogDetailLimit = 15 # строки ниже, если правки «мелкие» и вы не хотите менять отображаемую версию в логах). # Рекомендация: при значимых релизах меняйте и $ScriptVersion, и version.txt одинаково; при только # исправлениях на шаре — достаточно поднять patch в version.txt (например 1.3.0.1). -$ScriptVersion = "2.0.36-SAC" +$ScriptVersion = "2.0.37-SAC" # Логи (все под InstallRoot) $LogFile = Join-Path $script:InstallRoot "Logs\login_monitor.log" @@ -103,6 +103,8 @@ $MaxBackupDays = 31 # Heartbeat (файл; при отсутствии обновления > HeartbeatStaleAlertMultiplier × интервал — оповещение) $HeartbeatInterval = 14400 +# Окно (мин) для определения «старт после перезагрузки ОС» (LastBootUpTime + System 41/1074/6005/6008/6009). +$StartupRebootDetectMinutes = 5 # Инвентаризация железа/ПО для SAC (agent.inventory); интервал опроса, сек (12 ч). $InventoryIntervalSec = 43200 $GetInventory = 1 @@ -1095,7 +1097,8 @@ function Send-RdpMonitorLifecycleNotification { [Parameter(Mandatory = $true)][string]$SacSummary, [string]$SacTitle = '', [string]$SacSeverity = 'info', - [string]$EmailSubject = 'RDP Login Monitor' + [string]$EmailSubject = 'RDP Login Monitor', + [hashtable]$SacDetailsExtra = @{} ) $title = if (-not [string]::IsNullOrWhiteSpace($SacTitle)) { $SacTitle } else { "RDP login monitor: $Lifecycle" } @@ -1104,6 +1107,9 @@ function Send-RdpMonitorLifecycleNotification { lifecycle = $Lifecycle trigger = $Trigger } + foreach ($dk in @($SacDetailsExtra.Keys)) { + $sacDetails[$dk] = $SacDetailsExtra[$dk] + } if (-not [string]::IsNullOrWhiteSpace($plainBody)) { $sacDetails.notification_body = $plainBody } @@ -2447,6 +2453,110 @@ function Send-RdpMonitorSacHeartbeat { } } +function Get-RdpMonitorSystemEventShortLabel { + param([int]$Id) + switch ($Id) { + 41 { return 'Kernel-Power: unexpected reboot' } + 1074 { return 'User32: planned shutdown/restart' } + 6005 { return 'EventLog: service started' } + 6008 { return 'EventLog: previous shutdown unexpected' } + 6009 { return 'EventLog: OS version at startup' } + default { return "System ID $Id" } + } +} + +function Get-RdpMonitorStartupCause { + param([int]$WindowMinutes = 5) + + if ($WindowMinutes -lt 1) { $WindowMinutes = 5 } + $now = Get-Date + $since = $now.AddMinutes(-$WindowMinutes) + $bootTime = $null + $uptimeMin = $null + try { + $os = Get-CimInstance Win32_OperatingSystem -ErrorAction Stop + $bootTime = $os.LastBootUpTime + if ($null -ne $bootTime) { + $uptimeMin = ($now - $bootTime).TotalMinutes + } + } catch { + Write-Log "WARN: StartupCause: Win32_OperatingSystem недоступен: $($_.Exception.Message)" + } + + $rebootEventIds = @(41, 1074, 6005, 6008, 6009) + $events = @() + $eventQueryStart = $since + # После ребута монитор может стартовать позже окна (5 мин): тогда берём System с LastBootUpTime (до 60 мин). + if ($null -ne $bootTime -and $null -ne $uptimeMin -and $uptimeMin -le 60 -and $bootTime -lt $eventQueryStart) { + $eventQueryStart = $bootTime + } + try { + $events = @(Get-WinEvent -FilterHashtable @{ + LogName = 'System' + Id = $rebootEventIds + StartTime = $eventQueryStart + } -MaxEvents 15 -ErrorAction Stop | Sort-Object TimeCreated) + } catch { + if ($_.Exception -and $_.Exception.Message -notmatch 'No events were found|NoMatchingEventsFound') { + Write-Log "WARN: StartupCause: чтение журнала System: $($_.Exception.Message)" + } + } + + $bootRecent = ($null -ne $uptimeMin -and $uptimeMin -le $WindowMinutes) + $hasRebootEvents = ($events.Count -gt 0) + $bootEventsRecent = ($hasRebootEvents -and $null -ne $uptimeMin -and $uptimeMin -le 60) + + if ($bootRecent -or $bootEventsRecent) { + return [pscustomobject]@{ + Cause = 'os_reboot' + WindowMinutes = $WindowMinutes + BootTime = $bootTime + UptimeMinutes = if ($null -ne $uptimeMin) { [math]::Round($uptimeMin, 1) } else { $null } + SystemEvents = @($events | ForEach-Object { + [pscustomobject]@{ + TimeCreated = $_.TimeCreated + Id = $_.Id + ProviderName = $_.ProviderName + MessageShort = (Get-RdpMonitorSystemEventShortLabel -Id $_.Id) + } + }) + } + } + + return [pscustomobject]@{ + Cause = 'process_start' + WindowMinutes = $WindowMinutes + BootTime = $bootTime + UptimeMinutes = if ($null -ne $uptimeMin) { [math]::Round($uptimeMin, 1) } else { $null } + SystemEvents = @() + } +} + +function Format-RdpMonitorStartupCauseLogLine { + param([Parameter(Mandatory = $true)]$StartupCause) + + if ($StartupCause.Cause -eq 'os_reboot') { + $parts = [System.Collections.Generic.List[string]]::new() + [void]$parts.Add('Старт монитора после перезагрузки ОС') + if ($null -ne $StartupCause.UptimeMinutes) { + [void]$parts.Add("uptime $($StartupCause.UptimeMinutes) мин") + } + if ($StartupCause.BootTime) { + [void]$parts.Add("LastBootUpTime=$($StartupCause.BootTime.ToString('dd.MM.yyyy HH:mm:ss'))") + } + if ($StartupCause.SystemEvents.Count -gt 0) { + $evSumm = ($StartupCause.SystemEvents | ForEach-Object { + "$($_.TimeCreated.ToString('dd.MM.yyyy HH:mm:ss')) ID$($_.Id) ($($_.MessageShort))" + }) -join '; ' + [void]$parts.Add("System: $evSumm") + } + return ($parts -join '; ') + '.' + } + + $uptimeStr = if ($null -ne $StartupCause.UptimeMinutes) { "$($StartupCause.UptimeMinutes) мин" } else { '?' } + return "Старт монитора: перезагрузка ОС за последние $($StartupCause.WindowMinutes) мин не обнаружена (uptime $uptimeStr) — перезапуск процесса или задачи планировщика." +} + function Send-Heartbeat { param([switch]$IsStartup = $false) @@ -2492,6 +2602,14 @@ function Send-Heartbeat { } if ($IsStartup) { + $windowMin = 5 + if (Get-Variable -Name StartupRebootDetectMinutes -Scope Script -ErrorAction SilentlyContinue) { + $w = (Get-Variable -Name StartupRebootDetectMinutes -Scope Script -ValueOnly) + if ($null -ne $w -and [int]$w -gt 0) { $windowMin = [int]$w } + } + $startupCause = Get-RdpMonitorStartupCause -WindowMinutes $windowMin + Write-Log (Format-RdpMonitorStartupCauseLogLine -StartupCause $startupCause) + $message = "✅ Мониторинг логинов ЗАПУЩЕН`r`n" $message += "🏷️ Версия скрипта: $(ConvertTo-TelegramHtml $ScriptVersion)" $upd = Get-DeployUpdateMarker @@ -2500,10 +2618,18 @@ function Send-Heartbeat { $message += " (обновлён $(ConvertTo-TelegramHtml $upd.UpdatedAt))" $lifecycleTrigger = 'deploy_recycle' Set-DeployUpdateMarkerPendingOff -Marker $upd + } elseif ($startupCause.Cause -eq 'os_reboot') { + $lifecycleTrigger = 'os_reboot' + } elseif ($startupCause.Cause -eq 'process_start') { + $lifecycleTrigger = 'process_start' } $message += "`r`n" $message += "🖥️ Сервер: $hHost`r`n" $message += "🕐 Время запуска: $timestamp" + if ($startupCause.Cause -eq 'os_reboot') { + $causePlain = Format-RdpMonitorStartupCauseLogLine -StartupCause $startupCause + $message += "`r`n🔄 $(ConvertTo-TelegramHtml $causePlain)" + } if ($script:OsInstallKindLabel) { $message += "`r`n💻 Тип установки: $(ConvertTo-TelegramHtml $script:OsInstallKindLabel)" } @@ -2554,10 +2680,28 @@ function Send-Heartbeat { $message += " IP из IIS не заданы (ExchangeIisLogPath пуст)." } } + $sacStartupExtra = @{ + startup_cause = $startupCause.Cause + } + if ($null -ne $startupCause.UptimeMinutes) { + $sacStartupExtra.uptime_minutes = $startupCause.UptimeMinutes + } + if ($startupCause.BootTime) { + $sacStartupExtra.last_boot_up_time = $startupCause.BootTime.ToString('o') + } + if ($startupCause.SystemEvents.Count -gt 0) { + $sacStartupExtra.system_event_ids = @($startupCause.SystemEvents | ForEach-Object { $_.Id }) -join ',' + } + Send-RdpMonitorLifecycleNotification -Lifecycle 'started' -Trigger $lifecycleTrigger ` -TelegramHtmlMessage $message -EmailSubject 'RDP Login Monitor: запуск' ` -SacTitle 'RDP login monitor started' ` - -SacSummary "Мониторинг запущен на $(Get-MonitorServerLabelWithIp), версия $ScriptVersion" + -SacSummary $(if ($startupCause.Cause -eq 'os_reboot') { + "Мониторинг запущен после перезагрузки ОС на $(Get-MonitorServerLabelWithIp), версия $ScriptVersion" + } else { + "Мониторинг запущен на $(Get-MonitorServerLabelWithIp), версия $ScriptVersion" + }) ` + -SacDetailsExtra $sacStartupExtra Write-Log "Отправлено уведомление о запуске скрипта (каналы: $notifyChain)" Send-RdpMonitorSacHeartbeat -Timestamp $timestamp } else { diff --git a/login_monitor.settings.example.ps1 b/login_monitor.settings.example.ps1 index b57cf5c..e348d68 100644 --- a/login_monitor.settings.example.ps1 +++ b/login_monitor.settings.example.ps1 @@ -47,6 +47,9 @@ $DailyReportEnabled = 1 # --- Heartbeat SAC (agent.heartbeat): интервал в секундах; 14400 = 4 ч --- $HeartbeatInterval = 14400 +# Окно (мин): LastBootUpTime + System 41/1074/6005/6008/6009 → «старт после перезагрузки ОС» +$StartupRebootDetectMinutes = 5 + # --- Инвентаризация железа/ПО для SAC (agent.inventory, раз в 12 ч) --- $GetInventory = $true diff --git a/version.txt b/version.txt index e17fcc9..7499e98 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -2.0.36-SAC +2.0.37-SAC