From d23e99a885d5a6836521ca5b6905fd74a46813de Mon Sep 17 00:00:00 2001 From: PTah Date: Fri, 29 May 2026 17:18:02 +1000 Subject: [PATCH] feat: unify Windows daily report with SAC layout; DailyReportEnabled toggle Co-authored-by: Cursor --- Login_Monitor.ps1 | 234 +++++++++++++++++++++++------ login_monitor.settings.example.ps1 | 2 + version.txt | 2 +- 3 files changed, 189 insertions(+), 49 deletions(-) diff --git a/Login_Monitor.ps1 b/Login_Monitor.ps1 index c7a7eb4..0a538fb 100644 --- a/Login_Monitor.ps1 +++ b/Login_Monitor.ps1 @@ -80,7 +80,7 @@ $script:MonitorLoopInitialized = $false # строки ниже, если правки «мелкие» и вы не хотите менять отображаемую версию в логах). # Рекомендация: при значимых релизах меняйте и $ScriptVersion, и version.txt одинаково; при только # исправлениях на шаре — достаточно поднять patch в version.txt (например 1.3.0.1). -$ScriptVersion = "1.2.20-SAC" +$ScriptVersion = "1.2.21-SAC" # Логи (все под InstallRoot) $LogFile = Join-Path $script:InstallRoot "Logs\login_monitor.log" @@ -106,6 +106,8 @@ $script:IgnoreListCacheStampUtc = $null # Ежедневный отчет $DailyReportHour = 9 $DailyReportMinute = 0 +# $false = не слать report.daily.rdp с агента (суточный отчёт только из SAC) +$DailyReportEnabled = $true $LastReportFile = Join-Path $script:InstallRoot "Logs\last_daily_report.txt" # RD Gateway @@ -1256,6 +1258,153 @@ function Get-MonitorServerLabel { return [string]$env:COMPUTERNAME } +function Get-MonitorServerLabelWithIp { + $label = Get-MonitorServerLabel + $ip = '' + if (Get-Command -Name Get-SacHostIPv4 -ErrorAction SilentlyContinue) { + $ip = [string](Get-SacHostIPv4) + } + if (-not [string]::IsNullOrWhiteSpace($ip)) { + return '{0} ({1})' -f $label, $ip.Trim() + } + return $label +} + +function Get-InteractiveLogonTypesForDailyReport { + if ($script:IsWorkstation) { return @(10) } + return @(2, 3, 10) +} + +function Get-DailyReportAuthStats24h { + $since = (Get-Date).AddHours(-24) + $interactive = @(Get-InteractiveLogonTypesForDailyReport) + $ok = 0 + $fail = 0 + $failedByIp = @{} + try { + $events = @(Get-WinEvent -FilterHashtable @{ + LogName = 'Security' + Id = 4624, 4625 + StartTime = $since + } -ErrorAction Stop) + } catch { + Write-Log "Daily report: Security log unavailable: $($_.Exception.Message)" + return @{ + rdp_success = 0 + rdp_failed = 0 + active_bans = 0 + top_failed_ips = @() + } + } + foreach ($ev in $events) { + $info = Get-LoginEventInfo -Event $ev + if (Should-IgnoreEvent -Username $info.Username -ProcessName $info.ProcessName ` + -ComputerName $info.ComputerName -EventID $ev.Id -LogonType $info.LogonType -SourceIP $info.SourceIP) { + continue + } + if ($interactive -notcontains $info.LogonType) { continue } + if ($ev.Id -eq 4624) { + $ok++ + } else { + $fail++ + $ip = [string]$info.SourceIP + if (-not [string]::IsNullOrWhiteSpace($ip) -and $ip -ne '-') { + if (-not $failedByIp.ContainsKey($ip)) { $failedByIp[$ip] = 0 } + $failedByIp[$ip]++ + } + } + } + $top = @( + $failedByIp.GetEnumerator() | + Sort-Object -Property Value -Descending | + Select-Object -First 5 | + ForEach-Object { '{0} — {1}' -f $_.Key, $_.Value } + ) + return @{ + rdp_success = $ok + rdp_failed = $fail + active_bans = 0 + top_failed_ips = $top + } +} + +function Get-DailyReportActiveUsersFromQuser { + $lines = [System.Collections.Generic.List[string]]::new() + $quserExe = Join-Path $env:SystemRoot 'System32\quser.exe' + if (-not (Test-Path -LiteralPath $quserExe)) { + return ,$lines + } + $quserOutput = @(& $quserExe 2>$null) + if (-not $quserOutput -or $quserOutput.Count -le 1) { + return ,$lines + } + foreach ($raw in @($quserOutput | Select-Object -Skip 1)) { + $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 ([string]::IsNullOrWhiteSpace($u) -or $u -eq 'USERNAME') { continue } + $lines.Add(('👤 {0}' -f $u)) | Out-Null + } + return ,$lines +} + +function Build-DailyReportPlainBodyWindows { + param( + [hashtable]$Stats, + [string[]]$ActiveUsers, + [datetime]$ReportTime + ) + $server = Get-MonitorServerLabelWithIp + $timeStr = $ReportTime.ToString('dd.MM.yyyy HH:mm:ss') + $ok = [int]$Stats.rdp_success + $fail = [int]$Stats.rdp_failed + $bans = [int]$Stats.active_bans + $top = @($Stats.top_failed_ips) + $sb = [System.Text.StringBuilder]::new() + [void]$sb.AppendLine('📊 ЕЖЕДНЕВНЫЙ ОТЧЕТ МОНИТОРИНГА WINDOWS') + [void]$sb.AppendLine("🖥️ Сервер: $server") + [void]$sb.AppendLine("🕐 Время отчета: $timeStr") + [void]$sb.AppendLine('') + [void]$sb.AppendLine('📈 СТАТИСТИКА ЗА ПОСЛЕДНИЕ 24 ЧАСА:') + [void]$sb.AppendLine(" ✅ Успешных RDP подключений: $ok") + [void]$sb.AppendLine(" ❌ Неудачных попыток RDP: $fail") + [void]$sb.AppendLine(" 🚫 Активных банов: $bans") + [void]$sb.AppendLine('') + [void]$sb.AppendLine('🧾 ТОП-5 IP ПО НЕУДАЧНЫМ ПОПЫТКАМ:') + if ($top.Count -gt 0) { + foreach ($row in $top) { [void]$sb.AppendLine(" $row") } + } else { + [void]$sb.AppendLine(' (нет данных)') + } + [void]$sb.AppendLine('') + $userCount = $ActiveUsers.Count + [void]$sb.AppendLine("👥 АКТИВНЫЕ ПОЛЬЗОВАТЕЛИ ($userCount):") + if ($userCount -gt 0) { + foreach ($u in $ActiveUsers) { [void]$sb.AppendLine(" $u") } + } else { + [void]$sb.AppendLine(' (нет данных)') + } + return $sb.ToString().TrimEnd() +} + +function Convert-DailyReportPlainToTelegramHtml { + param([string]$PlainBody) + $lines = $PlainBody -split "`r?`n" + $out = [System.Collections.Generic.List[string]]::new() + foreach ($line in $lines) { + if ($line -match '^📊') { + $out.Add(('{0}' -f [System.Net.WebUtility]::HtmlEncode($line))) | Out-Null + } elseif ($line.Length -gt 0) { + $out.Add([System.Net.WebUtility]::HtmlEncode($line)) | Out-Null + } else { + $out.Add('') | Out-Null + } + } + return ($out -join "`r`n") +} + function Send-Heartbeat { param([switch]$IsStartup = $false) @@ -2342,58 +2491,44 @@ function Format-RDGatewayEvent { function Send-DailyReport { try { - $quserExe = Join-Path $env:SystemRoot 'System32\quser.exe' - $quserOutput = if (Test-Path -LiteralPath $quserExe) { - @(& $quserExe 2>$null) - } else { - Write-Log "quser.exe not found: $quserExe" - @() - } - $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 = "📊 ЕЖЕДНЕВНЫЙ ОТЧЕТ`r`n" - $message += "🖥️ Сервер: $(ConvertTo-TelegramHtml (Get-MonitorServerLabel))`r`n" - $message += "🕐 Время отчета: $(Get-Date -Format 'dd.MM.yyyy HH:mm:ss')`r`n" - $message += "👥 Активных сессий (quser): $count`r`n" - if ($uniqueUsers.Count -gt 0) { - $message += "`r`nУникальные логины ($($uniqueUsers.Count)):`r`n" - foreach ($name in $uniqueUsers) { - $safe = [System.Net.WebUtility]::HtmlEncode($name) - $message += " • $safe`r`n" - } - } else { - $message += "`r`nСписок пользователей недоступен (quser пуст или недостаточно прав)." + $reportTime = Get-Date + $stats = Get-DailyReportAuthStats24h + $activeUsers = @(Get-DailyReportActiveUsersFromQuser) + $plainBody = Build-DailyReportPlainBodyWindows -Stats $stats -ActiveUsers $activeUsers -ReportTime $reportTime + $telegramMessage = Convert-DailyReportPlainToTelegramHtml -PlainBody $plainBody + + $uniqueUsers = @( + $activeUsers | ForEach-Object { + ($_ -replace '^👤\s*', '').Trim() + } | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | Sort-Object -Unique + ) + $statsForSac = @{ + platform = 'windows' + successful_logins = [int]$stats.rdp_success + failed_logins = [int]$stats.rdp_failed + rdp_success = [int]$stats.rdp_success + rdp_failed = [int]$stats.rdp_failed + active_bans = [int]$stats.active_bans + top_failed_ips = @($stats.top_failed_ips) + active_users = @($activeUsers) + unique_users = @($uniqueUsers) + active_sessions_rdp = $activeUsers.Count + generated_by = 'agent' } + $escaped = [System.Net.WebUtility]::HtmlEncode($plainBody) + $reportHtml = '
' + ($escaped -replace "`r?`n", '
') + '
' $rdpReportDetails = @{ - stats = @{ - active_sessions_rdp = $count - unique_users = @($uniqueUsers) - } - report_body = (Get-PlainSummaryFromTelegramHtml -Message $message) - report_html = "
$message
" - report_format = 'telegram_html' + stats = $statsForSac + report_body = $plainBody + report_html = $reportHtml + report_format = 'plain' } - Send-MonitorNotification -Message $message -EmailSubject "RDP Login Monitor: ежедневный отчёт" ` + Send-MonitorNotification -Message $telegramMessage -EmailSubject "RDP Login Monitor: ежедневный отчёт" ` -SacEventType 'report.daily.rdp' -SacSeverity 'info' ` - -SacTitle 'RDP daily report' ` - -SacSummary "RDP 24ч: сессий $count, логинов $($uniqueUsers.Count)" ` + -SacTitle 'Ежедневный отчёт Windows' ` + -SacSummary "RDP 24ч: успех $($stats.rdp_success), неудач $($stats.rdp_failed), банов $($stats.active_bans)" ` -SacDetails $rdpReportDetails | Out-Null - Write-TextFileUtf8Bom -Path $LastReportFile -Text ((Get-Date).ToString("yyyy-MM-dd HH:mm:ss")) + Write-TextFileUtf8Bom -Path $LastReportFile -Text ($reportTime.ToString('yyyy-MM-dd HH:mm:ss')) Write-Log "Ежедневный отчет отправлен" return $true } catch { @@ -2403,6 +2538,9 @@ function Send-DailyReport { } function Check-AndSendDailyReport { + if (-not $DailyReportEnabled) { + return (Get-NextLocalSlotBoundary -Hour $DailyReportHour -Minute $DailyReportMinute) + } $lastReport = $null if (Test-Path $LastReportFile) { $txt = Get-Content $LastReportFile -ErrorAction SilentlyContinue diff --git a/login_monitor.settings.example.ps1 b/login_monitor.settings.example.ps1 index 278d1eb..bbd876e 100644 --- a/login_monitor.settings.example.ps1 +++ b/login_monitor.settings.example.ps1 @@ -41,6 +41,8 @@ $SacApiKey = 'sac_UkOsAT3UWiQS54KK5OJPBDCSucysQDrKFju28wmYiz8' # $SacTimeoutSec = 12 # $SacTlsSkipVerify = $false # $SacFallbackFailures = 5 +# $false = не слать report.daily.rdp с агента (суточный отчёт только из SAC) +# $DailyReportEnabled = $true # Проверка: powershell -File Login_Monitor.ps1 -CheckSac # --- Узкое исключение шумовых сетевых логонов (LogonType=3, Advapi) --- diff --git a/version.txt b/version.txt index c789d67..b3568b8 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -1.2.20-SAC +1.2.21-SAC