Compare commits

...

19 Commits

Author SHA1 Message Date
PapaTramp c67bd2595c chore(private): sync production secrets and paths for papatramp mirror
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-07 17:43:27 +10:00
PapaTramp f033d5bd87 fix: daily report без локального Telegram при fallback SAC (2.1.9-SAC)
Аналог ssh-monitor: report.daily.* только SAC/spool, timeout 45s,
spool flush приоритет отчётам (50 файлов/цикл).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-06 12:00:36 +10:00
PapaTramp f2bcc6c7df fix: restore production secrets on kalinamall and repair placeholder settings (2.1.8-SAC)
Real SAC/Telegram tokens and org paths back in example and NETLOGON publish script. Deploy now treats sac.example.com and sac_CHANGE_ME as unconfigured and overwrites from example.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-25 14:32:44 +10:00
PapaTramp 5708e42c66 fix: dedup RDG 302 notify by user+target+external IP (2.1.7-SAC)
Suppress duplicate SAC alerts when Windows logs two RD Gateway 302 events
within 90s for the same user, target host, and external client IP.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-25 10:03:26 +10:00
PapaTramp 8a937b8f17 fix: quarantine corrupt SAC spool files, flush newest first (2.1.6-SAC)
Reject null-padded spool payloads to rejected/, read UTF-16 spool, process recent events first, and raise flush batch to 20 so daily reports are not blocked by legacy junk.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-25 09:09:54 +10:00
PapaTramp ccd50a085d fix: RCM 1149 silent drop on RDS servers (2.1.5-SAC)
Should-IgnoreEvent rejected every 1149 because ComputerName is always '-'; add persistent poll cursor, skip logging, and Diagnose on NETLOGON publish list.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-24 14:29:02 +10:00
PapaTramp 55d58b25a3 docs: sync README version lines with 2.1.4-SAC
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-24 14:14:43 +10:00
PapaTramp 4b8e3313ae docs: document -NetlogonDest UNC path for update-rdp-monitor.ps1
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-24 09:21:17 +10:00
PapaTramp ab53d47329 feat: RCM 1149 on RDS servers and login dedup (2.1.4-SAC)
Enable Rcm1149 monitoring on servers when RCM journal is available; dedup 1149 notifications like 4624.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-23 15:01:09 +10:00
PapaTramp eecc0e1619 docs(deploy): clarify DailyReportEnabled comment in settings patch
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-23 14:41:53 +10:00
PapaTramp 128dd278f6 feat(deploy): sync ServerIPv4 hint and DailyReportEnabled on upgrade (2.1.3-SAC)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-22 10:01:16 +10:00
PapaTramp 8cefba1952 Merge branch 'main' of ssh://git.kalinamall.ru:2222/PapaTramp/RDP-login-monitor 2026-06-22 09:04:36 +10:00
PapaTramp b4dd593160 docs: SAC WinRM update path without NETLOGON
Добавлен раздел об обновлении RDP через SAC: zip с сервера, staging и Deploy-LoginMonitor.ps1.
2026-06-20 20:03:59 +10:00
PapaTramp f3a0adb7b8 fix: remove UTF-8 BOM from PowerShell entry scripts 2026-06-20 19:07:52 +10:00
PapaTramp a0b473b4d7 docs: concise README for 2.1.2-SAC capabilities 2026-06-20 19:02:59 +10:00
PapaTramp 2c1724fd90 fix: SAC poll URL interpolation in Sac-Client (2.1.2-SAC) 2026-06-19 23:49:44 +10:00
PapaTramp a9ca50bc15 fix: default HeartbeatStaleAlertMultiplier (2.1.1-SAC)
Startup notification failed when settings lacked the variable; default 2 in script and deploy bootstrap.
2026-06-19 23:47:48 +10:00
PapaTramp fb50fb3ea7 chore: bump version to 2.1.0-SAC 2026-06-19 23:42:08 +10:00
PapaTramp 5d2b90779c feat: SAC agent command poll qwinsta/logoff (2.0.39-SAC)
Poll GET /api/v1/agent/commands every SacCommandPollIntervalSec;
execute qwinsta/logoff with run_as from SAC; POST result.
2026-06-19 23:37:58 +10:00
11 changed files with 837 additions and 287 deletions
+149 -23
View File
@@ -260,11 +260,25 @@ function Copy-RdpMonitorDeployBundle {
} }
} }
function Test-RdpMonitorSettingsHasPlaceholderSecrets {
param([string]$SettingsPath)
$c = Get-RdpMonitorSettingsRaw -Path $SettingsPath
if ([string]::IsNullOrWhiteSpace($c)) { return $true }
if ($c -match '(?m)^\s*\$SacUrl\s*=\s*[''"]https?://[^''"]*example\.com[^''"]*[''"]') { return $true }
if ($c -match '(?m)^\s*\$SacApiKey\s*=\s*[''"]sac_CHANGE_ME[''"]') { return $true }
if ($c -match '(?m)^\s*\$TelegramBotToken\s*=\s*[''"]YOUR_BOT_TOKEN[''"]') { return $true }
if ($c -match '(?m)^\s*\$TelegramChatID\s*=\s*[''"]YOUR_CHAT_ID[''"]') { return $true }
return $false
}
function Test-RdpMonitorSettingsNeedsSacBootstrap { function Test-RdpMonitorSettingsNeedsSacBootstrap {
param([string]$SettingsPath) param([string]$SettingsPath)
$c = Get-RdpMonitorSettingsRaw -Path $SettingsPath $c = Get-RdpMonitorSettingsRaw -Path $SettingsPath
if ([string]::IsNullOrWhiteSpace($c)) { return $true } if ([string]::IsNullOrWhiteSpace($c)) { return $true }
if (Test-RdpMonitorSettingsHasPlaceholderSecrets -SettingsPath $SettingsPath) { return $true }
if ($c -notmatch '(?m)^\s*\$UseSAC\s*=') { return $true } if ($c -notmatch '(?m)^\s*\$UseSAC\s*=') { return $true }
if ($c -match '(?m)^\s*\$UseSAC\s*=\s*[''"]off[''"]') { if ($c -match '(?m)^\s*\$UseSAC\s*=\s*[''"]off[''"]') {
if ($c -notmatch '(?m)^\s*\$SacApiKey\s*=\s*[''"]sac_[^''"]+[''"]') { return $true } if ($c -notmatch '(?m)^\s*\$SacApiKey\s*=\s*[''"]sac_[^''"]+[''"]') { return $true }
@@ -285,12 +299,24 @@ function Test-RdpMonitorSettingsNeedsServerDisplayNameHint {
return $true return $true
} }
function Test-RdpMonitorSettingsNeedsDailyReportHint { function Test-RdpMonitorSettingsDailyReportEnabledIsTrue {
param([string]$SettingsPath) param([string]$SettingsPath)
if (-not (Test-Path -LiteralPath $SettingsPath)) { return $false } if (-not (Test-Path -LiteralPath $SettingsPath)) { return $false }
$c = Get-RdpMonitorSettingsRaw -Path $SettingsPath $c = Get-RdpMonitorSettingsRaw -Path $SettingsPath
if ([string]::IsNullOrWhiteSpace($c)) { return $false } if ([string]::IsNullOrWhiteSpace($c)) { return $false }
if ($c -match '(?m)^\s*(\#\s*)?\$DailyReportEnabled\s*=') { return $false } if ($c -match '(?m)^\s*\$DailyReportEnabled\s*=\s*\$(?:true)\b') { return $true }
if ($c -match '(?m)^\s*\$DailyReportEnabled\s*=\s*1\s*(?:#.*)?$') { return $true }
return $false
}
function Test-RdpMonitorSettingsNeedsDailyReportHint {
param([string]$SettingsPath)
if (-not (Test-Path -LiteralPath $SettingsPath)) { return $true }
$c = Get-RdpMonitorSettingsRaw -Path $SettingsPath
if ([string]::IsNullOrWhiteSpace($c)) { return $true }
if (Test-RdpMonitorSettingsDailyReportEnabledIsTrue -SettingsPath $SettingsPath) { return $false }
if (Test-RdpMonitorSettingsHasInvalidDailyReportAssignment -SettingsPath $SettingsPath) { return $true }
if ($c -match '(?m)^\s*(\#\s*)?\$DailyReportEnabled\s*=') { return $true }
return $true return $true
} }
@@ -327,7 +353,7 @@ function Repair-RdpMonitorSettingsDailyReportAssignmentIfInvalid {
return $true return $true
} }
function Update-RdpMonitorSettingsDailyReportHintIfMissing { function Sync-RdpMonitorSettingsDailyReportEnabledToTrue {
param([string]$LocalSettings) param([string]$LocalSettings)
if (-not (Test-Path -LiteralPath $LocalSettings)) { return $false } if (-not (Test-Path -LiteralPath $LocalSettings)) { return $false }
if (-not (Test-RdpMonitorSettingsNeedsDailyReportHint -SettingsPath $LocalSettings)) { if (-not (Test-RdpMonitorSettingsNeedsDailyReportHint -SettingsPath $LocalSettings)) {
@@ -337,24 +363,35 @@ function Update-RdpMonitorSettingsDailyReportHintIfMissing {
$c = Get-RdpMonitorSettingsRaw -Path $LocalSettings $c = Get-RdpMonitorSettingsRaw -Path $LocalSettings
if ([string]::IsNullOrWhiteSpace($c)) { return $false } if ([string]::IsNullOrWhiteSpace($c)) { return $false }
$dailyLine = '$DailyReportEnabled = $true # отчёт с агента (report.daily.rdp); $false или 0 — только SAC'
$hintBlock = @( $hintBlock = @(
'# --- Суточный отчёт report.daily.rdp (агент или только SAC) ---' '# --- Суточный отчёт report.daily.rdp (агент или только SAC) ---'
'# $DailyReportEnabled = $false # по умолчанию: только SAC; $true или 1 — отчёт с агента' $dailyLine
'# Не пишите "= false" без $ — PowerShell воспримет false как команду.' '# Не пишите "= false" без $ — PowerShell воспримет false как команду.'
) -join "`r`n" ) -join "`r`n"
$bak = "$LocalSettings.bak.$(Get-Date -Format 'yyyyMMdd_HHmmss')" $bak = "$LocalSettings.bak.$(Get-Date -Format 'yyyyMMdd_HHmmss')"
Copy-Item -LiteralPath $LocalSettings -Destination $bak -Force Copy-Item -LiteralPath $LocalSettings -Destination $bak -Force
Write-DeployLog "Добавление закомментированного `$DailyReportEnabled в login_monitor.settings.ps1; резервная копия: $bak"
$insertBefore = '(?m)^\s*#\s*---\s*Security Alert Center' if ($c -match '(?m)^\s*(\#\s*)?\$DailyReportEnabled\s*=') {
if ($c -match $insertBefore) { Write-DeployLog "login_monitor.settings.ps1: `$DailyReportEnabled → `$true (резервная копия: $bak)"
$newContent = [regex]::Replace($c, $insertBefore, ($hintBlock + "`r`n`r`n" + '$0'), 1) $newContent = [regex]::Replace(
$c,
'(?m)^\s*(\#\s*)?\$DailyReportEnabled\s*=.*$',
$dailyLine,
1
)
} else { } else {
$newContent = ($c.TrimEnd() + "`r`n`r`n" + $hintBlock + "`r`n") Write-DeployLog "login_monitor.settings.ps1: добавлен `$DailyReportEnabled = `$true (резервная копия: $bak)"
$insertBefore = '(?m)^\s*#\s*---\s*Security Alert Center'
if ($c -match $insertBefore) {
$newContent = [regex]::Replace($c, $insertBefore, ($hintBlock + "`r`n`r`n" + '$0'), 1)
} else {
$newContent = ($c.TrimEnd() + "`r`n`r`n" + $hintBlock + "`r`n")
}
} }
[System.IO.File]::WriteAllText($LocalSettings, $newContent.TrimEnd() + "`r`n", $Utf8Bom) [System.IO.File]::WriteAllText($LocalSettings, $newContent.TrimEnd() + "`r`n", $Utf8Bom)
Write-DeployLog 'login_monitor.settings.ps1: добавлена подсказка # $DailyReportEnabled = $false'
return $true return $true
} }
@@ -365,7 +402,7 @@ function Sync-RdpMonitorSettingsDailyReportPatches {
if (Repair-RdpMonitorSettingsDailyReportAssignmentIfInvalid -LocalSettings $LocalSettings) { if (Repair-RdpMonitorSettingsDailyReportAssignmentIfInvalid -LocalSettings $LocalSettings) {
$changed = $true $changed = $true
} }
if (Update-RdpMonitorSettingsDailyReportHintIfMissing -LocalSettings $LocalSettings) { if (Sync-RdpMonitorSettingsDailyReportEnabledToTrue -LocalSettings $LocalSettings) {
$changed = $true $changed = $true
} }
return $changed return $changed
@@ -682,6 +719,49 @@ function Sync-RdpMonitorSettingsHeartbeatInterval {
return $true return $true
} }
function Test-RdpMonitorSettingsNeedsHeartbeatStaleAlertMultiplier {
param([string]$SettingsPath)
if (-not (Test-Path -LiteralPath $SettingsPath)) { return $true }
$c = Get-RdpMonitorSettingsRaw -Path $SettingsPath
if ([string]::IsNullOrWhiteSpace($c)) { return $true }
return ($c -notmatch '(?m)^\s*\$HeartbeatStaleAlertMultiplier\s*=\s*\d+')
}
function Sync-RdpMonitorSettingsHeartbeatStaleAlertMultiplierIfMissing {
param(
[string]$LocalSettings,
[int]$TargetMultiplier = 2
)
if (-not (Test-RdpMonitorSettingsNeedsHeartbeatStaleAlertMultiplier -SettingsPath $LocalSettings)) {
return $false
}
if (-not (Test-Path -LiteralPath $LocalSettings)) { return $false }
$c = Get-RdpMonitorSettingsRaw -Path $LocalSettings
$block = @(
'# --- Heartbeat stale alert: множитель × HeartbeatInterval ---'
"`$HeartbeatStaleAlertMultiplier = $TargetMultiplier"
) -join "`r`n"
$bak = "$LocalSettings.bak.$(Get-Date -Format 'yyyyMMdd_HHmmss')"
Copy-Item -LiteralPath $LocalSettings -Destination $bak -Force
$insertAfterHeartbeat = '(?m)^(\s*\$HeartbeatInterval\s*=\s*\d+\s*)$'
if (-not [string]::IsNullOrWhiteSpace($c) -and $c -match $insertAfterHeartbeat) {
$newContent = [regex]::Replace($c, $insertAfterHeartbeat, ('$1' + "`r`n" + $block), 1)
} elseif (-not [string]::IsNullOrWhiteSpace($c)) {
$newContent = ($c.TrimEnd() + "`r`n`r`n" + $block + "`r`n")
} else {
$newContent = ($block + "`r`n")
}
[System.IO.File]::WriteAllText($LocalSettings, $newContent.TrimEnd() + "`r`n", $Utf8Bom)
Write-DeployLog "login_monitor.settings.ps1: добавлен `$HeartbeatStaleAlertMultiplier = $TargetMultiplier (резервная копия: $bak)"
return $true
}
function Test-RdpMonitorSettingsNeedsStartupRebootDetectMinutes { function Test-RdpMonitorSettingsNeedsStartupRebootDetectMinutes {
param([string]$SettingsPath) param([string]$SettingsPath)
if (-not (Test-Path -LiteralPath $SettingsPath)) { return $true } if (-not (Test-Path -LiteralPath $SettingsPath)) { return $true }
@@ -730,12 +810,14 @@ function Invoke-RdpMonitorSettingsPostPatches {
if (-not (Test-Path -LiteralPath $LocalSettings)) { return $false } if (-not (Test-Path -LiteralPath $LocalSettings)) { return $false }
$changed = $false $changed = $false
if (Update-RdpMonitorSettingsServerDisplayNameHintIfMissing -LocalSettings $LocalSettings) { $changed = $true } if (Update-RdpMonitorSettingsServerDisplayNameHintIfMissing -LocalSettings $LocalSettings) { $changed = $true }
if (Update-RdpMonitorSettingsServerIPv4HintIfMissing -LocalSettings $LocalSettings) { $changed = $true }
if (Sync-RdpMonitorSettingsDailyReportPatches -LocalSettings $LocalSettings) { $changed = $true } if (Sync-RdpMonitorSettingsDailyReportPatches -LocalSettings $LocalSettings) { $changed = $true }
if (Sync-RdpMonitorSettingsExchangeNoisePatches -LocalSettings $LocalSettings) { $changed = $true } if (Sync-RdpMonitorSettingsExchangeNoisePatches -LocalSettings $LocalSettings) { $changed = $true }
if (Repair-RdpMonitorSettingsWinRmLinesIfInvalid -LocalSettings $LocalSettings) { $changed = $true } if (Repair-RdpMonitorSettingsWinRmLinesIfInvalid -LocalSettings $LocalSettings) { $changed = $true }
if (Sync-RdpMonitorSettingsWinRmInboundBlock -LocalSettings $LocalSettings) { $changed = $true } if (Sync-RdpMonitorSettingsWinRmInboundBlock -LocalSettings $LocalSettings) { $changed = $true }
if (Sync-RdpMonitorSettingsMaxBackupDaysIfMissing -LocalSettings $LocalSettings) { $changed = $true } if (Sync-RdpMonitorSettingsMaxBackupDaysIfMissing -LocalSettings $LocalSettings) { $changed = $true }
if (Sync-RdpMonitorSettingsGetInventoryIfMissing -LocalSettings $LocalSettings) { $changed = $true } if (Sync-RdpMonitorSettingsGetInventoryIfMissing -LocalSettings $LocalSettings) { $changed = $true }
if (Sync-RdpMonitorSettingsHeartbeatStaleAlertMultiplierIfMissing -LocalSettings $LocalSettings) { $changed = $true }
if (Sync-RdpMonitorSettingsStartupRebootDetectMinutesIfMissing -LocalSettings $LocalSettings) { $changed = $true } if (Sync-RdpMonitorSettingsStartupRebootDetectMinutesIfMissing -LocalSettings $LocalSettings) { $changed = $true }
return $changed return $changed
} }
@@ -842,8 +924,6 @@ function Update-RdpMonitorSettingsServerDisplayNameHintIfMissing {
$hintBlock = @( $hintBlock = @(
'# --- Подпись сервера в Telegram и SAC (host.display_name); раскомментируйте при необходимости ---' '# --- Подпись сервера в Telegram и SAC (host.display_name); раскомментируйте при необходимости ---'
"# `$ServerDisplayName = '$hostLabel'" "# `$ServerDisplayName = '$hostLabel'"
'# --- Явный IPv4 хоста для SAC (опционально; иначе автоопределение) ---'
'# $ServerIPv4 = '''
) -join "`r`n" ) -join "`r`n"
$bak = "$LocalSettings.bak.$(Get-Date -Format 'yyyyMMdd_HHmmss')" $bak = "$LocalSettings.bak.$(Get-Date -Format 'yyyyMMdd_HHmmss')"
@@ -861,6 +941,51 @@ function Update-RdpMonitorSettingsServerDisplayNameHintIfMissing {
return $true return $true
} }
function Test-RdpMonitorSettingsNeedsServerIPv4Hint {
param([string]$SettingsPath)
if (-not (Test-Path -LiteralPath $SettingsPath)) { return $false }
$c = Get-RdpMonitorSettingsRaw -Path $SettingsPath
if ([string]::IsNullOrWhiteSpace($c)) { return $false }
if ($c -match '(?m)^\s*(\#\s*)?\$ServerIPv4\s*=') { return $false }
return $true
}
function Update-RdpMonitorSettingsServerIPv4HintIfMissing {
param([string]$LocalSettings)
if (-not (Test-Path -LiteralPath $LocalSettings)) { return $false }
if (-not (Test-RdpMonitorSettingsNeedsServerIPv4Hint -SettingsPath $LocalSettings)) {
return $false
}
$c = Get-RdpMonitorSettingsRaw -Path $LocalSettings
if ([string]::IsNullOrWhiteSpace($c)) { return $false }
$hintBlock = @(
'# --- Явный IPv4 хоста для SAC (опционально; иначе автоопределение) ---'
"# `$ServerIPv4 = '10.0.0.10'"
) -join "`r`n"
$bak = "$LocalSettings.bak.$(Get-Date -Format 'yyyyMMdd_HHmmss')"
Copy-Item -LiteralPath $LocalSettings -Destination $bak -Force
Write-DeployLog "Добавление закомментированного `$ServerIPv4 в login_monitor.settings.ps1; резервная копия: $bak"
$insertAfterDisplay = '(?m)^(\s*(\#\s*)?\$ServerDisplayName\s*=.*)$'
if ($c -match $insertAfterDisplay) {
$newContent = [regex]::Replace($c, $insertAfterDisplay, ('$1' + "`r`n" + $hintBlock), 1)
} else {
$insertBefore = '(?m)^\s*#\s*---\s*Security Alert Center'
if ($c -match $insertBefore) {
$newContent = [regex]::Replace($c, $insertBefore, ($hintBlock + "`r`n`r`n" + '$0'), 1)
} else {
$newContent = ($c.TrimEnd() + "`r`n`r`n" + $hintBlock + "`r`n")
}
}
[System.IO.File]::WriteAllText($LocalSettings, $newContent.TrimEnd() + "`r`n", $Utf8Bom)
Write-DeployLog "login_monitor.settings.ps1: добавлена подсказка # `$ServerIPv4 = '10.0.0.10'"
return $true
}
function Get-RdpMonitorSacBlockFromExample { function Get-RdpMonitorSacBlockFromExample {
param([string]$ExamplePath) param([string]$ExamplePath)
if (-not (Test-Path -LiteralPath $ExamplePath)) { return $null } if (-not (Test-Path -LiteralPath $ExamplePath)) { return $null }
@@ -869,12 +994,8 @@ function Get-RdpMonitorSacBlockFromExample {
if ($ex -match '(?ms)(#\s*---\s*Security Alert Center.*?)(?=\r?\n#\s*---|\z)') { if ($ex -match '(?ms)(#\s*---\s*Security Alert Center.*?)(?=\r?\n#\s*---|\z)') {
return $Matches[1].TrimEnd() return $Matches[1].TrimEnd()
} }
return @( Write-DeployLog "Предупреждение: в example нет блока Security Alert Center — patch SAC пропущен."
'# --- Security Alert Center (SAC) ---' return $null
'$UseSAC = ''fallback'''
'$SacUrl = ''https://sac.example.com'''
'$SacApiKey = ''sac_CHANGE_ME'''
) -join "`r`n"
} }
function Sync-RdpMonitorUseSacFallbackMode { function Sync-RdpMonitorUseSacFallbackMode {
@@ -912,8 +1033,10 @@ function Update-RdpMonitorSettingsSacBlockIfMissing {
if ($c -match '(?m)^\s*\$UseSAC\s*=' -and $c -match '(?m)^\s*\$SacApiKey\s*=\s*[''"]sac_[^''"]+[''"]') { if ($c -match '(?m)^\s*\$UseSAC\s*=' -and $c -match '(?m)^\s*\$SacApiKey\s*=\s*[''"]sac_[^''"]+[''"]') {
if ($c -notmatch '(?m)^\s*\$UseSAC\s*=\s*[''"]off[''"]') { if ($c -notmatch '(?m)^\s*\$UseSAC\s*=\s*[''"]off[''"]') {
Write-DeployLog "login_monitor.settings.ps1: блок SAC уже задан, файл не меняем." if (-not (Test-RdpMonitorSettingsHasPlaceholderSecrets -SettingsPath $LocalSettings)) {
return $false Write-DeployLog "login_monitor.settings.ps1: блок SAC уже задан, файл не меняем."
return $false
}
} }
} }
@@ -1385,6 +1508,7 @@ try {
$needsSettingsBootstrap = Test-RdpMonitorSettingsNeedsSacBootstrap -SettingsPath $settingsLocal $needsSettingsBootstrap = Test-RdpMonitorSettingsNeedsSacBootstrap -SettingsPath $settingsLocal
$needsDisplayNameHint = Test-RdpMonitorSettingsNeedsServerDisplayNameHint -SettingsPath $settingsLocal $needsDisplayNameHint = Test-RdpMonitorSettingsNeedsServerDisplayNameHint -SettingsPath $settingsLocal
$needsServerIPv4Hint = Test-RdpMonitorSettingsNeedsServerIPv4Hint -SettingsPath $settingsLocal
$needsDailyReportHint = Test-RdpMonitorSettingsNeedsDailyReportHint -SettingsPath $settingsLocal $needsDailyReportHint = Test-RdpMonitorSettingsNeedsDailyReportHint -SettingsPath $settingsLocal
$needsDailyReportRepair = Test-RdpMonitorSettingsHasInvalidDailyReportAssignment -SettingsPath $settingsLocal $needsDailyReportRepair = Test-RdpMonitorSettingsHasInvalidDailyReportAssignment -SettingsPath $settingsLocal
$needsExchangeNoisePatch = Test-RdpMonitorSettingsNeedsExchangeNoisePatch -SettingsPath $settingsLocal $needsExchangeNoisePatch = Test-RdpMonitorSettingsNeedsExchangeNoisePatch -SettingsPath $settingsLocal
@@ -1393,7 +1517,7 @@ try {
$needsStartupRebootDetectMinutes = Test-RdpMonitorSettingsNeedsStartupRebootDetectMinutes -SettingsPath $settingsLocal $needsStartupRebootDetectMinutes = Test-RdpMonitorSettingsNeedsStartupRebootDetectMinutes -SettingsPath $settingsLocal
$needsBundleSync = Test-RdpMonitorDeployBundleNeedsSync -ShareRoot $shareRoot $needsBundleSync = Test-RdpMonitorDeployBundleNeedsSync -ShareRoot $shareRoot
$needsTaskExecutionLimitFix = Test-RdpMonitorDeployMainTaskNeedsUnlimitedExecutionTime -ShareRoot $shareRoot $needsTaskExecutionLimitFix = Test-RdpMonitorDeployMainTaskNeedsUnlimitedExecutionTime -ShareRoot $shareRoot
$needsSacBootstrap = $needsSettingsBootstrap -or $needsBundleSync -or $needsDisplayNameHint -or $needsDailyReportHint -or $needsDailyReportRepair -or $needsExchangeNoisePatch -or $needsWinRmInboundBlock -or $needsHeartbeatInterval -or $needsStartupRebootDetectMinutes -or $needsTaskExecutionLimitFix $needsSacBootstrap = $needsSettingsBootstrap -or $needsBundleSync -or $needsDisplayNameHint -or $needsServerIPv4Hint -or $needsDailyReportHint -or $needsDailyReportRepair -or $needsExchangeNoisePatch -or $needsWinRmInboundBlock -or $needsHeartbeatInterval -or $needsStartupRebootDetectMinutes -or $needsTaskExecutionLimitFix
if (Test-RdpMonitorExchangeServerRole) { if (Test-RdpMonitorExchangeServerRole) {
Write-DeployLog "Обнаружена роль Exchange — при необходимости допишем WinRM/4624 noise settings в login_monitor.settings.ps1." Write-DeployLog "Обнаружена роль Exchange — при необходимости допишем WinRM/4624 noise settings в login_monitor.settings.ps1."
@@ -1422,8 +1546,10 @@ try {
Write-DeployLog "Версия совпадает ($shareVerRaw), но нужна донастройка SAC в settings — продолжаем деплой." Write-DeployLog "Версия совпадает ($shareVerRaw), но нужна донастройка SAC в settings — продолжаем деплой."
} elseif ($needsDisplayNameHint) { } elseif ($needsDisplayNameHint) {
Write-DeployLog "Версия совпадает ($shareVerRaw), но нет подсказки `$ServerDisplayName в settings — продолжаем деплой." Write-DeployLog "Версия совпадает ($shareVerRaw), но нет подсказки `$ServerDisplayName в settings — продолжаем деплой."
} elseif ($needsServerIPv4Hint) {
Write-DeployLog "Версия совпадает ($shareVerRaw), но нет подсказки `$ServerIPv4 в settings — продолжаем деплой."
} elseif ($needsDailyReportHint) { } elseif ($needsDailyReportHint) {
Write-DeployLog "Версия совпадает ($shareVerRaw), но нет `$DailyReportEnabled в settings — продолжаем деплой." Write-DeployLog "Версия совпадает ($shareVerRaw), но `$DailyReportEnabled не `$true — допишем/исправим и продолжим деплой."
} elseif ($needsDailyReportRepair) { } elseif ($needsDailyReportRepair) {
Write-DeployLog "Версия совпадает ($shareVerRaw), но в settings некорректно задан `$DailyReportEnabled (= true/false без `$) — продолжаем деплой." Write-DeployLog "Версия совпадает ($shareVerRaw), но в settings некорректно задан `$DailyReportEnabled (= true/false без `$) — продолжаем деплой."
} elseif ($needsExchangeNoisePatch) { } elseif ($needsExchangeNoisePatch) {
+36 -1
View File
@@ -300,6 +300,7 @@ if (-not $isAdmin) {
} }
$since = (Get-Date).AddMinutes(-1 * [math]::Abs($MinutesBack)) $since = (Get-Date).AddMinutes(-1 * [math]::Abs($MinutesBack))
$RcmLogName = 'Microsoft-Windows-TerminalServices-RemoteConnectionManager/Operational'
$isWs = Test-IsWorkstationOs $isWs = Test-IsWorkstationOs
[void]$report.AppendLine("OS ProductType workstation=$isWs (server mode LT 2/3/10, workstation LT 10)") [void]$report.AppendLine("OS ProductType workstation=$isWs (server mode LT 2/3/10, workstation LT 10)")
[void]$report.AppendLine("Window StartTime >= $($since.ToString('yyyy-MM-dd HH:mm:ss'))") [void]$report.AppendLine("Window StartTime >= $($since.ToString('yyyy-MM-dd HH:mm:ss'))")
@@ -399,8 +400,42 @@ if ($notifyable.Count -eq 0 -and $recent4624.Count -gt 0) {
[void]$report.AppendLine('') [void]$report.AppendLine('')
[void]$report.AppendLine('UseSAC=exclusive: Telegram по rdp.login.success только из SAC (не локально агентом).') [void]$report.AppendLine('UseSAC=exclusive: Telegram по rdp.login.success только из SAC (не локально агентом).')
[void]$report.AppendLine('На сервере RDS без аудита Security 4624 — смотрите RCM Operational 1149 (2.1.5-SAC+: исправлен silent skip при ComputerName=-).')
[void]$report.AppendLine('rdp.login.success = severity info; при SAC min_severity=warning Telegram не уйдёт, но событие в UI SAC должно быть.') [void]$report.AppendLine('rdp.login.success = severity info; при SAC min_severity=warning Telegram не уйдёт, но событие в UI SAC должно быть.')
[void]$report.AppendLine('Проверьте SAC: type=rdp.login.success, hostname ITIS198, время входа, event_id из login_monitor.log.')
[void]$report.Append((Write-Section '10. RCM Operational 1149'))
$recent1149 = @()
try {
$recent1149 = @(Get-WinEvent -FilterHashtable @{
LogName = $RcmLogName
ID = 1149
StartTime = $since
} -ErrorAction SilentlyContinue)
} catch { }
[void]$report.AppendLine("RCM 1149 в окне ($RcmLogName): $($recent1149.Count)")
foreach ($ev in $recent1149 | Select-Object -First 8) {
$u = '-'; $ip = '-'
try {
if ($ev.Properties.Count -gt 0) { $u = [string]$ev.Properties[0].Value }
if ($ev.Properties.Count -gt 2) { $ip = [string]$ev.Properties[2].Value }
} catch { }
[void]$report.AppendLine(" $($ev.TimeCreated.ToString('yyyy-MM-dd HH:mm:ss')) User=$u IP=$ip")
}
$rcmNotifyLines = @()
if (Test-Path -LiteralPath $monLog) {
$sinceLog = $since.ToString('yyyy-MM-dd HH:mm')
$rcmNotifyLines = @(Select-String -LiteralPath $monLog -Pattern 'Notify RCM 1149|Skip 1149' -ErrorAction SilentlyContinue |
Where-Object { $_.Line -ge $sinceLog } |
Select-Object -ExpandProperty Line)
}
[void]$report.AppendLine("Строки Notify/Skip 1149 в login_monitor.log: $($rcmNotifyLines.Count)")
foreach ($ln in $rcmNotifyLines | Select-Object -Last 10) { [void]$report.AppendLine(" $ln") }
if ($recent1149.Count -gt 0 -and $rcmNotifyLines.Count -eq 0) {
[void]$report.AppendLine('ВНИМАНИЕ: 1149 в журнале есть, в логе агента нет Notify/Skip — вероятен баг 2.1.4 (все 1149 отбрасывались) или агент не работал в момент входа.')
}
[void]$report.AppendLine('')
[void]$report.AppendLine('Проверьте SAC: type=rdp.login.success, hostname, время входа, event_id из login_monitor.log.')
$text = $report.ToString() $text = $report.ToString()
[System.IO.File]::WriteAllText($OutputPath, $text, (New-Object System.Text.UTF8Encoding $true)) [System.IO.File]::WriteAllText($OutputPath, $text, (New-Object System.Text.UTF8Encoding $true))
+46 -3
View File
@@ -2,12 +2,37 @@
Скрипт **`update-rdp-monitor.ps1`** на сервере публикации (например DC3) выполняет `git pull` и копирует файлы в шару. Скрипт **`update-rdp-monitor.ps1`** на сервере публикации (например DC3) выполняет `git pull` и копирует файлы в шару.
## Путь на шаре (`-NetlogonDest`)
После `git pull` скрипт копирует дистрибутив в UNC-каталог NETLOGON:
```text
\\<имя-DC>\NETLOGON\RDP-login-monitor
```
`<имя-DC>` — NetBIOS-имя или FQDN **контроллера домена**, где лежит SYSVOL (тот же хост, с которого GPO запускает `Deploy-LoginMonitor.ps1`). Примеры:
- `\\K6A-DC3\NETLOGON\RDP-login-monitor`
- `\\dc01.corp.example.com\NETLOGON\RDP-login-monitor`
Значение по умолчанию в скрипте — **заглушка** `\\dc.contoso.local\NETLOGON\RDP-login-monitor`. В реальном домене её **нужно переопределить**, иначе после успешного `git pull` будет ошибка **«Не найден сетевой путь»** (скрипт не достучится до несуществующего хоста).
Проверка перед публикацией:
```powershell
Test-Path '\\K6A-DC3\NETLOGON\RDP-login-monitor'
# или хотя бы корень шары:
Test-Path '\\K6A-DC3\NETLOGON'
```
Должно вернуть `True` под учётной записью, с которой запускаете публикацию (на DC — обычно локальный админ; с рабочей станции — доменный админ с доступом к NETLOGON).
## Параметры по умолчанию ## Параметры по умолчанию
| Параметр | Значение | | Параметр | Значение |
|----------|----------| |----------|----------|
| `$RepoPath` | `C:\Soft\Git\RDP-login-monitor` | | `$RepoPath` | `C:\Soft\Git\RDP-login-monitor` |
| `$NetlogonDest` | `\\dc.contoso.local\NETLOGON\RDP-login-monitor` | | `$NetlogonDest` | `\\dc.contoso.local\NETLOGON\RDP-login-monitor` *(заглушка — замените на свой DC)* |
| `$GitUrl` | `https://github.com/PTah/RDP-login-monitor.git` | | `$GitUrl` | `https://github.com/PTah/RDP-login-monitor.git` |
| `$GitBranch` | `main` | | `$GitBranch` | `main` |
| `$LogFile` | `C:\soft\Logs\update-rdp-monitor.log` | | `$LogFile` | `C:\soft\Logs\update-rdp-monitor.log` |
@@ -28,9 +53,27 @@
## Запуск ## Запуск
**На сервере публикации** (клон репозитория + доступ к NETLOGON):
```powershell ```powershell
powershell.exe -NoProfile -ExecutionPolicy Bypass -File C:\soft\update-rdp-monitor.ps1 powershell.exe -NoProfile -ExecutionPolicy Bypass -File C:\Soft\Git\RDP-login-monitor\update-rdp-monitor.ps1 `
powershell.exe -NoProfile -ExecutionPolicy Bypass -File C:\soft\update-rdp-monitor.ps1 -WhatIf -NetlogonDest '\\K6A-DC3\NETLOGON\RDP-login-monitor'
```
Другой remote git (закрытое зеркало):
```powershell
powershell.exe -NoProfile -ExecutionPolicy Bypass -File C:\Soft\Git\RDP-login-monitor\update-rdp-monitor.ps1 `
-NetlogonDest '\\K6A-DC3\NETLOGON\RDP-login-monitor' `
-GitUrl 'https://git.papatramp.ru/PTah/RDP-login-monitor.git'
```
Пробный прогон без копирования:
```powershell
powershell.exe -NoProfile -ExecutionPolicy Bypass -File C:\Soft\Git\RDP-login-monitor\update-rdp-monitor.ps1 `
-NetlogonDest '\\K6A-DC3\NETLOGON\RDP-login-monitor' `
-WhatIf
``` ```
После pull обязательно проверьте **`version.txt`** на шаре — его номер определяет, подтянут ли обновления на клиентах. Метка может включать суффикс (например **`1.2.27-SAC`**); **`Deploy-LoginMonitor.ps1`** сравнивает её с **`deployed_version.txt`** по полной строке. После pull обязательно проверьте **`version.txt`** на шаре — его номер определяет, подтянут ли обновления на клиентах. Метка может включать суффикс (например **`1.2.27-SAC`**); **`Deploy-LoginMonitor.ps1`** сравнивает её с **`deployed_version.txt`** по полной строке.
+15
View File
@@ -203,6 +203,21 @@ powershell.exe -NoProfile -ExecutionPolicy Bypass `
**`Deploy-LoginMonitor.ps1`** пишет **`restart.request`**, ждёт до **35 с**, затем **`Stop-Process -Force`** только при таймауте. **`Deploy-LoginMonitor.ps1`** пишет **`restart.request`**, ждёт до **35 с**, затем **`Stop-Process -Force`** только при таймауте.
## Обновление через SAC (WinRM)
Альтернатива NETLOGON/GPO: кнопка **«Обновить через WinRM»** на карточке хоста в SAC (сервер ≥ 0.20.15).
| Этап | Где | Действие |
|------|-----|----------|
| 1 | SAC | `git fetch` RDP-login-monitor → zip (`.ps1` с UTF-8 BOM) |
| 2 | Клиент (WinRM) | `Invoke-WebRequest``/api/v1/agent/rdp-bundle/<token>` |
| 3 | Клиент | Распаковка в `C:\ProgramData\RDP-login-monitor\_sac_staging` |
| 4 | Клиент | `Deploy-LoginMonitor.ps1 -SourceShareRoot _sac_staging` |
**Требования:** domain admin в SAC; `SAC_PUBLIC_URL` доступен с ПК; `rdp_git_repo_url` в настройках обновлений. **`Deploy-LoginMonitor.ps1` на шаре NETLOGON для этого пути не обязателен** — скрипт приходит в zip.
Лог операции — в модалке SAC сразу при старте. См. [agent-control-plane.md](https://git.papatramp.ru/PTah/security-alert-center/src/branch/main/docs/agent-control-plane.md) §4.3.
## Версии ## Версии
| Файл | Роль | | Файл | Роль |
+132 -19
View File
@@ -1,4 +1,4 @@
<# <#
.SYNOPSIS .SYNOPSIS
Мониторинг логинов/попыток входа с уведомлениями в Telegram Мониторинг логинов/попыток входа с уведомлениями в Telegram
.DESCRIPTION .DESCRIPTION
@@ -16,7 +16,7 @@
- Добавлены исключения шума: DWM-*, UMFD-*, HealthMailbox*, Font Driver Host*, NtLmSsp и др. - Добавлены исключения шума: DWM-*, UMFD-*, HealthMailbox*, Font Driver Host*, NtLmSsp и др.
- Heartbeat без дрейфа: используется nextHeartbeatTime (а не "прошло N секунд"). - Heartbeat без дрейфа: используется nextHeartbeatTime (а не "прошло N секунд").
- Win32_OperatingSystem.ProductType: 1 = рабочая станция (4624/4625 только LogonType 10 + при наличии журнала событие 1149 RCM); - Win32_OperatingSystem.ProductType: 1 = рабочая станция (4624/4625 только LogonType 10 + при наличии журнала событие 1149 RCM);
2/3 = сервер/КД — прежняя логика (типы 2, 3, 10). 2/3 = сервер/КД — Security 4624/4625 (типы 2, 3, 10) + при наличии журнала RCM — 1149 (на RDS часто нет 4624 без аудита входа).
Секреты Telegram (DPAPI LocalMachine): на ЭТОЙ машине, под админом, выполните: Секреты Telegram (DPAPI LocalMachine): на ЭТОЙ машине, под админом, выполните:
Add-Type -AssemblyName System.Security Add-Type -AssemblyName System.Security
$p=[Text.Encoding]::UTF8.GetBytes('<токен>') $p=[Text.Encoding]::UTF8.GetBytes('<токен>')
@@ -90,7 +90,7 @@ $script:SkipLogDetailLimit = 15
# строки ниже, если правки «мелкие» и вы не хотите менять отображаемую версию в логах). # строки ниже, если правки «мелкие» и вы не хотите менять отображаемую версию в логах).
# Рекомендация: при значимых релизах меняйте и $ScriptVersion, и version.txt одинаково; при только # Рекомендация: при значимых релизах меняйте и $ScriptVersion, и version.txt одинаково; при только
# исправлениях на шаре — достаточно поднять patch в version.txt (например 1.3.0.1). # исправлениях на шаре — достаточно поднять patch в version.txt (например 1.3.0.1).
$ScriptVersion = "2.0.38-SAC" $ScriptVersion = "2.1.9-SAC"
# Логи (все под InstallRoot) # Логи (все под InstallRoot)
$LogFile = Join-Path $script:InstallRoot "Logs\login_monitor.log" $LogFile = Join-Path $script:InstallRoot "Logs\login_monitor.log"
@@ -103,17 +103,19 @@ $MaxBackupDays = 31
# Heartbeat (файл; при отсутствии обновления > HeartbeatStaleAlertMultiplier × интервал — оповещение) # Heartbeat (файл; при отсутствии обновления > HeartbeatStaleAlertMultiplier × интервал — оповещение)
$HeartbeatInterval = 14400 $HeartbeatInterval = 14400
$HeartbeatStaleAlertMultiplier = 2
# Окно (мин) для определения «старт после перезагрузки ОС» (LastBootUpTime + System 41/1074/6005/6008/6009). # Окно (мин) для определения «старт после перезагрузки ОС» (LastBootUpTime + System 41/1074/6005/6008/6009).
$StartupRebootDetectMinutes = 5 $StartupRebootDetectMinutes = 5
# Инвентаризация железа/ПО для SAC (agent.inventory); интервал опроса, сек (12 ч). # Инвентаризация железа/ПО для SAC (agent.inventory); интервал опроса, сек (12 ч).
$InventoryIntervalSec = 43200 $InventoryIntervalSec = 43200
$GetInventory = 1 $GetInventory = 1
$HeartbeatStaleAlertMultiplier = 2 $SacCommandPollIntervalSec = 60
# Один RDP-вход часто даёт 2+ события 4624 с одним временем — не слать дубли в Telegram/SAC. # Один RDP-вход часто даёт 2+ события 4624 с одним временем — не слать дубли в Telegram/SAC.
$LoginSuccessNotifyDedupSeconds = 90 $LoginSuccessNotifyDedupSeconds = 90
$HeartbeatFile = Join-Path $script:InstallRoot "Logs\last_heartbeat.txt" $HeartbeatFile = Join-Path $script:InstallRoot "Logs\last_heartbeat.txt"
$GatewayPollCursorFile = Join-Path $script:InstallRoot "Logs\last_rdgateway_poll.txt" $GatewayPollCursorFile = Join-Path $script:InstallRoot "Logs\last_rdgateway_poll.txt"
$SecurityPollCursorFile = Join-Path $script:InstallRoot "Logs\last_security_poll.txt" $SecurityPollCursorFile = Join-Path $script:InstallRoot "Logs\last_security_poll.txt"
$Rcm1149PollCursorFile = Join-Path $script:InstallRoot "Logs\last_rcm1149_poll.txt"
$DeployUpdateMarkerFile = Join-Path $script:InstallRoot "deploy_last_update.txt" $DeployUpdateMarkerFile = Join-Path $script:InstallRoot "deploy_last_update.txt"
# Построчные правила подавления уведомлений Security 4624/4625 (см. ignore.lst.example в репозитории). # Построчные правила подавления уведомлений Security 4624/4625 (см. ignore.lst.example в репозитории).
$script:IgnoreListPath = Join-Path $script:InstallRoot "ignore.lst" $script:IgnoreListPath = Join-Path $script:InstallRoot "ignore.lst"
@@ -133,12 +135,17 @@ $LastReportFile = Join-Path $script:InstallRoot "Logs\last_daily_report.txt"
$EnableRDGatewayMonitoring = $true $EnableRDGatewayMonitoring = $true
$RDGatewayLogName = "Microsoft-Windows-TerminalServices-Gateway/Operational" $RDGatewayLogName = "Microsoft-Windows-TerminalServices-Gateway/Operational"
$RDGatewayEvents = @(302, 303) $RDGatewayEvents = @(302, 303)
# Дубликаты RDG 302 (User+Target+ExternalIP) в окне N с — один alert в SAC/Telegram.
$Rdg302NotifyDedupSeconds = 90
# Макс. возраст сохранённого курсора RD Gateway (мин): если файл старше — replay не делаем. # Макс. возраст сохранённого курсора RD Gateway (мин): если файл старше — replay не делаем.
$GatewayEventsLookbackMinutes = 60 $GatewayEventsLookbackMinutes = 60
# Security 4624/4625/4648: персистентный cursor + replay не старше N мин (медленный boot / поздний старт агента). # Security 4624/4625/4648: персистентный cursor + replay не старше N мин (медленный boot / поздний старт агента).
$SecurityEventsLookbackMinutes = 60 $SecurityEventsLookbackMinutes = 60
# RCM 1149: персистентный cursor + replay (как Security).
$Rcm1149EventsLookbackMinutes = 60
# RDP Remote Connection Manager (workstations): User authentication succeeded — событие 1149 # RDP Remote Connection Manager: User authentication succeeded — событие 1149 (workstation + RDS server)
$EnableRcm1149Monitoring = 1
$RcmLogName = "Microsoft-Windows-TerminalServices-RemoteConnectionManager/Operational" $RcmLogName = "Microsoft-Windows-TerminalServices-RemoteConnectionManager/Operational"
$RcmEventId = 1149 $RcmEventId = 1149
@@ -251,7 +258,9 @@ $SacUrl = ''
$SacApiKey = '' $SacApiKey = ''
$SacSpoolDir = '' $SacSpoolDir = ''
$SacAgentIdFile = '' $SacAgentIdFile = ''
$SacTimeoutSec = 12 $SacTimeoutSec = 45
$SacSpoolFlushMaxFiles = 50
$SacSpoolMaxAgeHours = 72
$SacTlsSkipVerify = $false $SacTlsSkipVerify = $false
$SacFallbackFailures = 5 $SacFallbackFailures = 5
@@ -2636,7 +2645,7 @@ function Send-Heartbeat {
if ($script:IsWorkstation) { if ($script:IsWorkstation) {
$message += "`r`n📌 <b>Режим:</b> рабочая станция — Security 4624/4625 только LogonType 10 (RDP); при наличии журнала — также 1149 (Remote Connection Manager)." $message += "`r`n📌 <b>Режим:</b> рабочая станция — Security 4624/4625 только LogonType 10 (RDP); при наличии журнала — также 1149 (Remote Connection Manager)."
} else { } else {
$message += "`r`n📌 <b>Режим:</b> сервер — Security 4624/4625, типы входа 2, 3, 10." $message += "`r`n📌 <b>Режим:</b> сервер — Security 4624/4625, типы входа 2, 3, 10; при журнале RCM — также 1149 (RDS без аудита 4624)."
} }
$ignoreEntries = @(Get-RdpMonitorIgnoreListEntries) $ignoreEntries = @(Get-RdpMonitorIgnoreListEntries)
if ($ignoreEntries.Count -gt 0) { if ($ignoreEntries.Count -gt 0) {
@@ -3226,8 +3235,10 @@ function Should-IgnoreEvent {
if ($Username -like $p) { return $true } if ($Username -like $p) { return $true }
} }
if ($ComputerName -eq "Authz" -or $ComputerName -like "Authz*") { return $true } if ($EventID -ne 1149) {
if ($ComputerName -eq "NtLmSsp" -or $ComputerName -like "NtLmSsp*" -or $ComputerName -like "*NtLmSsp*") { 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) { foreach ($lp in $ExcludedLogonProcesses) {
if ($ProcessName -like "*$lp*") { return $true } if ($ProcessName -like "*$lp*") { return $true }
@@ -3237,13 +3248,14 @@ function Should-IgnoreEvent {
} }
if ($SourceIP -eq "127.0.0.1" -or $SourceIP -like "fe80:*") { 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 } # RCM 1149 не содержит WorkstationName; caller передаёт ComputerName='-'.
if ($EventID -ne 1149 -and ($ComputerName -eq "-" -or $ComputerName -eq "N/A")) { return $true }
foreach ($pattern in $ExcludedComputerPatterns) { foreach ($pattern in $ExcludedComputerPatterns) {
if ($ComputerName -like $pattern) { return $true } if ($ComputerName -like $pattern) { return $true }
} }
if ($EventID -in 4624, 4625) { if ($EventID -in 4624, 4625, 1149) {
if (Test-RdpMonitorIgnoreListMatch -EventId ([string]$EventID) -Username $Username ` if (Test-RdpMonitorIgnoreListMatch -EventId ([string]$EventID) -Username $Username `
-ComputerName $ComputerName -SourceIP $SourceIP) { -ComputerName $ComputerName -SourceIP $SourceIP) {
return $true return $true
@@ -3755,6 +3767,51 @@ function Set-RdpSecurityPollCursor {
} }
} }
function Get-RdpRcm1149PollCursor {
$now = Get-Date
$fresh = $now.AddSeconds(-10)
$maxAgeMin = [math]::Max(1, [int]$Rcm1149EventsLookbackMinutes)
$lookbackFloor = $now.AddMinutes(-1 * $maxAgeMin)
if (-not (Test-Path -LiteralPath $Rcm1149PollCursorFile)) {
Write-Log "RCM 1149: cursor отсутствует — replay с $($lookbackFloor.ToString('dd.MM.yyyy HH:mm:ss')) (lookback ${maxAgeMin} мин)."
return $lookbackFloor
}
try {
$raw = (Get-Content -LiteralPath $Rcm1149PollCursorFile -TotalCount 1 -ErrorAction Stop).Trim()
if ([string]::IsNullOrWhiteSpace($raw)) {
Write-Log "RCM 1149: cursor пуст — replay с $($lookbackFloor.ToString('dd.MM.yyyy HH:mm:ss')) (lookback ${maxAgeMin} мин)."
return $lookbackFloor
}
$parsed = [datetime]::Parse($raw, [Globalization.CultureInfo]::InvariantCulture, [Globalization.DateTimeStyles]::RoundtripKind)
if ($parsed.Kind -eq [DateTimeKind]::Utc) {
$parsed = $parsed.ToLocalTime()
}
if ($parsed -lt $lookbackFloor) {
Write-Log "RCM 1149: cursor старше $maxAgeMin мин — replay с $($lookbackFloor.ToString('dd.MM.yyyy HH:mm:ss'))."
return $lookbackFloor
}
Write-Log "RCM 1149: cursor=$($parsed.ToString('dd.MM.yyyy HH:mm:ss')) (lookback cap ${maxAgeMin} мин)."
return $parsed
} catch {
Write-Log "RCM 1149: не удалось прочитать cursor ($Rcm1149PollCursorFile) — replay с $($lookbackFloor.ToString('dd.MM.yyyy HH:mm:ss'))."
return $lookbackFloor
}
}
function Set-RdpRcm1149PollCursor {
param([Parameter(Mandatory = $true)][datetime]$Cursor)
try {
$dir = Split-Path -Parent $Rcm1149PollCursorFile
if (-not (Test-Path -LiteralPath $dir)) {
New-Item -ItemType Directory -Path $dir -Force | Out-Null
}
Write-TextFileUtf8Bom -Path $Rcm1149PollCursorFile -Text ($Cursor.ToString('o'))
} catch {
Write-Log "WARN: RCM 1149 cursor save failed: $($_.Exception.Message)"
}
}
function Test-RdgGatewayBenignErrorCode { function Test-RdgGatewayBenignErrorCode {
param([string]$ErrorCode) param([string]$ErrorCode)
if ([string]::IsNullOrWhiteSpace($ErrorCode)) { return $true } if ([string]::IsNullOrWhiteSpace($ErrorCode)) { return $true }
@@ -3765,6 +3822,20 @@ function Test-RdgGatewayBenignErrorCode {
return $false return $false
} }
function Get-Rdg302NotifyDedupKey {
param(
[string]$Username,
[string]$InternalIP,
[string]$ExternalIP
)
$userPart = Get-RdpLoginNotifyDedupUsernamePart -Username $Username
$target = if ($InternalIP) { [string]$InternalIP.Trim() } else { '-' }
if ([string]::IsNullOrWhiteSpace($target)) { $target = '-' }
$ext = if ($ExternalIP) { [string]$ExternalIP.Trim() } else { '-' }
if ([string]::IsNullOrWhiteSpace($ext)) { $ext = '-' }
return "rdg302|$userPart|$target|$ext"
}
function Get-RdgGatewaySacEventType { function Get-RdgGatewaySacEventType {
param( param(
[Parameter(Mandatory = $true)][int]$EventId, [Parameter(Mandatory = $true)][int]$EventId,
@@ -4327,7 +4398,7 @@ function Start-LoginMonitor {
if ($osKind.IsWorkstation) { if ($osKind.IsWorkstation) {
Write-Log "Режим рабочей станции: Security — только LogonType 10 (RDP); при наличии журнала — событие 1149 (Remote Connection Manager)." Write-Log "Режим рабочей станции: Security — только LogonType 10 (RDP); при наличии журнала — событие 1149 (Remote Connection Manager)."
} else { } else {
Write-Log "Режим сервера: Security — LogonType 2, 3, 10" Write-Log "Режим сервера: Security — LogonType 2, 3, 10; при журнале RCM — 1149 (если `$EnableRcm1149Monitoring)."
} }
if (Test-MonitorFeatureEnabled -Value $EnableAdminShareMonitoring) { if (Test-MonitorFeatureEnabled -Value $EnableAdminShareMonitoring) {
Write-Log "Admin share: Security 5140 для $($AdminShareMonitorSuffixes -join ', ') (audit File Share; workstation + server)." Write-Log "Admin share: Security 5140 для $($AdminShareMonitorSuffixes -join ', ') (audit File Share; workstation + server)."
@@ -4394,9 +4465,15 @@ function Start-LoginMonitor {
$rdGatewayAvailable = $false $rdGatewayAvailable = $false
if ($EnableRDGatewayMonitoring) { $rdGatewayAvailable = Test-RDGatewayLog } if ($EnableRDGatewayMonitoring) { $rdGatewayAvailable = Test-RDGatewayLog }
$rcmMonitoringEnabled = ($osKind.IsWorkstation -and (Test-RcmLogAvailable)) $rcmMonitoringEnabled = (Test-MonitorFeatureEnabled -Value $EnableRcm1149Monitoring) -and (Test-RcmLogAvailable)
if ($osKind.IsWorkstation -and -not $rcmMonitoringEnabled) { if (Test-MonitorFeatureEnabled -Value $EnableRcm1149Monitoring) {
Write-Log "Рабочая станция: журнал Remote Connection Manager недоступен — уведомления только по Security 4624/4625 (LogonType 10). Проверьте, что включён удалённый рабочий стол." if ($rcmMonitoringEnabled) {
Write-Log "RCM 1149: опрос включён ($RcmLogName)."
} elseif ($osKind.IsWorkstation) {
Write-Log "Рабочая станция: журнал Remote Connection Manager недоступен — уведомления только по Security 4624/4625 (LogonType 10). Проверьте, что включён удалённый рабочий стол."
} else {
Write-Log "Сервер: журнал Remote Connection Manager недоступен — RCM 1149 отключён; только Security 4624/4625."
}
} }
$rcmShadowMonitoringEnabled = (Test-MonitorFeatureEnabled -Value $EnableRcmShadowControlMonitoring) -and (Test-RcmLogAvailable) $rcmShadowMonitoringEnabled = (Test-MonitorFeatureEnabled -Value $EnableRcmShadowControlMonitoring) -and (Test-RcmLogAvailable)
@@ -4404,6 +4481,7 @@ function Start-LoginMonitor {
$adminShareMonitoringEnabled = Test-MonitorFeatureEnabled -Value $EnableAdminShareMonitoring $adminShareMonitoringEnabled = Test-MonitorFeatureEnabled -Value $EnableAdminShareMonitoring
$nextHeartbeatTime = (Get-Date).AddSeconds($HeartbeatInterval) $nextHeartbeatTime = (Get-Date).AddSeconds($HeartbeatInterval)
$script:NextSacCommandPollTime = Get-Date
$nextInventoryTime = (Get-Date).AddSeconds($InventoryIntervalSec) $nextInventoryTime = (Get-Date).AddSeconds($InventoryIntervalSec)
$nextRotationCheck = Check-AndRotateLog $nextRotationCheck = Check-AndRotateLog
$nextReportCheck = Check-AndSendDailyReport $nextReportCheck = Check-AndSendDailyReport
@@ -4417,7 +4495,7 @@ function Start-LoginMonitor {
Write-Log "RD Gateway: журнал недоступен — опрос 302/303 отключён ($RDGatewayLogName)" Write-Log "RD Gateway: журнал недоступен — опрос 302/303 отключён ($RDGatewayLogName)"
} }
} }
$lastRcmCheckTime = (Get-Date).AddSeconds(-10) $lastRcmCheckTime = Get-RdpRcm1149PollCursor
$lastRcmShadowCheckTime = (Get-Date).AddSeconds(-10) $lastRcmShadowCheckTime = (Get-Date).AddSeconds(-10)
$lastWinRmCheckTime = (Get-Date).AddSeconds(-10) $lastWinRmCheckTime = (Get-Date).AddSeconds(-10)
$lastAdminShare5140CheckTime = (Get-Date).AddSeconds(-10) $lastAdminShare5140CheckTime = (Get-Date).AddSeconds(-10)
@@ -4642,6 +4720,14 @@ function Start-LoginMonitor {
Write-Log "Skip RDG 303: ephemeral channel (SessionDuration=0, User=$($ei.Username), Target=$($ei.InternalIP), ErrorCode=$($ei.ErrorCode))" Write-Log "Skip RDG 303: ephemeral channel (SessionDuration=0, User=$($ei.Username), Target=$($ei.InternalIP), ErrorCode=$($ei.ErrorCode))"
continue continue
} }
if ($event.Id -eq 302) {
$dedupKey302 = Get-Rdg302NotifyDedupKey -Username $ei.Username `
-InternalIP $ei.InternalIP -ExternalIP $ei.ExternalIP
if (Test-RdpMonitorNotifyDedup -Key $dedupKey302 -WindowSeconds $Rdg302NotifyDedupSeconds) {
Write-Log "Notify dedup RDG 302: User=$($ei.Username) Target=$($ei.InternalIP) Ext=$($ei.ExternalIP) (window ${Rdg302NotifyDedupSeconds}s)"
continue
}
}
$msg = Format-RDGatewayEvent -EventID $event.Id ` $msg = Format-RDGatewayEvent -EventID $event.Id `
-Username $ei.Username ` -Username $ei.Username `
-ExternalIP $ei.ExternalIP ` -ExternalIP $ei.ExternalIP `
@@ -4688,12 +4774,30 @@ function Start-LoginMonitor {
} -ErrorAction SilentlyContinue } -ErrorAction SilentlyContinue
if ($rcmEvents) { if ($rcmEvents) {
$rcmNotified = 0
$rcmSkipped = 0
foreach ($event in $rcmEvents) { foreach ($event in $rcmEvents) {
if ($event.TimeCreated -le $lastRcmCheckTime) { continue } if ($event.TimeCreated -le $lastRcmCheckTime) { continue }
$rcmInfo = Get-Rcm1149EventInfo -Event $event $rcmInfo = Get-Rcm1149EventInfo -Event $event
if ($rcmInfo.Username -like "*$") { continue } if ($rcmInfo.Username -like "*$") {
$rcmSkipped++
Write-Log "Skip 1149: User=$($rcmInfo.Username) IP=$($rcmInfo.ClientIP) — machine account"
continue
}
if (Should-IgnoreEvent -Username $rcmInfo.Username -ProcessName "-" ` if (Should-IgnoreEvent -Username $rcmInfo.Username -ProcessName "-" `
-ComputerName "-" -EventID 1149 -LogonType 10 -SourceIP $rcmInfo.ClientIP) { continue } -ComputerName "-" -EventID 1149 -LogonType 10 -SourceIP $rcmInfo.ClientIP) {
$rcmSkipped++
Write-Log "Skip 1149: User=$($rcmInfo.Username) IP=$($rcmInfo.ClientIP) — built-in exclusion or ignore.lst"
continue
}
$dedupKey1149 = Get-RdpLoginSuccessNotifyDedupKey -SecurityLogComputerName $event.MachineName `
-Username $rcmInfo.Username -SourceIP $rcmInfo.ClientIP -LogonType 10
if (-not (Test-RdpLoginSuccessNotifyDedupAllow -DedupKey $dedupKey1149)) {
$rcmSkipped++
Write-Log "Notify dedup 1149: User=$($rcmInfo.Username) IP=$($rcmInfo.ClientIP) (window ${LoginSuccessNotifyDedupSeconds}s)"
continue
}
$msg = Format-Rcm1149Event -Username $rcmInfo.Username -ClientIP $rcmInfo.ClientIP ` $msg = Format-Rcm1149Event -Username $rcmInfo.Username -ClientIP $rcmInfo.ClientIP `
-TimeCreated $rcmInfo.TimeCreated -SecurityLogComputerName $event.MachineName -TimeCreated $rcmInfo.TimeCreated -SecurityLogComputerName $event.MachineName
@@ -4709,8 +4813,13 @@ function Start-LoginMonitor {
ip_address = $rcmInfo.ClientIP ip_address = $rcmInfo.ClientIP
event_id_windows = 1149 event_id_windows = 1149
} | Out-Null } | Out-Null
$rcmNotified++
} }
$lastRcmCheckTime = Update-MonitorPollCursor -CurrentCursor $lastRcmCheckTime -Events @($rcmEvents) $lastRcmCheckTime = Update-MonitorPollCursor -CurrentCursor $lastRcmCheckTime -Events @($rcmEvents)
Set-RdpRcm1149PollCursor -Cursor $lastRcmCheckTime
if ($rcmNotified -gt 0 -or $rcmSkipped -gt 0) {
Write-Log "RCM 1149 poll: fetched=$(@($rcmEvents).Count) notified=$rcmNotified skipped=$rcmSkipped cursor=$($lastRcmCheckTime.ToString('dd.MM.yyyy HH:mm:ss'))"
}
} }
} }
@@ -4933,7 +5042,11 @@ function Start-LoginMonitor {
$nextReportCheck = Check-AndSendDailyReport $nextReportCheck = Check-AndSendDailyReport
} }
if ($script:SacClientLoaded) { if ($script:SacClientLoaded) {
Invoke-SacFlushSpool -MaxFiles 5 | Out-Null Invoke-SacFlushSpool | Out-Null
if ((Get-SacNormalizedMode) -ne 'off' -and $now -ge $script:NextSacCommandPollTime) {
Invoke-SacProcessPendingCommands | Out-Null
$script:NextSacCommandPollTime = $now.AddSeconds($SacCommandPollIntervalSec)
}
} }
} catch { } catch {
if ($_.Exception -is [System.Management.Automation.PipelineStoppedException]) { throw } if ($_.Exception -is [System.Management.Automation.PipelineStoppedException]) { throw }
+45 -218
View File
@@ -1,234 +1,61 @@
# RDP Login Monitor # RDP Login Monitor
PowerShell-набор для мониторинга входов в Windows с уведомлениями в Telegram и/или Email (SMTP). **Версия:** `2.1.5-SAC` (`$ScriptVersion` + `version.txt`)
## Быстрый старт (1 команда с шары) PowerShell-мониторинг Windows: RDP/RDS, RD Gateway, WinRM, admin share, блокировки УЗ, heartbeat, отчёты.
Для **первичной установки на новый сервер** используйте только deploy-скрипт с шары: ## Установка (домен)
```powershell ```powershell
powershell -NoProfile -ExecutionPolicy Bypass -File "\\<DC>\NETLOGON\RDP-login-monitor\Deploy-LoginMonitor.ps1" powershell -NoProfile -ExecutionPolicy Bypass -File "\\<DC>\NETLOGON\RDP-login-monitor\Deploy-LoginMonitor.ps1"
``` ```
Скрипт сам: Каталог: `C:\ProgramData\RDP-login-monitor\` · настройки: `login_monitor.settings.ps1` (не перезаписываются при деплое).
- создаст `C:\ProgramData\RDP-login-monitor\`;
- скопирует `Login_Monitor.ps1`, `Sac-Client.ps1` и служебные файлы; ## Обновление через SAC (WinRM)
- создаст/обновит задачи `RDP-Login-Monitor` и `RDP-Login-Monitor-Watchdog`;
- запустит монитор; С карточки хоста в SAC: **«Обновить через WinRM»** (SAC ≥ 0.20.15). Сервер тянет репозиторий с git, отдаёт zip по HTTPS; на ПК **не нужны** git и NETLOGON.
- при необходимости создаст/дополнит `login_monitor.settings.ps1`;
- проверит WinRM Operational и попробует включить канал. 1. Staging: `C:\ProgramData\RDP-login-monitor\_sac_staging`
2. `Deploy-LoginMonitor.ps1 -SourceShareRoot` (тот же алгоритм, что при GPO)
3. Нужны: WinRM, domain admin в SAC, доступ ПК к `SAC_PUBLIC_URL`, `rdp_git_repo_url` в **Настройки → Обновления агентов**
Подробнее: [security-alert-center — agent-control-plane](https://git.papatramp.ru/PTah/security-alert-center/src/branch/main/docs/agent-control-plane.md) §4.3.
## События в SAC
| Источник | Тип SAC |
|----------|---------|
| Security 4624/4625 | `rdp.login.*` |
| Security 5140 | `smb.admin_share.access` |
| WinRM Operational 91 | `winrm.session.started` |
| RD Gateway 302/303 | `rdg.connection.*` → flap 302→303 в SAC |
| Security 4740 (КД) | `auth.account.locked` |
| Агент | `agent.heartbeat`, `report.daily.rdp`, `agent.inventory` |
**SAC:** `Sac-Client.ps1`, `$UseSAC` — [agent-integration.md](https://git.papatramp.ru/PTah/security-alert-center/src/branch/main/docs/agent-integration.md). Poll команд `qwinsta`/`logoff` (≥ 2.1.0-SAC).
## Ключевые файлы
| Файл | Назначение |
|------|------------|
| `Login_Monitor.ps1` | Основной цикл |
| `Sac-Client.ps1` | Отправка в SAC |
| `Deploy-LoginMonitor.ps1` | NETLOGON, задачи, WinRM self-heal |
| `login_monitor.settings.ps1` | Секреты и параметры (локально) |
## Быстрые проверки
Проверка после установки (1 команда):
```powershell ```powershell
Get-Content "C:\ProgramData\RDP-login-monitor\Logs\login_monitor.log" -Tail 60 Get-Content "C:\ProgramData\RDP-login-monitor\Logs\login_monitor.log" -Tail 60
Login_Monitor.ps1 -CheckSac
``` ```
## Актуальная схема (рекомендуется) ## Документация
- Базовый путь установки: **`C:\ProgramData\RDP-login-monitor\`**. - [Docs/README.md](Docs/README.md) — развёртывание, Exchange
- Основной скрипт: **`Login_Monitor.ps1`** — журнал Security **`4624`/`4625`** (логика зависит от типа ОС: рабочая станция или сервер/КД), при всплеске **`4625`** — **агрегированные оповещения** (два порога: IP+пользователь и только IP), при наличии журнала — **Remote Connection Manager `1149`** (часто актуально для РС с RDP), **RDS Shadow Control** (`20506`/`20507`/`20510`, severity warning), **WinRM inbound / Enter-PSSession** (Operational `91`, severity warning), **admin share `C$`/`ADMIN$`** (Security **5140**, severity warning), при роли **RD Gateway****`302`/`303`**, на **КД, где запущен монитор** (имя совпадает с **`$LockoutMonitorDomainController`**) — **`4740`** (блокировка УЗ + IP из IIS ActiveSync), **ежедневный отчёт** (активные сессии через `quser`), **heartbeat**, **ротация логов**, уведомления в Telegram и/или Email. - [security-alert-center](https://git.papatramp.ru/PTah/security-alert-center) — сервер, RDG flap, qwinsta через SAC UI/Seaca
- Установка задач: запуск **`Login_Monitor.ps1 -InstallTasks`** создаёт:
- `RDP-Login-Monitor` (основной монитор),
- `RDP-Login-Monitor-Watchdog` (контроль процесса каждые 5 минут).
- Доменная доставка и обновления: **`Deploy-LoginMonitor.ps1`** + **`version.txt`** с шары `NETLOGON`. После успешного деплоя в приветственном сообщении (Telegram/Email) может появиться отметка об обновлении (файл **`deploy_last_update.txt`** рядом с логами).
- Документация по развёртыванию: **[Docs/README.md](Docs/README.md)** (RDP-монитор, Exchange, NETLOGON).
- **`Encrypt-DpapiForRdpMonitor.ps1`** — опционально для подготовки DPAPI-строк токена/chat id и пароля SMTP (`$MailSmtpPasswordProtectedB64` в файле настроек).
- **Локальные настройки RDP-монитора:** **`login_monitor.settings.ps1`** в каталоге установки (образец **`login_monitor.settings.example.ps1`**). При автообновлении **`Login_Monitor.ps1`** с шары файл настроек **не перезаписывается** (как **`exchange_monitor.settings.ps1`** для Exchange).
- **Security Alert Center (SAC):** модуль **`Sac-Client.ps1`** (копируется вместе с `Login_Monitor.ps1`). Режимы **`$UseSAC`**: `off` | `exclusive` | `dual` | `fallback` — контракт в репозитории **security-alert-center** (`docs/agent-integration.md`). Версия релиза: **`$ScriptVersion`** и **`version.txt`** (сейчас **2.0.20-SAC**); **`Sac-Client.ps1`** передаёт ту же версию в SAC (`product_version`).
### Highlights (события в SAC / Telegram)
| Источник | Что ловится | Тип SAC (пример) |
|----------|-------------|------------------|
| Security 4624/4625 | RDP/RDS входы | `rdp.login.success` / `rdp.login.failed` |
| Security **5140** | Доступ к **`C$`**, **`ADMIN$`** | `smb.admin_share.access` |
| WinRM Operational 91 | Enter-PSSession / PS Remoting | `winrm.session.started` |
| RCM Operational | RDS Shadow Control | `rdp.shadow.control.*` |
| RD Gateway | 302/303 | `rdg.connection.*` |
| Security 4740 | Блокировка УЗ (на заданном КД) | `auth.account.locked` |
| Агент | Heartbeat, отчёт, lifecycle | `agent.heartbeat`, `report.daily.rdp` |
## Что изменилось (важное)
- **Локальные настройки RDP-монитора** вынесены в **`login_monitor.settings.ps1`** (образец **`login_monitor.settings.example.ps1`**). При автообновлении **`Login_Monitor.ps1`** с шары секреты и параметры КД **не слетают**. Deploy при отсутствии settings создаёт файл один раз из example на шаре.
- **Кодировка `.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` вызывается по полному пути `%SystemRoot%\System32\auditpol.exe` (без зависимости от PATH), stdout+stderr объединяются через `ProcessStartInfo`.
- **Агрегация 4625 (брутфорс)**: при включённом `$FailedLogonRateLimitEnabled` — уровень 1: **5** неудачных попыток за **60** с с одного источника для **одной** учётной записи (IP+user); уровень 2: **12** попыток за **60** с с одного IP (несколько логинов). Пока порог не достигнут — поштучные 4625; при всплеске — сводные алерты, одиночные подавляются. Параметры в начале `Login_Monitor.ps1`. Автоблокировка IP не выполняется.
- **Exchange Mail Security** (`Exchange-MailSecurity.ps1`): на **сервере Exchange** — очереди, пересылка на внешние адреса, watchdog. Руководство: **[Docs/exchange-mail-security.md](Docs/exchange-mail-security.md)**. GPO **`Deploy-LoginMonitor.ps1`** на MailServer ставит тот же RDP-монитор и дописывает WinRM/4624 noise в **`login_monitor.settings.ps1`**; **`Deploy-DomainMonitors.ps1 -Target Exchange`** — отдельно.
- **Admin share 5140 (2.0.19+):** Security **5140** для **`C$`/`ADMIN$`** (audit File Share); SAC `smb.admin_share.access`; подавление в `ignore.lst` (`smb:` / `5140:`).
- **WinRM / Enter-PSSession (2.0.4+):** улучшен парсинг события `Microsoft-Windows-WinRM/Operational` (`ID=91`) — корректно извлекаются `user` и `clientIP` из EventData/Message fallback, событие отправляется в SAC как `winrm.session.started`.
- **Deploy WinRM self-heal (2.0.3+):** `Deploy-LoginMonitor.ps1` проверяет/дописывает обязательный блок WinRM inbound в `login_monitor.settings.ps1`, диагностирует доступность канала `Microsoft-Windows-WinRM/Operational` и пытается включить его через `wevtutil`.
- **Диагностика skip WinRM (2.0.5+):** в логах пишется точная причина `Skip WinRM 91` (`empty-user`, `excluded-user-pattern`, `machine-account`, `local-or-linklocal-ip`, `ignore-list-match`), что упрощает troubleshooting.
- **WinRM Exchange strict (2.0.23+):** на сервере с ролью Exchange — WinRM **91** без user в EventData/Properties не алертится; корреляция Security **4624** только при `LogonProcess WinRM` (отсекает ложные связки с Outlook/почтовым LT3). Причины skip: `exchange-winrm-no-user-in-event`, `exchange-winrm-unresolved-uri-no-ip`. Deploy дописывает `$WinRmExchangeStrictMode = 1`.
## 1) Подготовка
1. Подготовьте папку установки:
- `C:\ProgramData\RDP-login-monitor\`
2. Скопируйте в неё как минимум:
- `Login_Monitor.ps1`
- `Sac-Client.ps1`
- `login_monitor.settings.example.ps1` → переименуйте в **`login_monitor.settings.ps1`** и задайте параметры (см. п. 3)
- (для доменного развёртывания отдельно на шаре) `Deploy-LoginMonitor.ps1`, `version.txt` и `login_monitor.settings.example.ps1`
3. Настройте **`C:\ProgramData\RDP-login-monitor\login_monitor.settings.ps1`** (не редактируйте секреты в `Login_Monitor.ps1` — они перезапишутся при деплое):
- **Telegram:** `$TelegramBotToken` / `$TelegramChatID` или `...ProtectedB64`
- **Email (SMTP):** `$MailSmtpHost`, `$MailFrom`, `$MailTo`, `$MailSmtpPort` (по умолчанию 587), при необходимости `$MailSmtpUser` / `$MailSmtpPassword` (или `$MailSmtpPasswordProtectedB64` через DPAPI), `$MailSmtpStartTls` / `$MailSmtpSsl`
- **Порядок:** `$NotifyOrder` — пусто = авто (Telegram → Email, только настроенные); иначе `telegram,email` или `email` и т.п. (допускаются `tg`, `mail`)
- **SAC (опционально):** `$UseSAC`, `$SacUrl`, `$SacApiKey` — см. блок в **`login_monitor.settings.example.ps1`**. Проверка: `Login_Monitor.ps1 -CheckSac`
- **IP хоста для SAC (опционально):** `$ServerIPv4` — явный IPv4 для `host.ipv4`; если не задан, берётся автоопределение
- **Exchange-шум 4624 (опционально):** `${Ignore4624-LT3-EmptyIP-Event} = $true` — подавляет `4624` c `LogonType=3` и `IP='-'` (часто Outlook/SMTP-клиенты на почтовом сервере)
- Пошаговое обновление по домену: **[Docs/deploy-rdp-login-monitor.md](Docs/deploy-rdp-login-monitor.md)** (раздел «Обновление на любой Windows-машине»)
4. Запускайте с правами администратора (чтение `Security` журнала и регистрация задач).
5. Логи и служебные файлы будут в:
- `C:\ProgramData\RDP-login-monitor\Logs\`
6. (Опционально) Подавление части алертов по списку — см. раздел **«7) ignore.lst»** ниже.
7. (Опционально) Мониторинг блокировок AD на КД — в **`login_monitor.settings.ps1`**: **`$LockoutMonitorDomainController`** (короткое имя узла, на котором **установлен и запущен** монитор), **`$NetBiosDomainName`**, **`$ExchangeIisLogPath`**, **`$ExchangeIisLogMinutesBeforeLockout`** (по умолчанию 30), **`$ExchangeIisLogTailLines`** (по умолчанию 5000), **`$ExchangeServerHostForIisExclude`**. В оповещении: пользователь из 4740 и IP из IIS за окно до блокировки. В **`ignore.lst`** префикс **`4740:`** или **`all:`** — см. **`ignore.lst.example`**.
8. Heartbeat: при отсутствии обновления **`Logs\last_heartbeat.txt`** дольше **`$HeartbeatStaleAlertMultiplier` × `$HeartbeatInterval`** (по умолчанию 2×1 ч) — оповещение в Telegram/Email.
## 2) Ручной запуск
Используйте этот вариант для быстрой проверки старта/логики без установки задач планировщика.
```powershell
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "C:\ProgramData\RDP-login-monitor\Login_Monitor.ps1"
```
Примечание: при ручном запуске монитор работает в текущей сессии до остановки (например, `Ctrl+C`).
## 3) Запуск через Планировщик заданий (Task Scheduler)
Текущая схема: вручную задачи в GUI создавать не нужно.
Достаточно запустить:
```powershell
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "C:\ProgramData\RDP-login-monitor\Login_Monitor.ps1" -InstallTasks
```
Скрипт сам зарегистрирует `RDP-Login-Monitor` и `RDP-Login-Monitor-Watchdog`, а также запросит немедленный первый запуск задач.
Для доменной установки/обновления с шары вручную ничего в планировщике на клиенте настраивать не требуется: используйте `Deploy-LoginMonitor.ps1` (подробно в [Docs/deploy-rdp-login-monitor.md](Docs/deploy-rdp-login-monitor.md)).
## 4) Что проверять после запуска
- Логи:
- `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` обновляется по интервалу **`$HeartbeatInterval`** (по умолчанию раз в час).
- Ежедневный отчёт: после первого прохождения дневного слота (по умолчанию **09:00**, задаётся **`$DailyReportHour`** / **`$DailyReportMinute`** в `Login_Monitor.ps1`) уходит сводка по **`quser`** (Telegram/Email); метка последнего отчёта — `Logs\last_daily_report.txt`.
- Stale heartbeat: если **`last_heartbeat.txt`** не обновлялся дольше **`$HeartbeatStaleAlertMultiplier` × `$HeartbeatInterval`** — оповещение в Telegram/Email (см. п. 8 подготовки).
- При старте в Telegram/Email: строка **«Каналы уведомлений»** (фактический порядок доставки), плюс режим RDS/4740 по конфигурации.
- Telegram при старте: при установленном **RD Session Host** (или аналогичных компонентах RDS, не только шлюз) — строка про входы по RDP/RDS на этом сервере; при доступном журнале **RD Gateway** — отдельная строка про подключения к **внутренним целевым ПК** через шлюз (302/303). Узел только с ролью RD Gateway не дублирует формулировку «хост сессий».
- **Дубли Telegram на один RDP-вход:** Windows часто пишет **несколько 4624** с одним временем; с версии **1.2.18-SAC** второе уведомление за **`$LoginSuccessNotifyDedupSeconds`** (90 с) подавляется (`Notify dedup 4624` в логе).
- **В логе нет `Notify`, но 4624 в Security есть:** монитор обрабатывает только события **после** своего `StartTime` (окно опроса ~10 с при старте). Ищите строки **`Skip 4624:`** (фильтр LogonType / ignore.lst). Диагностика: **`tools\Show-Rdp4624Recent.ps1`**.
## 4.1) Контрольный список стабилизации (5 минут)
После деплоя на сервер быстро проверьте, что WinRM/Enter-PSSession действительно проходит весь путь до SAC:
1. **WinRM Operational включён**
```powershell
Get-WinEvent -ListLog 'Microsoft-Windows-WinRM/Operational' |
Select-Object LogName, IsEnabled, RecordCount
```
2. **Тестовый вход с другой машины**
```powershell
Enter-PSSession -ComputerName <SERVER> -Credential <DOMAIN\USER>
```
3. **На целевом сервере есть событие ID=91**
```powershell
Get-WinEvent -LogName 'Microsoft-Windows-WinRM/Operational' -MaxEvents 50 |
Where-Object Id -eq 91 |
Select-Object TimeCreated, Id, Message
```
4. **В логе агента нет ложного skip**
```powershell
Get-Content 'C:\ProgramData\RDP-login-monitor\Logs\login_monitor.log' -Tail 120 |
Select-String -Pattern 'WinRM 91|Skip WinRM|reason='
```
Для версии `2.0.5-SAC+` при skip указывается конкретная причина: `empty-user`, `excluded-user-pattern`, `machine-account`, `local-or-linklocal-ip`, `ignore-list-match`.
5. **Событие ушло в SAC**
- В UI SAC (`/events`) есть запись типа `winrm.session.started` от нужного хоста.
Минимальная настройка шумоподавления (если нужно):
- не используйте `all:` в `ignore.lst` для рабочих админских учёток;
- для WinRM подавление задавайте адресно: `winrm:user:DOMAIN\\service` или `winrm:ip:x.x.x.x`;
- при проверке troubleshooting временно отключайте лишние исключения и проверяйте повторно.
## 5) Автоматический перезапуск при падении
Режим `-Watchdog` внутри `Login_Monitor.ps1` делает:
- проверяет, есть ли процесс `powershell.exe/pwsh.exe` с `Login_Monitor.ps1` в командной строке;
- если процесса нет — запускает монитор;
- если монитор уже есть — не дублирует экземпляр.
## 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).
- **`Diagnose-RdpLoginMonitor.ps1`** — сбор диагностики после RDP-входа (Security 4624/4778, хвост `login_monitor.log`, симуляция фильтров монитора). Отчёт в **`Logs\diagnose_*.txt`**. Запуск:
```powershell
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "C:\ProgramData\RDP-login-monitor\Diagnose-RdpLoginMonitor.ps1" -MinutesBack 15 -ExpectedUser "ваш_логин"
```
- **`login_monitor.settings.example.ps1`** — образец **`login_monitor.settings.ps1`** (Telegram, SMTP, 4740, локальные IP-исключения). Deploy при первой установке может создать `login_monitor.settings.ps1` из example автоматически.
## 7) Подавление уведомлений Security: `ignore.lst`
В каталоге установки можно положить файл **`C:\ProgramData\RDP-login-monitor\ignore.lst`** (рядом с **`Login_Monitor.ps1`**). По умолчанию правила относятся к **`4624`/`4625`**; префикс **`4740:`** (или **`lockout:`**, **`блокир:`**) — только к блокировкам учётной записи; **`all:`** — и входы, и **4740**. Для **4740** тип **`ip:`** сравнивается с IP из IIS ActiveSync. Жёсткие исключения в скрипте по-прежнему для всех типов событий, кроме **4740** (там только `ignore.lst` и встроенные проверки пользователя).
События **RD Gateway (`302`/`303`)**, **RCM `1149`**, ежедневный отчёт и heartbeat **этим файлом не настраиваются**.
### Как читается файл
- Чтение выполняется по мере обработки событий; содержимое **кэшируется в памяти**. Если **`LastWriteTimeUtc`** файла изменился (редактирование и сохранение), список **перечитывается автоматически** — перезапуск монитора не обязателен.
- Кодировка: **UTF-8** (`Get-Content -Encoding UTF8`). Строка может начинаться с BOM — он отбрасывается при разборе.
- Пустые строки пропускаются. Строки, начинающиеся с **`#`** или **`;`**, считаются комментариями.
- Строка с **`:`**: берётся **первая** двоеточие — всё слева (после обрезки пробелов) определяет тип правила, всё справа — значение. Если справа пусто, строка игнорируется.
- Строка **без** **`:`**: целиком трактуется как правило типа «любое совпадение» (см. ниже).
### Префикс области (в самом начале строки, до типа правила)
| Префикс | События |
| --- | --- |
| *(нет)* | **4624**, **4625** |
| `4740:`, `lockout:`, `блокир:` | **4740** |
| `all:`, `*:` | **4624**, **4625**, **4740** |
Пример: `4740:user:svc_sync` — не слать оповещение о блокировке этой УЗ.
### Типы правил (левая часть до первого `:` после префикса области)
| Левая часть (фрагменты совпадают как 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`** и **`login_monitor.settings.ps1`** **не копирует и не перезаписывает** — правила и секреты локальны; при отсутствии settings Deploy создаёт его один раз из **`login_monitor.settings.example.ps1`** на шаре.
## Ключевые слова (для поиска репозитория)
`rdp`, `rd-gateway`, `rdp-gateway`, `rds`, `remote-desktop`, `windows-security-log`, `eventlog`, `event-id-4624`, `event-id-4625`, `event-id-4740`, `event-id-302`, `event-id-303`, `account-lockout`, `active-sync`, `exchange`, `iis`, `smtp`, `email`, `powershell`, `telegram-bot`, `watchdog`, `gpo`, `netlogon`, `domain-deployment`, `windows-server`, `monitoring`
## English
See repository docs; agent contract is in SAC `docs/agent-integration.md`.
+2
View File
@@ -1,5 +1,7 @@
# RDP Login Monitor # RDP Login Monitor
**Version:** `2.1.5-SAC` (`$ScriptVersion` + `version.txt`)
PowerShell toolkit for monitoring Windows logons with Telegram and/or Email (SMTP) notifications. PowerShell toolkit for monitoring Windows logons with Telegram and/or Email (SMTP) notifications.
## Recommended layout ## Recommended layout
+398 -13
View File
@@ -1,4 +1,4 @@
<# <#
.SYNOPSIS .SYNOPSIS
Клиент Security Alert Center для RDP-login-monitor. Клиент Security Alert Center для RDP-login-monitor.
.DESCRIPTION .DESCRIPTION
@@ -430,7 +430,7 @@ function Test-SacHealth {
$base = Get-SacBaseUrl $base = Get-SacBaseUrl
if ([string]::IsNullOrWhiteSpace($base)) { return $false } if ([string]::IsNullOrWhiteSpace($base)) { return $false }
$timeout = if ($SacTimeoutSec) { [int]$SacTimeoutSec } else { 12 } $timeout = Get-SacTimeoutSecResolved
try { try {
Invoke-SacTlsPrep Invoke-SacTlsPrep
$resp = Invoke-WebRequest -Uri "$base/health" -Method Get -UseBasicParsing -TimeoutSec $timeout $resp = Invoke-WebRequest -Uri "$base/health" -Method Get -UseBasicParsing -TimeoutSec $timeout
@@ -474,6 +474,84 @@ function Move-SacSpoolToRejected {
Move-Item -LiteralPath $src -Destination $dst -Force -ErrorAction SilentlyContinue Move-Item -LiteralPath $src -Destination $dst -Force -ErrorAction SilentlyContinue
} }
function Test-SacGuidString {
param([string]$Value)
if ([string]::IsNullOrWhiteSpace($Value)) { return $false }
return ($Value.Trim() -match '^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$')
}
function Get-SacEventIdFromSpoolFileName {
param([string]$Path)
if ([string]::IsNullOrWhiteSpace($Path)) { return $null }
$base = [System.IO.Path]::GetFileNameWithoutExtension($Path)
if (Test-SacGuidString -Value $base) {
return $base.ToLowerInvariant()
}
return $null
}
function Get-SacEventIdFromJsonText {
param([string]$JsonText)
if ([string]::IsNullOrWhiteSpace($JsonText)) { return $null }
if ($JsonText -match '"event_id"\s*:\s*"([0-9a-fA-F-]{36})"') {
return $Matches[1].ToLowerInvariant()
}
return $null
}
function Test-SacSpoolBytesCorrupt {
param([byte[]]$Bytes)
if ($null -eq $Bytes -or $Bytes.Length -lt 2) { return $true }
# UTF-16/UTF-32 с нулевым стартом или «пустой» spool — не JSON (см. hex 00 00 00 00).
if ($Bytes[0] -eq 0 -and $Bytes[1] -eq 0) { return $true }
return $false
}
function Read-SacSpoolFileBytes {
param([Parameter(Mandatory = $true)][string]$Path)
return [System.IO.File]::ReadAllBytes($Path)
}
function Read-SacSpoolFileText {
param([Parameter(Mandatory = $true)][string]$Path)
$bytes = Read-SacSpoolFileBytes -Path $Path
if (Test-SacSpoolBytesCorrupt -Bytes $bytes) { return $null }
if ($bytes.Length -ge 2 -and $bytes[0] -eq 0xFF -and $bytes[1] -eq 0xFE) {
return [System.Text.Encoding]::Unicode.GetString($bytes)
}
if ($bytes.Length -ge 2 -and $bytes[0] -eq 0x7B -and $Bytes[1] -eq 0) {
return [System.Text.Encoding]::Unicode.GetString($bytes)
}
$utf8 = New-Object System.Text.UTF8Encoding $false
return $utf8.GetString($bytes)
}
function Move-SacSpoolFileToRejected {
param(
[Parameter(Mandatory = $true)][string]$SpoolFilePath,
[string]$Reason = ''
)
$eventId = Get-SacEventIdFromSpoolFileName -Path $SpoolFilePath
if ($Reason) {
$label = if ($eventId) { "$eventId.json" } else { [System.IO.Path]::GetFileName($SpoolFilePath) }
Write-SacLog "WARN: SAC spool → rejected ($Reason): $label"
}
if ($eventId) {
Move-SacSpoolToRejected -EventId $eventId
return
}
$dir = Get-SacSpoolDirResolved
$name = [System.IO.Path]::GetFileName($SpoolFilePath)
$src = Join-Path $dir $name
if (-not (Test-Path -LiteralPath $src)) { return }
$rejDir = Join-Path $dir 'rejected'
if (-not (Test-Path -LiteralPath $rejDir)) {
New-Item -ItemType Directory -Path $rejDir -Force | Out-Null
}
$dst = Join-Path $rejDir $name
Move-Item -LiteralPath $src -Destination $dst -Force -ErrorAction SilentlyContinue
}
function Get-SacPostBodyBytes { function Get-SacPostBodyBytes {
param([string]$JsonText) param([string]$JsonText)
$bytes = Get-SacUtf8Bytes -Text $JsonText $bytes = Get-SacUtf8Bytes -Text $JsonText
@@ -588,7 +666,10 @@ function Write-SacPostBodyDiagnostic {
} }
function Invoke-SacPostPayload { function Invoke-SacPostPayload {
param([string]$JsonBody) param(
[string]$JsonBody,
[string]$SpoolFilePath = ''
)
if (-not (Test-SacConfigured)) { return $false } if (-not (Test-SacConfigured)) { return $false }
if (-not (Test-SacShouldAttemptSend)) { return $false } if (-not (Test-SacShouldAttemptSend)) { return $false }
@@ -597,16 +678,20 @@ function Invoke-SacPostPayload {
if ([string]::IsNullOrWhiteSpace($ingest)) { return $false } if ([string]::IsNullOrWhiteSpace($ingest)) { return $false }
$jsonText = Repair-SacJsonText -Text (Get-SacSingleString -Value $JsonBody -Label 'spool payload') $jsonText = Repair-SacJsonText -Text (Get-SacSingleString -Value $JsonBody -Label 'spool payload')
if ($jsonText -notmatch '"event_id"\s*:\s*"([0-9a-fA-F-]{36})"') { $eventId = Get-SacEventIdFromJsonText -JsonText $jsonText
Write-SacLog 'WARN: SAC JSON has no event_id (uuid); skip POST' if (-not $eventId) {
if (-not [string]::IsNullOrWhiteSpace($SpoolFilePath)) {
Move-SacSpoolFileToRejected -SpoolFilePath $SpoolFilePath -Reason 'no event_id in JSON body'
} else {
Write-SacLog 'WARN: SAC JSON has no event_id (uuid); skip POST'
}
return $false return $false
} }
$eventId = $Matches[1]
$eventType = 'unknown' $eventType = 'unknown'
if ($jsonText -match '"type"\s*:\s*"([^"]+)"') { if ($jsonText -match '"type"\s*:\s*"([^"]+)"') {
$eventType = $Matches[1] $eventType = $Matches[1]
} }
$timeout = if ($SacTimeoutSec) { [int]$SacTimeoutSec } else { 12 } $timeout = Get-SacTimeoutSecResolved
$spoolOnFailure = $true $spoolOnFailure = $true
$bodyBytes = $null $bodyBytes = $null
@@ -726,6 +811,31 @@ function Test-SacHeartbeatOnlyEventType {
return ($EventType -eq 'agent.heartbeat') return ($EventType -eq 'agent.heartbeat')
} }
function Test-SacDailyReportEventType {
param([string]$EventType)
return ($EventType -in @('report.daily.rdp', 'report.daily.ssh'))
}
function Get-SacTimeoutSecResolved {
if ($SacTimeoutSec) {
$n = 0
if ([int]::TryParse([string]$SacTimeoutSec, [ref]$n) -and $n -gt 0) {
return $n
}
}
return 45
}
function Get-SacSpoolFlushMaxFilesResolved {
if (Get-Variable -Name SacSpoolFlushMaxFiles -ErrorAction SilentlyContinue) {
$n = 0
if ([int]::TryParse([string]$SacSpoolFlushMaxFiles, [ref]$n) -and $n -gt 0) {
return $n
}
}
return 50
}
function Merge-SacNotifyDetails { function Merge-SacNotifyDetails {
param( param(
[hashtable]$Details = $null, [hashtable]$Details = $null,
@@ -798,6 +908,20 @@ function Send-NotifyOrSac {
return (Send-SacEvent @sacEventArgs) return (Send-SacEvent @sacEventArgs)
} }
# Суточный отчёт — только SAC/spool; при сбое ingest не дублировать в локальный Telegram.
if (Test-SacDailyReportEventType -EventType $EventType) {
if ($mode -eq 'off') {
return $false
}
$merged = Merge-SacNotifyDetails -Details $Details -TelegramVia 'sac'
$sacEventArgs = Get-SacEventInvokeArgs -EventType $EventType -Severity $Severity -Title $Title -Summary $Summary -Details $merged -OccurredAt $OccurredAt
if (Send-SacEvent @sacEventArgs) {
return $true
}
Write-SacLog 'WARN: daily report не принят SAC — остаётся в spool (локальный Telegram пропущен)'
return $false
}
switch ($mode) { switch ($mode) {
'off' { 'off' {
return (Send-SacLocalChannels -TelegramMessage $TelegramMessage -EmailSubject $EmailSubject) return (Send-SacLocalChannels -TelegramMessage $TelegramMessage -EmailSubject $EmailSubject)
@@ -829,7 +953,11 @@ function Send-NotifyOrSac {
} }
function Invoke-SacFlushSpool { function Invoke-SacFlushSpool {
param([int]$MaxFiles = 20) param([int]$MaxFiles = 0)
if ($MaxFiles -le 0) {
$MaxFiles = Get-SacSpoolFlushMaxFilesResolved
}
$mode = Get-SacNormalizedMode $mode = Get-SacNormalizedMode
if ($mode -eq 'off') { return } if ($mode -eq 'off') { return }
@@ -838,21 +966,278 @@ function Invoke-SacFlushSpool {
$dir = Get-SacSpoolDirResolved $dir = Get-SacSpoolDirResolved
if (-not (Test-Path -LiteralPath $dir)) { return } if (-not (Test-Path -LiteralPath $dir)) { return }
$files = @(Get-ChildItem -LiteralPath $dir -Filter '*.json' -File -ErrorAction SilentlyContinue | Sort-Object LastWriteTime) $files = @(Get-ChildItem -LiteralPath $dir -Filter '*.json' -File -ErrorAction SilentlyContinue)
$count = 0 $daily = @()
$other = @()
foreach ($f in $files) { foreach ($f in $files) {
$eventType = ''
try {
$raw = Read-SacSpoolFileText -Path $f.FullName
if (-not [string]::IsNullOrWhiteSpace($raw)) {
$obj = $raw | ConvertFrom-Json -ErrorAction Stop
if ($null -ne $obj.type) { $eventType = [string]$obj.type }
}
} catch {
$eventType = ''
}
if (Test-SacDailyReportEventType -EventType $eventType) {
$daily += $f
} else {
$other += $f
}
}
$ordered = @(
@($daily | Sort-Object LastWriteTime)
@($other | Sort-Object LastWriteTime)
)
$count = 0
foreach ($f in $ordered) {
$count++ $count++
if ($count -gt $MaxFiles) { break } if ($count -gt $MaxFiles) { break }
try { try {
$utf8 = New-Object System.Text.UTF8Encoding $false $bytes = Read-SacSpoolFileBytes -Path $f.FullName
$json = [System.IO.File]::ReadAllText($f.FullName, $utf8) if (Test-SacSpoolBytesCorrupt -Bytes $bytes) {
Invoke-SacPostPayload -JsonBody $json | Out-Null Move-SacSpoolFileToRejected -SpoolFilePath $f.FullName -Reason 'null or empty payload'
continue
}
$json = Read-SacSpoolFileText -Path $f.FullName
if ([string]::IsNullOrWhiteSpace($json)) {
Move-SacSpoolFileToRejected -SpoolFilePath $f.FullName -Reason 'unreadable payload'
continue
}
Invoke-SacPostPayload -JsonBody $json -SpoolFilePath $f.FullName | Out-Null
} catch { } catch {
Write-SacLog "WARN: SAC spool flush failed for $($f.Name): $($_.Exception.Message)" Write-SacLog "WARN: SAC spool flush failed for $($f.Name): $($_.Exception.Message)"
} }
} }
} }
function Get-SacAgentCommandsUrl {
$base = Get-SacBaseUrl
if ([string]::IsNullOrWhiteSpace($base)) { return $null }
return "$base/api/v1/agent/commands"
}
function Get-SacAgentCommandResultUrl {
param([Parameter(Mandatory = $true)][string]$CommandId)
$base = Get-SacBaseUrl
if ([string]::IsNullOrWhiteSpace($base)) { return $null }
return "$base/api/v1/agent/commands/$CommandId/result"
}
function Invoke-SacHttpGet {
param(
[Parameter(Mandatory = $true)][string]$Uri,
[int]$TimeoutSec = 45
)
Invoke-SacTlsPrep
try {
$resp = Invoke-WebRequest -Uri $Uri -Method Get -UseBasicParsing -TimeoutSec $TimeoutSec `
-Headers @{ Authorization = "Bearer $SacApiKey" }
return @{ StatusCode = [int]$resp.StatusCode; Content = [string]$resp.Content }
} catch {
$code = 0
$content = ''
if ($_.Exception.Response) {
$code = [int]$_.Exception.Response.StatusCode
try {
$reader = New-Object System.IO.StreamReader($_.Exception.Response.GetResponseStream())
$content = $reader.ReadToEnd()
$reader.Close()
} catch { }
}
return @{ StatusCode = $code; Content = $content; Error = $_.Exception.Message }
}
}
function Invoke-SacHttpPostJson {
param(
[Parameter(Mandatory = $true)][string]$Uri,
[Parameter(Mandatory = $true)][string]$JsonBody,
[int]$TimeoutSec = 45
)
Invoke-SacTlsPrep
$bytes = Get-SacUtf8Bytes -Text $JsonBody
try {
$resp = Invoke-WebRequest -Uri $Uri -Method Post -UseBasicParsing -TimeoutSec $TimeoutSec `
-Headers @{ Authorization = "Bearer $SacApiKey" } `
-ContentType 'application/json; charset=utf-8' `
-Body $bytes
return @{ StatusCode = [int]$resp.StatusCode; Content = [string]$resp.Content }
} catch {
$code = 0
$content = ''
if ($_.Exception.Response) {
$code = [int]$_.Exception.Response.StatusCode
try {
$reader = New-Object System.IO.StreamReader($_.Exception.Response.GetResponseStream())
$content = $reader.ReadToEnd()
$reader.Close()
} catch { }
}
return @{ StatusCode = $code; Content = $content; Error = $_.Exception.Message }
}
}
function Invoke-SacCaptureProcess {
param(
[Parameter(Mandatory = $true)][string]$CommandLine
)
$psi = New-Object System.Diagnostics.ProcessStartInfo
$psi.FileName = 'cmd.exe'
$psi.Arguments = "/c $CommandLine"
$psi.UseShellExecute = $false
$psi.RedirectStandardOutput = $true
$psi.RedirectStandardError = $true
$psi.CreateNoWindow = $true
$p = New-Object System.Diagnostics.Process
$p.StartInfo = $psi
[void]$p.Start()
$stdout = $p.StandardOutput.ReadToEnd()
$stderr = $p.StandardError.ReadToEnd()
if (-not $p.WaitForExit(60000)) {
try { $p.Kill() } catch { }
return @{ ExitCode = -1; Stdout = $stdout; Stderr = 'timeout' }
}
return @{ ExitCode = $p.ExitCode; Stdout = $stdout; Stderr = $stderr }
}
function Invoke-SacRunWithRunAs {
param(
[Parameter(Mandatory = $true)][string]$CommandLine,
$RunAs = $null
)
if ($null -eq $RunAs -or [string]::IsNullOrWhiteSpace($RunAs.user)) {
return (Invoke-SacCaptureProcess -CommandLine $CommandLine)
}
$user = [string]$RunAs.user
$password = [string]$RunAs.password
if ([string]::IsNullOrWhiteSpace($password)) {
return (Invoke-SacCaptureProcess -CommandLine $CommandLine)
}
$taskName = "SacCmd_$([guid]::NewGuid().ToString('N').Substring(0, 12))"
$outFile = Join-Path $env:TEMP "$taskName.out.txt"
$errFile = Join-Path $env:TEMP "$taskName.err.txt"
if (Test-Path -LiteralPath $outFile) { Remove-Item -LiteralPath $outFile -Force -ErrorAction SilentlyContinue }
if (Test-Path -LiteralPath $errFile) { Remove-Item -LiteralPath $errFile -Force -ErrorAction SilentlyContinue }
$wrapped = "/c `"$CommandLine > `"$outFile`" 2> `"$errFile`"`""
try {
$action = New-ScheduledTaskAction -Execute 'cmd.exe' -Argument $wrapped
Register-ScheduledTask -TaskName $taskName -Action $action -User $user -Password $password `
-RunLevel Highest -Force | Out-Null
Start-ScheduledTask -TaskName $taskName
$deadline = (Get-Date).AddSeconds(45)
do {
Start-Sleep -Milliseconds 300
$state = (Get-ScheduledTask -TaskName $taskName).State
} while ($state -eq 'Running' -and (Get-Date) -lt $deadline)
$stdout = if (Test-Path -LiteralPath $outFile) { Get-Content -LiteralPath $outFile -Raw -ErrorAction SilentlyContinue } else { '' }
$stderr = if (Test-Path -LiteralPath $errFile) { Get-Content -LiteralPath $errFile -Raw -ErrorAction SilentlyContinue } else { '' }
return @{ ExitCode = 0; Stdout = [string]$stdout; Stderr = [string]$stderr }
} catch {
return @{ ExitCode = 1; Stdout = ''; Stderr = $_.Exception.Message }
} finally {
Unregister-ScheduledTask -TaskName $taskName -Confirm:$false -ErrorAction SilentlyContinue
Remove-Item -LiteralPath $outFile, $errFile -Force -ErrorAction SilentlyContinue
}
}
function Invoke-SacAgentCommand {
param(
[Parameter(Mandatory = $true)]$Command,
$RunAs = $null
)
$type = [string]$Command.type
switch ($type) {
'qwinsta' {
return (Invoke-SacRunWithRunAs -CommandLine 'qwinsta.exe' -RunAs $RunAs)
}
'logoff' {
$sid = $null
if ($Command.params -and $Command.params.session_id) {
$sid = [int]$Command.params.session_id
}
if (-not $sid) {
return @{ ExitCode = 1; Stdout = ''; Stderr = 'missing session_id' }
}
$line = "logoff.exe $sid /v"
return (Invoke-SacRunWithRunAs -CommandLine $line -RunAs $RunAs)
}
default {
return @{ ExitCode = 1; Stdout = ''; Stderr = "unknown command type: $type" }
}
}
}
function Submit-SacAgentCommandResult {
param(
[Parameter(Mandatory = $true)][string]$CommandId,
[Parameter(Mandatory = $true)][string]$Status,
[string]$Stdout = '',
[string]$Stderr = ''
)
$url = Get-SacAgentCommandResultUrl -CommandId $CommandId
if ([string]::IsNullOrWhiteSpace($url)) { return $false }
$body = @{
status = $Status
stdout = $Stdout
stderr = $Stderr
} | ConvertTo-Json -Compress
$timeout = Get-SacTimeoutSecResolved
$resp = Invoke-SacHttpPostJson -Uri $url -JsonBody $body -TimeoutSec $timeout
return ($resp.StatusCode -in 200, 201)
}
function Invoke-SacProcessPendingCommands {
if ((Get-SacNormalizedMode) -eq 'off') { return 0 }
if (-not (Test-SacConfigured)) { return 0 }
$pollUrl = Get-SacAgentCommandsUrl
if ([string]::IsNullOrWhiteSpace($pollUrl)) { return 0 }
$agentId = Get-SacAgentInstanceId
$timeout = Get-SacTimeoutSecResolved
$uri = "${pollUrl}?agent_instance_id=$([uri]::EscapeDataString($agentId))"
$get = Invoke-SacHttpGet -Uri $uri -TimeoutSec $timeout
if ($get.StatusCode -ne 200) {
if ($get.StatusCode -gt 0) {
Write-SacLog "WARN: SAC agent commands poll HTTP $($get.StatusCode)"
}
return 0
}
$parsed = $null
try {
$parsed = $get.Content | ConvertFrom-Json
} catch {
Write-SacLog "WARN: SAC agent commands poll: invalid JSON"
return 0
}
$commands = @($parsed.commands)
if ($commands.Count -eq 0) { return 0 }
$done = 0
foreach ($cmd in $commands) {
$cmdId = [string]$cmd.id
if ([string]::IsNullOrWhiteSpace($cmdId)) { continue }
Write-SacLog "SAC agent command: $cmdId type=$($cmd.type)"
$runAs = $cmd.run_as
$result = Invoke-SacAgentCommand -Command $cmd -RunAs $runAs
$status = if ([int]$result.ExitCode -eq 0) { 'completed' } else { 'failed' }
$stderr = [string]$result.Stderr
if ([int]$result.ExitCode -ne 0 -and [string]::IsNullOrWhiteSpace($stderr)) {
$stderr = "exit code $($result.ExitCode)"
}
if (Submit-SacAgentCommandResult -CommandId $cmdId -Status $status `
-Stdout ([string]$result.Stdout) -Stderr $stderr) {
$done++
Write-SacLog "SAC agent command result submitted: $cmdId status=$status"
} else {
Write-SacLog "WARN: SAC agent command result failed: $cmdId"
}
}
return $done
}
function Test-SacConnection { function Test-SacConnection {
Write-Host 'SAC check (rdp-login-monitor)' Write-Host 'SAC check (rdp-login-monitor)'
Write-Host "UseSAC=$(Get-SacNormalizedMode)" Write-Host "UseSAC=$(Get-SacNormalizedMode)"
+4
View File
@@ -46,6 +46,10 @@ $DailyReportEnabled = 1
# --- Heartbeat SAC (agent.heartbeat): интервал в секундах; 14400 = 4 ч --- # --- Heartbeat SAC (agent.heartbeat): интервал в секундах; 14400 = 4 ч ---
$HeartbeatInterval = 14400 $HeartbeatInterval = 14400
# Оповещение, если last_heartbeat.txt не обновлялся > множитель × интервал (2 × 4 ч = 8 ч)
$HeartbeatStaleAlertMultiplier = 2
# Poll SAC на команды qwinsta/logoff (сек); см. security-alert-center/docs/agent-control-plane.md
# $SacCommandPollIntervalSec = 60
# Окно (мин): LastBootUpTime + System 41/1074/6005/6008/6009 → «старт после перезагрузки ОС» # Окно (мин): LastBootUpTime + System 41/1074/6005/6008/6009 → «старт после перезагрузки ОС»
$StartupRebootDetectMinutes = 5 $StartupRebootDetectMinutes = 5
+9 -9
View File
@@ -28,15 +28,15 @@ switch ($Target) {
$BaseHost = $Base -replace '^https://', '' $BaseHost = $Base -replace '^https://', ''
$patterns = @( $patterns = @(
@{ From = 'https://git.kalinamall.ru/PapaTramp/([^)/''"\s]+)/blob/main/'; To = "$Base/`$1$BlobSuffix/" } @{ From = 'https://git.papatramp.ru/PTah/([^)/''"\s]+)/blob/main/'; To = "$Base/`$1$BlobSuffix/" }
@{ From = 'https://git.kalinamall.ru/PapaTramp/([^)/''"\s]+)/src/branch/main/'; To = "$Base/`$1$BlobSuffix/" } @{ From = 'https://git.papatramp.ru/PTah/([^)/''"\s]+)/src/branch/main/'; To = "$Base/`$1$BlobSuffix/" }
@{ From = 'https://git.kalinamall.ru/PapaTramp/([^)/''"\s]+)/src/branch/main/'; To = "$Base/`$1$BlobSuffix/" } @{ From = 'https://git.papatramp.ru/PTah/([^)/''"\s]+)/src/branch/main/'; To = "$Base/`$1$BlobSuffix/" }
@{ From = 'https://git.kalinamall.ru/PapaTramp/'; To = "$Base/" } @{ From = 'https://git.papatramp.ru/PTah/'; To = "$Base/" }
@{ From = 'https://git.kalinamall.ru/PapaTramp/'; To = "$Base/" } @{ From = 'https://git.papatramp.ru/PTah/'; To = "$Base/" }
@{ From = 'https://git.kalinamall.ru/PapaTramp/'; To = "$Base/" } @{ From = 'https://git.papatramp.ru/PTah/'; To = "$Base/" }
@{ From = 'git.kalinamall.ru/PapaTramp/'; To = "$BaseHost/" } @{ From = 'git.papatramp.ru/PTah/'; To = "$BaseHost/" }
@{ From = 'git.kalinamall.ru/PapaTramp/'; To = "$BaseHost/" } @{ From = 'git.papatramp.ru/PTah/'; To = "$BaseHost/" }
@{ From = 'git.kalinamall.ru/PapaTramp/'; To = "$BaseHost/" } @{ From = 'git.papatramp.ru/PTah/'; To = "$BaseHost/" }
) )
$extensions = @('*.md', '*.json', '*.service', '*.example', '*.sh', '*.ps1', '*.yml', '*.yaml') $extensions = @('*.md', '*.json', '*.service', '*.example', '*.sh', '*.ps1', '*.yml', '*.yaml')
+1 -1
View File
@@ -1 +1 @@
2.0.38-SAC 2.1.9-SAC